场景:多个进程抢同一个资源

单机里互斥用一把锁就够了——同一进程内的线程共享内存,锁状态大家都看得见。但当多个进程、多台机器要互斥地操作同一资源(比如防止同一订单被重复扣款、防止定时任务在多实例上重复执行),进程间没有共享内存,就需要一个所有节点都能访问的外部协调者来裁决谁持有锁。Redis 因为快、原子、人人可达,成了最常用的分布式锁载体。

但分布式锁是出了名的"看着简单、坑深似海"。下面从最朴素的 SETNX 一步步演进,把每一步为什么不够、加了什么补丁讲清楚。

第一版:SETNX 加锁

SETNX key value(SET if Not eXists)是天然的加锁原语:只有 key 不存在时才设置成功,返回 1;已存在返回 0。靠它的原子性,多个客户端同时来,只有一个能设置成功,即拿到锁。

1
2
3
SETNX lock:order:123 "client-A"
# 返回 1 -> 拿到锁;返回 0 -> 别人持有,等待或失败
DEL lock:order:123 # 用完释放

问题立刻暴露:如果持锁的客户端崩溃了,没来得及 DEL,锁就永远留在那里,后续所有人都拿不到——死锁。

第二版:加过期时间

给锁加 TTL,即使持有者崩了,锁也会自动过期释放。但加锁和设过期必须是一个原子操作:

1
2
3
4
5
6
7
# 错误写法:两条命令之间崩溃,锁就没了过期时间,变死锁
SETNX lock:order:123 "client-A"
EXPIRE lock:order:123 30 # 如果在这之前崩溃,前功尽弃

# 正确写法:用 SET 的扩展参数一次搞定
SET lock:order:123 "client-A" NX EX 30
# NX = 不存在才设;EX 30 = 30 秒过期,二者原子完成

SET ... NX EX 把"判断不存在 + 设置 + 设过期"合成一条原子命令,堵住了上面的窗口。这是当今单 Redis 节点加锁的标准姿势。

第三版:锁的唯一标识与安全释放

新问题:误删别人的锁。设想客户端 A 拿到锁,但因为 GC 停顿、网络延迟等原因,A 的操作超过了 30 秒 TTL,锁自动过期;此时 B 拿到了锁;A 缓过来执行 DEL,删掉的却是 B 的锁。于是 B 和后续的 C 同时在临界区,互斥被破坏。

补丁是:加锁时写入一个唯一值(如 UUID),释放时先比对值、确认是自己的锁才删。而"比对 + 删除"也必须原子,否则又有竞态,所以用 Lua 脚本:

1
2
3
4
5
6
-- 释放锁的 Lua 脚本,KEYS[1]=锁名, ARGV[1]=我的唯一标识
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end

Redis 执行 Lua 脚本是单线程原子的,中途不会插入别的命令,从而保证"判断是不是我的锁"和"删锁"不可分割。

还没解决的根本问题:TTL 与业务时长

无论怎么补,只要锁有 TTL,就存在"业务还没干完锁先过期"的风险,导致两个客户端同时持锁。两种应对:

  1. 看门狗(watchdog)续期:持锁期间起一个后台线程,定期(如每 1/3 TTL)给锁续命,业务结束才停。Redisson 等客户端库就内置了这种自动续期。代价是若客户端进程整个卡死(看门狗也停了),锁最终还是会过期——这其实是合理的兜底。
  2. TTL 设得足够长 + 业务保证幂等:承认锁可能失效,靠业务层幂等(如扣款带唯一流水号、二次执行不产生副作用)兜底。幂等几乎总是分布式系统该有的最后防线,别把正确性全压在锁上。

单点之外:Redlock

上面所有方案都建立在"一个 Redis 节点"上。但若这个节点是主从架构:客户端在 master 上拿到锁,master 还没把这条写同步到 slave 就宕机,slave 被提升为新 master——新 master 上没有这把锁,于是另一个客户端又能拿到同一把锁。主从复制是异步的,这个窗口客观存在。

Redlock 算法试图解决这个问题。它不依赖单点,而是部署 N 个相互独立的 Redis 节点(通常 5 个,无主从关系),加锁流程:

1
2
3
4
5
1. 记录当前时间 T1
2. 依次向 N 个节点用 SET NX PX 请求加锁,每次请求设很短的超时
3. 只有在多数节点(N/2+1)上成功,且总耗时小于锁 TTL,才算加锁成功
4. 锁的实际有效时间 = 原 TTL - 加锁总耗时(扣掉已消耗的时间)
5. 若失败,向所有节点发释放请求

核心思想是用多数派(quorum) 替代单点:只要多数节点存活并认可,锁就成立;少数节点宕机不影响。

关于 Redlock 的争议

Redlock 是有名的争议话题。批评的核心论点是:基于时间(TTL)的分布式锁,在存在 GC 停顿、时钟漂移、网络延迟的现实系统里,无法提供绝对的互斥保证。典型反例:客户端拿到锁后发生长时间 GC 停顿,期间锁过期、别人拿到了锁,GC 结束后这个客户端"以为自己还持有锁"继续写——多数派也救不了它,因为问题出在客户端自己对时间的误判。

被广泛认可的更强方案是引入 fencing token(栅栏令牌):锁服务每次发锁都附带一个单调递增的版本号,客户端带着这个 token 去访问被保护的资源,资源端只接受比已见过的更大的 token、拒绝旧 token。这样即使旧持有者"复活"了,它手里的 token 已经过期(更小),写入会被资源端挡掉。但这要求被保护的资源本身支持版本校验,不是锁单独能搞定的。

实践上的取舍:

  • 对正确性要求不极端(锁只是性能优化,偶尔重复执行可接受):单 Redis 节点 + SET NX EX + Lua 安全释放,足够,简单可靠。
  • 要求强一致互斥:别只靠 Redis。考虑带 fencing token 的方案,或直接用为一致性而生的协调系统(如基于共识算法的服务),并让被保护资源支持令牌校验。

常见踩坑小结

  • 加锁与设 TTL 不原子 → 用 SET NX EX,别拆成 SETNX + EXPIRE。
  • 释放锁直接 DEL → 必须带唯一标识、用 Lua 原子比对删除,否则误删他人锁。
  • TTL 短于业务时长 → 上看门狗续期,且业务做幂等兜底。
  • 主从切换丢锁 → 认清异步复制的丢锁窗口;强一致需求别指望单 Redis。
  • 把锁当唯一正确性保障 → 分布式下锁会失效是常态,幂等与 fencing token 才是底线。

分布式锁的演进史,本质是一部"为每个失效场景打补丁"的历史。理解每一层补丁挡住了哪种失败、又留了哪个口子,你才能为自己的业务选对那个"够用且不过度"的方案。