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 怎么办?你需要一个看门狗,在任务仍在运行时延长锁。

模式

  1. 获取锁,TTL 30 秒。
  2. 启动后台定时器,每 10 秒运行一次。
  3. 定时器检查任务是否仍在运行。如果是,延长锁。
  4. 任务完成后,取消定时器并释放锁。
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 实例:

  1. 在 N 个实例中的 N/2+1 个上获取锁。
  2. 只有在时间限制内在多数节点上获取成功,锁才有效。
  3. 如果获取失败,在所有实例上释放锁。

这更复杂,也有争议(参见 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 是怎么获取锁的。这就是分布式锁的核心。