ThinkPHP 6.0 中用 Redis SETNX 实现互斥锁需满足三原则:加锁必须原子(用 SET key val NX PX 替代分步操作)、解锁须校验身份(Lua 脚本比对值后删除)、超时时间应按业务 P95 耗时+缓冲合理设置,避免死锁与误删。

ThinkPHP 6.0 中用 Redis 的 SETNX 实现互斥锁,核心目标是解决多进程/多实例并发访问同一资源时的冲突问题,比如重复下单、缓存击穿、定时任务重复执行等。它轻量、易上手,但直接用原生 SETNX + EXPIRE 组合容易踩坑——不是“能跑就行”,而是得守住互斥性、防死锁、防误删这三条底线。
加锁必须原子:别分开写 SETNX 和 EXPIRE
这是最常见也最危险的错误。TP6 中若手动分两步操作:
$redis->setnx('lock:order:123', 'tp6-req-abc')$redis->expire('lock:order:123', 30)
一旦第一步成功、第二步因网络中断或进程崩溃没执行,锁就永久存在,后续所有请求都被拦住。正确做法是用 Redis 原生命令的原子组合:SET lock:order:123 "tp6-req-abc" NX PX 30000
TP6 可通过 Redis::command() 或封装好的 set 方法(支持 NX 和 PX 参数)实现。例如:
$result = $redis->set('lock:order:123', 'tp6-req-abc', ['nx', 'px' => 30000]);
返回 true 表示加锁成功,false 表示已被占用。
解锁必须校验身份:防止 A 释放了 B 的锁
不能简单 DEL lock:key。因为可能 A 加锁后执行慢,超时自动释放;B 趁机加锁成功;这时 A 执行完调用 DEL,就把 B 的锁给删了——引发并发安全问题。
解决方案:锁值必须唯一(如 UUID 或带时间戳的随机串),解锁时用 Lua 脚本比对再删:
// TP6 中可封装为方法$script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";$redis->eval($script, ['lock:order:123'], ['tp6-req-abc']);
锁要支持可重入?TP6 默认不支持,得自己设计
原生 SETNX 锁天然不可重入:同一个请求在已持锁状态下再次尝试加同一把锁,会失败阻塞甚至死锁。TP6 场景中若业务逻辑嵌套调用(如订单创建里调用库存扣减,两者都需同一资源锁),就得扩展:
- 锁值改存为
client_id:count格式(如"tp6-req-abc:2") - 加锁时先 GET,若存在且 client_id 匹配,则 INCR 计数;否则 SETNX + PX
- 解锁时 DECR,为 0 才真正 DEL
注意:这需要额外的 Lua 脚本保障原子性,复杂度上升,非必要不建议在 TP6 简单场景中强行实现。
超时时间设多少?得结合业务实际
30 秒不是万能值。设太短:业务还没做完锁就释放,导致并发;设太长:出错后恢复慢,影响吞吐。
建议策略:
- 统计该业务平均耗时,取 P95 + 缓冲(比如平均 800ms,设 3–5 秒)
- 关键路径(如支付回调)可适当加长,非关键(如日志记录)尽量缩短
- 配合看门狗机制:加锁后启动一个异步任务,在超时前 1/3 时间续期(需保证续期原子性)
TP6 中续期可用 GETSET 或 SETEX,但务必校验锁归属,避免续错别人的锁。


















