PHP后端分布式锁的实现方案比较:从数据库到Redis的实战演进

作为一名在电商领域摸爬滚打多年的PHP开发者,我深刻体会到分布式锁在高并发场景下的重要性。记得去年双十一大促,我们的订单系统因为锁竞争问题出现了严重的超卖现象,那次惨痛经历让我对各种分布式锁方案有了更深入的研究。今天就来分享几种主流的PHP分布式锁实现方案,希望能帮助大家避开我踩过的那些坑。

为什么需要分布式锁?

在单体应用时代,我们使用PHP的flock文件锁或者MySQL的行锁就能解决并发问题。但随着业务发展,系统演进到分布式架构,多个PHP应用实例同时操作共享资源时,传统的单机锁就力不从心了。比如库存扣减、优惠券发放、订单创建等场景,都需要一个跨进程、跨机器的锁机制来保证数据一致性。

基于数据库的分布式锁实现

这是最传统的实现方式,利用数据库的唯一索引或行锁特性来实现。我们先来看基于唯一索引的方案:


class DatabaseLock
{
    private $pdo;
    
    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }
    
    public function acquire(string $lockName, int $timeout = 10): bool
    {
        $identifier = uniqid();
        $expireTime = time() + $timeout;
        
        try {
            $stmt = $this->pdo->prepare(
                "INSERT INTO distributed_locks (lock_name, identifier, expire_time) VALUES (?, ?, ?)"
            );
            $stmt->execute([$lockName, $identifier, $expireTime]);
            return true;
        } catch (PDOException $e) {
            // 锁已存在,检查是否过期
            $this->clearExpiredLock($lockName);
            return false;
        }
    }
    
    public function release(string $lockName, string $identifier): bool
    {
        $stmt = $this->pdo->prepare(
            "DELETE FROM distributed_locks WHERE lock_name = ? AND identifier = ?"
        );
        return $stmt->execute([$lockName, $identifier]);
    }
    
    private function clearExpiredLock(string $lockName): void
    {
        $stmt = $this->pdo->prepare(
            "DELETE FROM distributed_locks WHERE lock_name = ? AND expire_time < ?"
        );
        $stmt->execute([$lockName, time()]);
    }
}

这种方案的优点是实现简单,依赖少。但缺点也很明显:性能较差,数据库压力大,而且在主从架构下可能存在数据延迟问题。我曾经在生产环境使用这种方案,在QPS超过1000时,数据库连接数就撑不住了。

基于Redis的SETNX方案

Redis因为其高性能和丰富的数据结构,成为分布式锁的首选方案。最基本的实现是使用SETNX命令:


class RedisLock
{
    private $redis;
    
    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
    }
    
    public function acquire(string $lockKey, int $expire = 10): bool
    {
        $identifier = uniqid();
        
        // 使用SETNX尝试获取锁
        $result = $this->redis->setnx($lockKey, $identifier);
        
        if ($result) {
            // 设置过期时间,防止死锁
            $this->redis->expire($lockKey, $expire);
            return true;
        }
        
        return false;
    }
    
    public function release(string $lockKey, string $identifier): bool
    {
        // 验证是否是自己持有的锁
        if ($this->redis->get($lockKey) === $identifier) {
            return $this->redis->del($lockKey) > 0;
        }
        return false;
    }
}

这个方案比数据库方案性能好很多,但在实际使用中我发现了一个严重问题:SETNX和EXPIRE不是原子操作,如果在设置过期时间前进程崩溃,会导致死锁。这个坑让我在凌晨三点被报警叫醒过多次。

基于Redis的SET扩展方案

为了解决原子性问题,Redis 2.6.12之后提供了带选项的SET命令:


