单纯用 set key value nx px 仍可能死锁,因存在三类风险:1 进程崩溃导致unlock未执行;2 业务超时锁自动过期,新旧进程并发操作;3 主从切换致锁“复活”引发双持有。

为什么单纯用 SET key value NX PX 仍可能死锁
很多人以为加了 PX 就高枕无忧,其实不然。死锁风险主要来自三类场景:
— 锁持有者进程崩溃或协程被强制终止(如超时 kill、OOM),unlock() 根本没机会执行;
— 业务逻辑耗时远超预设 TTL,锁自动过期,但旧流程仍在操作共享资源,新进程又抢到锁,造成并发写;
— Redis 主从异步复制下,主节点写入成功后宕机,从节点升主,旧锁 key 在新主上“复活”,出现双持有。
Redlock 真的能防住所有死锁吗
不能。Redlock(多 Redis 实例投票锁)解决的是单点故障下的锁可靠性问题,不是死锁预防机制。它通过向 N 个独立 Redis 实例请求锁,要求至少 ⌊N/2⌋+1 个返回成功才算加锁成功,从而降低因单实例故障导致锁状态丢失的概率。但它不改变以下事实:
— 每个实例上的锁仍是基于 SET ... NX PX,照样会过期;
— 它不感知业务是否完成,也不续期;
— 若客户端在获得多数锁后、还没开始业务就挂了,那些已成功的锁依然会滞留到 TTL 结束。
所以 Redlock 的价值是「提升锁获取动作的容错性」,而非「防止业务层死锁」。Hyperf 生态中 pudongping/hyperf-wise-locksmith 提供了 redLock() 封装,但必须配合显式续期或看门狗使用,否则只是把单点风险分散成多点风险。
真正防死锁的关键:TTL + 续期 + 唯一 owner 校验
生产环境防死锁,靠的是三层防御:
— TTL 合理设置:不是拍脑袋定 30 秒,而是按「P99 业务耗时 + 网络抖动余量(建议 +200ms)+ 故障恢复缓冲(如 GC pause)」综合估算。例如库存扣减 P99 是 420ms,那就设 EX 1000(1 秒),别设 5 秒;
— owner 必须唯一且可校验:用 uniqid('lock_', true) 或更稳的 bin2hex(random_bytes(16)) 生成,绝不用固定字符串或时间戳;
— 主动续期(Watchdog):启动一个协程,在锁剩余寿命 1/3 时调用 renewLock(),Lua 脚本必须先比对 GET key == owner 再 EXPIRE,否则续错锁。
示例关键片段:
$owner = bin2hex(random_bytes(16));
$locked = $redis->set('lock:order:123', $owner, ['NX', 'EX' => 1000]);
if ($locked) {
// 启动续期协程
Coroutine::create(function () use ($redis, $owner) {
while (true) {
Coroutine::sleep(300); // 每300ms检查一次
$lua = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end";
$redis->eval($lua, ['lock:order:123', $owner, 1000], 1);
}
});
// 执行业务...
}
最容易被忽略的点:释放锁的 Lua 脚本必须原子且严格校验
很多项目直接用 $redis->del('lock:key'),这是高危操作。只要锁被别人续期过,或者自己过期后又被别人抢走,DEL 就会误删他人锁。正确做法只有一条:
— 所有解锁逻辑必须走 Lua 脚本,且脚本第一行必须是 GET key == owner 判断;
— 不要用 DEL,要用 redis.call("DEL", ...) 包裹在判断分支里;
— eval() 的第三个参数(key 数量)必须是 1,别写成 0 或 2,否则 ARGV 索引错乱,校验失效。
这个细节在 Hyperf 官方示例里有体现,但实际项目中常被简化掉——一旦漏掉,TTL 和 Redlock 全白搭。











