TP6.0 基于 Redis SetNX 实现的简单互斥锁机制【并发】
先看一个典型场景:电商秒杀、库存扣减、定时任务重复调度——这些并发冲突在 TP6 应用里屡见不鲜。用 Redis 的 SETNX 做互斥锁,确实是轻量又直接的方案,但“能用”和“稳妥”之间,隔着三层硬功夫:原子加锁、身份校验、合理超时。这三条,差一条就翻车。 很多人第一次上手,直接写 SETNX 加
先看一个典型场景:电商秒杀、库存扣减、定时任务重复调度——这些并发冲突在 TP6 应用里屡见不鲜。用 Redis 的 SETNX 做互斥锁,确实是轻量又直接的方案,但“能用”和“稳妥”之间,隔着三层硬功夫:原子加锁、身份校验、合理超时。这三条,差一条就翻车。

很多人第一次上手,直接写 SETNX 加 EXPIRE 两步走——觉得能跑就行。但问题恰恰出在这里:两步操作之间,网络断了,进程挂了,锁就永远留在 Redis 里,所有后续请求全部被堵死。这不是理论风险,是生产环境里反复上演的真实事故。
加锁必须原子:别把 SETNX 和 EXPIRE 拆开写
在 TP6 里,如果这样写:
$redis->setnx('lock:order:123', 'tp6-req-abc')$redis->expire('lock:order:123', 30)
第一步成功,第二步挂掉——锁就成永久钉子户。正确的做法,是用 Redis 原生支持的原子命令:SET key val NX PX 30000。TP6 里通过 Redis::set() 方法传参就能实现:
$result = $redis->set('lock:order:123', 'tp6-req-abc', ['nx', 'px' => 30000]);
返回 true 就是锁到手,false 说明已经被别人占了。一步到位,没有中间态,才是安全的起点。
解锁必须校验身份:防的不是别人,是“自己”
直接 DEL lock:key 是另一个大坑。想象一下:A 加锁后执行得慢,超时自动释放了;B 恰好拿到锁开始处理;这时 A 终于执行完,顺手一个 DEL——把 B 的锁删了。并发安全直接破防。
解决方案其实不复杂:锁值必须唯一,比如用 UUID 或带时间戳的随机串。解锁时通过 Lua 脚本比对值再删除:
$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,计数归零才真正 DEL。
注意,这需要额外的 Lua 脚本保障原子性,复杂度明显上升。除非业务确实需要,否则在 TP6 的简单场景里,不建议强行上可重入。
超时时间:30 秒不是万能药
设太短,业务没做完锁就释放,并发冲突该来还是来;设太长,出错后恢复慢,吞吐量直接受影响。更关键的是,这个时间得跟业务实际挂钩。
建议的做法:
- 先统计业务平均耗时,取 P95 再加缓冲——比如平均 800 毫秒,设 3 到 5 秒
- 关键路径(如支付回调)可以适当放宽,非关键路径(如日志记录)尽量压缩
- 配合看门狗机制:加锁后启动一个异步任务,在超时前 1/3 时间点续期——但续期也必须校验锁归属,避免把别人的锁续上了
TP6 里续期可以用 GETSET 或 SETEX,但核心就一条:原子性、身份校验、合理超时,这三条缺一不可。


































