第四章:分布式锁——基于Redis的分布式锁实现与PHP代码实战

说到分布式锁,我脑子里第一个蹦出来的场景就是:几年前我们团队做秒杀系统,库存只有100件,结果一瞬间涌进来10万请求。那时候还没用分布式锁,数据库直接被干趴下了。嗯,从那以后,我对分布式锁的敬畏心就上来了。

说白了,分布式锁就是解决一个核心问题:在分布式环境下,如何保证同一时间只有一个进程能访问共享资源。你想想看,单机环境下我们用文件锁、用synchronized就能搞定。但到了多台服务器,这些招数全都不好使了。

4.1 为什么需要分布式锁

我习惯用一个例子来解释:假设你有一个计数器,存在Redis里。两个PHP进程同时执行:

$redis->incr('counter');

这行代码在单机下没问题,因为incr是原子操作。但如果你要做的是:

$count = $redis->get('counter');
$count++;
$redis->set('counter', $count);

这就出事了。两个进程同时读到count=5,都加1,最后写回去还是6。本该是7的。这就是典型的竞态条件。

注意: 我曾经在项目中见过有人用SETNX加锁,但忘了设置过期时间。结果进程挂了,锁永远不释放,整个系统就卡死了。这种坑,踩一次就记住了。

4.2 Redis分布式锁的核心原理

Redis实现分布式锁,核心就三个操作:

  • 加锁:用SETNX或SET命令,设置一个唯一的key
  • 解锁:删除这个key,但要确保是锁的持有者才能删
  • 超时:设置过期时间,防止死锁

我个人习惯用SET命令的扩展参数,一步到位:

SET lock_key unique_value NX PX 30000

这行命令的意思是:如果key不存在就设置,过期时间30秒。NX保证原子性,PX设置过期时间。一步搞定,不用分两步走。

4.3 一个完整的PHP实现

来看一个我常用的实现。这个版本我用了好几年,线上跑得挺稳:

<?php
class RedisDistributedLock
{
    private $redis;
    private $lockKey;
    private $lockValue;
    private $expireTime = 30000; // 毫秒

    public function __construct($redis, $lockKey)
    {
        $this->redis = $redis;
        $this->lockKey = $lockKey;
        $this->lockValue = uniqid('', true);
    }

    public function acquire($timeout = 10000)
    {
        $start = microtime(true) * 1000;
        
        while (true) {
            $result = $this->redis->set(
                $this->lockKey,
                $this->lockValue,
                ['NX', 'PX' => $this->expireTime]
            );

            if ($result) {
                return true;
            }

            // 超时检查
            if ((microtime(true) * 1000 - $start) >= $timeout) {
                return false;
            }

            usleep(1000); // 1ms后重试
        }
    }

    public function release()
    {
        // 用Lua脚本保证原子性
        $script = <<<'LUA'
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
LUA;

        return $this->redis->eval($script, [$this->lockKey, $this->lockValue], 1);
    }
}
小技巧: 解锁时用Lua脚本判断value是否匹配,这个习惯是我从一次线上事故中学到的。不加这个判断,A进程可能把B进程的锁给解了,那画面太美不敢看。

4.4 使用示例

实际用起来很简单。我习惯这样写:

$lock = new RedisDistributedLock($redis, 'order:123:lock');

if ($lock->acquire(5000)) {
    try {
        // 处理订单
        processOrder(123);
    } finally {
        $lock->release();
    }
} else {
    echo "获取锁失败,请稍后重试";
}

这里有个细节:finally块里释放锁。不管业务代码抛不抛异常,锁都得释放。这个习惯帮我避免过好几次线上故障。

4.5 避坑指南

做分布式锁,坑不少。我一个个说:

常见问题 原因 解决方案
死锁 进程崩溃,锁没释放 设置过期时间,用PX参数
锁误删 A进程删了B进程的锁 解锁时校验value,用Lua脚本
锁过期 业务执行时间超过锁时间 启动守护线程自动续期
Redis宕机 主节点挂了,锁丢失 用RedLock算法,多节点加锁

我曾经遇到过最头疼的问题就是锁过期。业务处理了40秒,锁30秒就过期了。别的进程拿到锁,两个进程同时处理同一笔订单。后来我加了个自动续期的机制,每10秒检查一次,如果业务还在跑就续期。

4.6 性能优化建议

说几个我实际用下来的经验:

  • 锁粒度要小:别锁整个用户,锁具体某个订单就行
  • 超时时间要合理:太短容易误判,太长影响并发
  • 重试间隔别太短:1ms差不多了,太短会打满CPU
  • 考虑用RedLock:对一致性要求高的场景,用多个Redis节点

核心要点:

  • 加锁用SET NX PX,一步到位
  • 解锁用Lua脚本,校验value
  • 业务时间超过锁时间要续期
  • finally块里释放锁,别偷懒

嗯,分布式锁就这些了。说白了就是一套规则:谁先拿到锁谁干活,干完活记得还锁。但细节决定成败,每个环节都得想清楚。下次遇到高并发场景,试试这个方案,应该能帮你省不少心。