用 PHP 写一个 Redis 分布式锁避坑指南
分布式锁:看似简单,处处是坑
如果聊到 Redis,那分布式锁几乎是个绕不开的话题。面试喜欢问,生产环境也真会用到。很多人觉得,分布式锁不就是 `SETNX` 一下,完事了吗?真上手写一遍才发现,坑远比想象中多。今天不聊底层源码,就聊聊用 PHP 写 Redis 分布式锁时,你绝对会碰到的那几个坑。
掉进 SETNX 的老坑里
闭着眼搜“Redis 分布式锁”,搜索引擎大概率会给你推荐 `SETNX` 命令。如果你真的直接用了 `SETNX`,恭喜你,第一个坑已经踩进去了一半。
为什么?`SETNX` 命令本身没有过期时间的参数。一个典型的错误操作是:
$lock = $redis->setnx($key, $value);
if ($lock) {
// 业务代码
$redis->del($key);
}
这时如果有人进程拿到锁后,业务逻辑执行崩溃了,后面的 `DEL` 根本没机会执行。锁就永远不释放了——死锁。
也许你会说,那我设置一下过期时间不行吗?可以,但如果你用两条命令来实现,那又会掉进第二个坑:
$redis->setnx($key, $value);
$redis->expire($key, 10); // 原子性问题
`SETNX` 和 `EXPIRE` 不是原子操作。如果 `SETNX` 执行完,进程就挂掉了,`EXPIRE` 没跑,锁还是不会自动释放。正确做法,应该用 `SET` 命令配合扩展参数,一步到位,比如在 Redis 2.6.12+ 之后配合 `NX` 和 `EX` 参数:
$result = $redis->set($lockKey, $token, ['nx', 'ex' => 10]);
if ($result) {
// 拿到锁了
}
核心:加锁必须是原子的,且必须带过期时间。
误删别人锁的坑
假设你吸取教训,加了过期时间。但还有个更隐蔽的问题:你把锁删错了。
场景是这样的:线程 A 持有锁,设置过期时间是 10 秒。结果 A 业务执行特别慢,超过了 10 秒,锁自动过期了。这时线程 B 来了,它成功拿到了新锁。接着 A 终于执行完了,它执行 `del($lockKey)`——于是,A 把 B 刚拿到的锁给释放了。
这就乱套了。解决方案也很简单:删除锁之前,先判断这个锁的“持有者身份”。加锁时,给 value 生成一个唯一的 `token`(比如 `uniqid()`),删除时先比对一下 value 是否是自己设的那个值,是才删。这一步,最好用 Lua 脚本来保证原子性。
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
核心:解锁前,先验证身份再删除,避免误删他人锁。
过期时间到底设多久?
这是个哲学问题。设短了,业务没跑完锁就过期了,会导致并发问题;设长了,万一服务真崩了,得等很久锁才能被释放。
有些同学建议“做个看门狗”,像 Redisson 那样自动续期。可惜很多 PHP 生态里的客户端没有现成的看门狗机制,自己实现又有成本和风险。如果不上续期,那怎么尽量安全?
我的经验是:过期时间是根据业务最慢执行时间的估算值,还要乘一个安全系数。比如说代码平均执行 100ms,那就设成 1 秒。如果代码里嵌套外部 HTTP 调用,某次超时达到 10 秒,那这个锁等 10 秒以上才能释放,业务就被你坑了。
更稳妥的思路是:记得在业务代码里给网络请求设置上限超时时间,让它一定不能超过锁的过期时间。既然锁是保护共享资源的,那业务执行时长是硬约束,必须设计在锁的 TTL 内跑完。
核心:TTL 要覆盖业务最长执行时间,同时业务本身需要对慢请求做兜底。
主从复制导致的锁丢失问题
你写好了业务代码,终于没线上事故了。但如果你用的 Redis 是主从架构,也就是 Master-Slave,那么还藏着一个大坑:Master 挂掉时的锁丢失。
在线程 A 给 Master 写入锁,数据还没来得及同步到 Slave,Master 就突然宕机了。等哨兵重新进行主从切换,Slave 升级成了 Master,那个锁的数据其实还没有同步过去。这时线程 B 再来请求,它能轻松获取到同一个锁,分布式锁就失效了。
这种场景要根治,就得引用 `Redlock` 算法了。它要求向多个 Redis 节点写入锁,并且大多数节点同意才算成功。PHP 有现成的库,在网上能搜到,比如 `linianmin/redlock`,也有人基于 predator 实现。
不过 Redlock 也有自己的争议和复杂度。如果你们的业务不是极度严格的金融级强一致,那么可以用一种比较实用的折中方案:配置哨兵模式时,把 `min-slaves-max-lag` 设置成 1 秒,保证主写成功后至少有从有延迟 1 秒内同步。另一种做法是,干脆不要太纠结琐碎的分布式锁,而是用数据库的行锁 / `SELECT FOR UPDATE` 去替代低频写操作。分布式锁真正适合的是大流量高并发场景,但高并发场景用 Redis 主从又是硬伤。这确实是个两难的问题。
局部异常与锁释放的顺序
最后提一个很多人忽略的小细节:释放锁的代码记得放在 `finally` 块中。不要在执行完业务后直接 `return`,结果锁没释放。还有,如果释放锁的网络请求本身失败了(比如 Redis 连接超时),你需要在代码里用 `try/catch` 捕获异常后重试释放。不要小看这些异常,Redis 是快,但它毕竟是网络服务,照样会发生 `connect timeout`。
以及,如果 Redis 挂了怎么办?部分实操派的方案是做个熔断:只用本地单机锁做降级,牺牲一点一致性,但保证系统不雪崩。
结语
综合来看,一个可靠的 Redis 分布式锁,没有那么困难,但也绝对没那么简单。它是原子操作、过期时间、身份标识、容错策略和主从一致性几个问题的综合博弈。建议你在写这类代码时,尽量使用成熟的开源库,比如 `Symfony\Lock` 或者 `predis` 之上的 Lock 组件,别什么都自己造。毕竟坑都在那了,前人已经帮你趟过一遍了。
会写 PHP 不难,能把分布式锁写稳,才算真正理解了并发这门手艺。
年卡会员