class RedisAdvancedLock
{
    private $redis;
    
    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
    }
    
    public function acquire(string $lockKey, int $expire = 10, int $retry = 3): bool
    {
        $identifier = uniqid();
        
        for ($i = 0; $i < $retry; $i++) {
            // 使用SET NX EX保证原子性
            $result = $this->redis->set(
                $lockKey, 
                $identifier, 
                ['NX', 'EX' => $expire]
            );
            
            if ($result) {
                return true;
            }
            
            if ($i < $retry - 1) {
                usleep(100000); // 等待100ms重试
            }
        }
        
        return false;
    }
    
    public function release(string $lockKey, string $identifier): bool
    {
        $script = "
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
        ";
        
        // 使用Lua脚本保证原子性
        $result = $this->redis->eval($script, [$lockKey, $identifier], 1);
        return $result > 0;
    }
}

这个方案解决了原子性问题,但还有一个隐患:如果业务执行时间超过锁的过期时间,会导致锁提前释放,其他进程可能获取到锁。我曾经因为一个复杂的库存计算逻辑执行了15秒,而锁只设置了10秒过期,导致了数据不一致。

Redlock算法实现

在Redis集群环境下,Redis作者提出了Redlock算法。这里我用PHP实现一个简化版本:


class RedLock
{
    private $redisInstances;
    
    public function __construct(array $redisConfigs)
    {
        foreach ($redisConfigs as $config) {
            $redis = new Redis();
            $redis->connect($config['host'], $config['port']);
            $this->redisInstances[] = $redis;
        }
    }
    
    public function acquire(string $lockKey, int $ttl = 10000): bool
    {
        $identifier = uniqid();
        $startTime = microtime(true) * 1000;
        $quorum = (int)(count($this->redisInstances) / 2) + 1;
        $lockedCount = 0;
        
        foreach ($this->redisInstances as $redis) {
            try {
                if ($redis->set($lockKey, $identifier, ['NX', 'PX' => $ttl])) {
                    $lockedCount++;
                }
            } catch (Exception $e) {
                // 忽略单个节点故障
                continue;
            }
            
            if ($lockedCount >= $quorum) {
                return true;
            }
        }
        
        // 获取锁失败,释放已获取的锁
        $this->release($lockKey, $identifier);
        return false;
    }
}

Redlock提供了更好的可靠性,但实现复杂,性能开销较大。在实际项目中,我们需要根据业务场景权衡选择。

实战经验与选型建议

经过多个项目的实践,我总结了一些选型建议:

1. 简单业务场景:使用Redis的SET NX EX方案,配合Lua脚本释放锁,这能满足90%的需求。

2. 高可用场景:如果对可靠性要求极高,可以考虑Redlock,但要接受性能损失。

3. 数据库方案:只有在没有Redis且并发量不大的情况下使用。

这里分享一个我在电商项目中使用的生产级代码:


class DistributedLockManager
{
    private $redis;
    private $locks = [];
    
    public function withLock(string $lockKey, callable $businessLogic, int $timeout = 10)
    {
        $lock = new RedisAdvancedLock($this->redis);
        $identifier = uniqid();
        
        if (!$lock->acquire($lockKey, $timeout)) {
            throw new RuntimeException("获取分布式锁失败: " . $lockKey);
        }
        
        try {
            $this->locks[$lockKey] = $identifier;
            return $businessLogic();
        } finally {
            $lock->release($lockKey, $identifier);
            unset($this->locks[$lockKey]);
        }
    }
}

// 使用示例
$lockManager = new DistributedLockManager($redis);
$result = $lockManager->withLock('order_create_123', function() {
    // 业务逻辑
    return createOrder();
});

这种封装方式确保了锁的正确释放,避免了因为异常导致的死锁问题。

总结

分布式锁没有银弹,每种方案都有其适用场景。在选择时,我们需要考虑业务的数据一致性要求、性能要求、系统复杂度等因素。从我个人的经验来看,Redis的SET NX EX方案+Lua脚本是最实用的选择,它在性能、可靠性和实现复杂度之间取得了很好的平衡。

最后提醒大家:分布式锁只是解决并发问题的一种手段,在设计系统时,我们还可以考虑使用乐观锁、队列、状态机等其他方案来避免锁竞争。记住,最好的锁就是不用锁!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。