用 PHP 写一个 Redis 分布式锁避坑指南

最长的电影
最长的电影 正式会员正式会员
发布于 2026-07-09 20:25 ·154 浏览 ·0 回复
内容摘要

用 PHP 实现 Redis 分布式锁时,应避免直接使用 SETNX 或分步 SETNX+EXPIRE,而要用 SET 配合 NX、EX 原子加锁并设置过期时间;解锁前须校验唯一 token 并用 Lua 原子删除,防止误删他人锁;过期时间需按业务最慢执行时间乘安全系数估算,并结合续期或业务层保护降低锁提前失效风险。

分布式锁:看似简单,处处是坑

如果聊到 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 不难,能把分布式锁写稳,才算真正理解了并发这门手艺。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-53.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~