Redis 分布式锁:SET NX PX 正确姿势 + 失败重试策略
分布式锁看似简单。SET key value NX PX 30000——搞定了?没那么简单。至少有五种方式可以搞砸它。本指南展示正确的实现方式和那些连资深工程师都会踩的坑。
问题场景
在分布式系统中,多个进程需要协调对共享资源的访问。没有锁的话:
- 两个 Worker 处理同一个任务。
- 两台服务器同时更新同一行数据库记录。
- 一笔支付被扣款两次。
正确姿势:SET NX PX
在 Redis 中获取锁的现代方式:
SET lock:order:5001 "worker-a-uuid" NX PX 30000
这条命令原子性地做了三件事:
NX:仅在 Key 不存在时设置(获取锁)。PX 30000:30 秒后自动过期(防止死锁)。- 值是唯一标识符(用于安全释放锁)。
试一试
SET lock:order:5001 "worker-a-uuid" NX PX 30000
SET lock:order:5001 "worker-b-uuid" NX PX 30000
GET lock:order:5001
TTL lock:order:5001
第二个 SET 返回 nil——Worker B 获取锁失败。Worker A 持有锁。
为什么值必须唯一
值用于标识谁持有锁。没有它,你可能会意外释放别人的锁。
危险模式
Worker A 获取锁(TTL 10秒)
Worker A 处理花了 15 秒(锁过期了!)
Worker B 获取了已释放的锁
Worker A 完成后 DEL 了锁
Worker B 以为自己持有锁,但锁刚被删了
Worker C 获取锁——现在 B 和 C 都以为自己持有锁
安全释放锁
只有在你仍然持有锁时才删除它。这需要 Lua 脚本保证原子性:
# 伪代码(Lua 脚本):
if redis.call("GET", key) == my_uuid then
redis.call("DEL", key)
end
用 Redis 命令可以在删除前验证所有权:
SET lock:resource:1 "uuid-abc-123" NX PX 30000
GET lock:resource:1
DEL lock:resource:1
但注意:GET + DEL 在没有 Lua 的情况下不是原子的。生产环境中务必使用 Lua 脚本。
指数退避重试
当获取锁失败时,不要在紧密循环中自旋。使用带抖动的指数退避:
第 1 次尝试:等待 50ms + random(0, 50)ms
第 2 次尝试:等待 100ms + random(0, 100)ms
第 3 次尝试:等待 200ms + random(0, 200)ms
第 4 次尝试:等待 400ms + random(0, 400)ms
最大尝试次数:5
模拟锁竞争
SET lock:job:process "worker-1" NX PX 10000
SET lock:job:process "worker-2" NX PX 10000
SET lock:job:process "worker-3" NX PX 10000
GET lock:job:process
Worker 2 和 3 得到 nil。在你的应用中,它们应该退避并重试。
锁续期(看门狗)
如果你的任务执行时间超过锁的 TTL 怎么办?你需要一个看门狗,在任务仍在运行时延长锁。
模式
- 获取锁,TTL 30 秒。
- 启动后台定时器,每 10 秒运行一次。
- 定时器检查任务是否仍在运行。如果是,延长锁。
- 任务完成后,取消定时器并释放锁。
SET lock:long-task "uuid-abc" NX PX 30000
PEXPIRE lock:long-task 30000
GET lock:long-task
DEL lock:long-task
像 Redisson(Java)这样的库自动实现了看门狗模式。
常见错误
错误一:使用 SETNX + EXPIRE(两条命令)
SETNX lock:old-pattern "value"
EXPIRE lock:old-pattern 30
如果进程在 SETNX 和 EXPIRE 之间崩溃,锁将永远存在。始终使用单条 SET ... NX PX 命令。
错误二:固定锁值
SET lock:resource "locked" NX PX 30000
使用固定值如 "locked" 意味着任何进程都能释放锁。始终使用唯一标识符(UUID)。
错误三:TTL 太短
如果锁的 TTL 是 5 秒但操作需要 10 秒,锁在操作中途过期。另一个进程获取了它,你就有了竞态条件。
经验法则:锁的 TTL 至少应该是预期操作时间的 3 倍,再加上看门狗保底。
错误四:没有重试上限
无限重试可能导致级联故障。设置最大重试次数并优雅失败。
错误五:忽略时钟漂移
在 Redlock(多节点)中,Redis 节点之间的时钟漂移可能导致锁在不同时间过期。在 TTL 计算中考虑漂移。
Redlock:多节点分布式锁
为了更高的可靠性,Redlock 算法使用多个独立的 Redis 实例:
- 在 N 个实例中的 N/2+1 个上获取锁。
- 只有在时间限制内在多数节点上获取成功,锁才有效。
- 如果获取失败,在所有实例上释放锁。
这更复杂,也有争议(参见 Martin Kleppmann 的分析)。对于大多数场景,单个 Redis 实例配合合理的 TTL 和看门狗就够了。
坑点总结
| 错误 | 后果 | 修复 |
|---|---|---|
| SETNX + EXPIRE(两条命令) | 崩溃时死锁 | 使用 SET NX PX |
| 固定锁值 | 意外释放锁 | 使用 UUID |
| 没有 TTL / TTL 太长 | 死锁 | 设置合理 TTL |
| TTL 太短 | 竞态条件 | TTL ≥ 3倍操作时间 + 看门狗 |
| 没有重试上限 | 级联故障 | 最大重试次数 + 退避 |
| DEL 不检查所有权 | 释放别人的锁 | Lua 脚本原子检查并删除 |
在线编辑器试一试
前往 Redis 在线编辑器 练习:
SET lock:order:5001 "worker-a-uuid" NX PX 30000
SET lock:order:5001 "worker-b-uuid" NX PX 30000
GET lock:order:5001
SETNX lock:old "value"
EXPIRE lock:old 30
TTL lock:old
SET lock:safe "uuid-123" NX PX 10000
GET lock:safe
DEL lock:safe
SET lock:safe "uuid-456" NX PX 10000
GET lock:safe
观察第二个 SET NX 是怎么失败的,以及 DEL 之后新的 Worker 是怎么获取锁的。这就是分布式锁的核心。