Redis SETNX 不能直接当分布式锁用,因其缺乏锁过期、防误删、高可用机制;需结合唯一token、Lua原子校验删除、合理TTL及续期策略才能实现安全分布式锁。

Redis SETNX 命令为什么不能直接当分布式锁用
因为 SETNX 只保证“设置不存在的 key”原子性,但没解决锁过期、误删、单点故障三个硬伤。比如进程 A 加锁成功,执行到一半崩溃,锁永远不释放;或者 A 的锁超时自动删了,B 加了新锁,A 回来却把 B 的锁删掉——这就是经典的“锁误删”问题。
ThinkPHP 自带的 Cache::lock() 底层其实封装了 Redis 的 SET 命令加 NX EX 选项,但默认没带唯一标识(value),所以删锁时靠 DEL 硬删,根本不管是不是自己加的。
- 必须给每个锁写入一个客户端唯一 token(比如
uniqid('', true)生成的字符串)作为 value - 加锁用
SET key token NX EX seconds,确保原子性+自动过期 - 解锁必须用 Lua 脚本:先
GET校验 token 是否匹配,再DEL,整个过程不可打断
ThinkPHP 6.x 中手写安全 Redis 分布式锁
别依赖 Cache::lock() 的默认行为,它不校验 token。你需要自己封装一个带校验的锁类,核心是两个原子操作:
加锁:$redis->set($key, $token, ['nx', 'ex' => $ttl])
立即学习“PHP免费学习笔记(深入)”;
解锁:用 eval 执行 Lua 脚本,避免 GET+DEL 的竞态
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
- Token 必须在加锁前生成,且全局唯一(推荐
sprintf('%s_%d', uniqid(), getmypid())) - TTL 不能设太短(比如 1 秒),否则业务没跑完锁就丢了;也不能太长(比如 1 小时),否则故障后恢复慢
- ThinkPHP 的
Redis驱动需开启serialize为false,否则 value 会被序列化,Lua 脚本比对失败
锁续期(renew)为什么容易翻车
长任务(比如导出大 Excel)需要在锁快过期时自动延长 TTL,但“查锁+重设过期时间”不是原子的。如果查到锁属于自己,刚要 EXPIRE,锁却被别人删了,EXPIRE 就作用在了别人的 key 上。
安全做法是用 Lua 脚本一次性完成“校验 token + 设置新过期时间”,但 ThinkPHP 原生不提供这个能力,得自己调 $redis->eval()。
- 续期脚本里不能只
GET后判断,必须GET和EXPIRE在同一个 Lua 原子上下文中 - 续期间隔建议设为 TTL 的 1/3(比如 TTL=30s,每 10s 续一次),留出网络和执行缓冲
- 别在锁释放后还继续续期——得用一个标志位或
try/finally确保只在持有锁期间续
本地缓存 + Redis 锁组合时的坑
有人想“先查本地 Cache,没命中再上 Redis 锁”,这会破坏锁语义:两个请求同时发现本地没缓存,都去 Redis 加锁,结果只有一个成功,另一个被阻塞,但等它拿到锁再去查 DB,可能数据已更新,本地缓存又写入旧值。
正确顺序只能是:所有请求统一走 Redis 锁 → 拿到锁的查 DB 写缓存 → 其他人等锁释放后直接读缓存。
- 本地缓存(如 file / apcu)只能作为锁之后的二级加速,绝不能绕过锁做“预判”
- ThinkPHP 的
cache()->remember()默认不带锁,直接并发穿透 DB,必须配合手动锁控制 - 如果用了多级缓存(Redis + local),解锁前务必清掉本地副本,否则其他进程读到脏数据
真正难的不是写对那几行 Lua,而是想清楚“谁该等、谁该跳过、谁负责写、谁只读”——这些逻辑一旦混在业务里,加锁就变成定时炸弹。



















