Redis 分布式锁到底靠不靠谱:从 SETNX 到 Redlock,我踩过的坑和业内的争议
分布式锁这题基本是后端面试必问,但我发现大部分人(包括我自己刚工作那两年)背的答案都停在"用 SETNX 加锁,用完 DEL 释放"这一层。这个答案在面试官追问两句之后基本就崩了,因为它连最基本的原子性都没保证。
这篇文章想把这道题从头到尾捋一遍,包括业内一个挺有意思的争议:Redis 作者本人和一位很有分量的分布式系统学者,公开吵过 Redlock 到底安不安全。
第一步:SETNX + EXPIRE,两条命令的坑
最早的写法是这样:
csharp
SETNX lock:order:123 1
EXPIRE lock:order:123 10
先抢锁,抢到了再给个过期时间防止死锁。问题也很直白:这是两条命令,不是原子操作。如果客户端在执行完 SETNX 刚要执行 EXPIRE 的时候进程挂了,或者网络抖了一下,这把锁就永远不会过期了,等于死锁。别的线程从此再也拿不到这把锁,除非有人手动上 Redis 删掉。
我第一次听到这个坑的时候还挺意外的,总觉得这种"命令之间的空隙"应该是理论问题,实际生产里几乎不会撞上。后来自己维护的一个定时任务系统里就真的出现过一次------服务器在两条命令之间被 OOM killer 干掉了,那把锁硬是卡了小半天没人发现,直到下游任务全部堆积报警。
第二步:SET 一条命令搞定原子性,但引入了新问题
Redis 2.6.12 之后 SET 命令自己带了 NX 和 PX 参数,一条命令就能把"抢锁"和"设过期时间"合并成原子操作:
sql
SET lock:order:123 <随机值> NX PX 10000
这一步解决了两条命令之间的空隙问题。但紧接着又冒出一个新麻烦:过期时间到底设多久合适?
假设你把过期时间设成 10 秒,但业务逻辑实际跑了 15 秒才执行完。第 10 秒锁自动过期了,另一个线程趁虚而入拿到了同一把锁,这时候地里就有两个人在同时改同一份数据。等第一个线程执行完,它会心安理得地去释放锁,结果释放掉的是第二个线程刚拿到的锁。
这里还牵出另一个必须提的细节:释放锁不能直接 DEL,得先判断这把锁是不是自己加的。做法是加锁时把一个随机值(比如 UUID)当作 value,释放时用 Lua 脚本先 GET 比对再 DEL,保证这个"检查再删除"的过程也是原子的:
lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
这一步很多人会漏掉,直接 DEL 的话,锁被误删的概率其实不低。
第三步:业务执行时间不可控,看门狗登场
过期时间设多长这事没有标准答案,业务逻辑跑多久往往是不确定的,尤其是里面还嵌套了别的 RPC 调用。Redisson 这个 Java 客户端给的方案是看门狗(watchdog):加锁成功后起一个后台线程,每隔"过期时间的三分之一"就去续一次期,只要业务逻辑没跑完,锁就不会因为超时而失效。默认的过期时间是 30 秒,也就是大概每 10 秒续一次。
续期的时候也不是无脑续,会先校验当前锁的持有者是不是自己,避免给别人的锁续期。只要客户端进程没挂、Redisson 实例没关闭,这套机制基本能保证锁不会在业务跑到一半的时候突然消失。
不少人写代码用了 lock() 方法就默认看门狗一直在生效,但如果你调用的是带超时参数的重载(比如 lock(10, TimeUnit.SECONDS)),看门狗是不启用的,10 秒一到锁就没了,这个坑我见过至少两次。
第四步:单节点 Redis 挂了怎么办,Redlock 的思路
前面聊的都是建立在"这个 Redis 节点一直在线"的前提下。但现实中 Redis 一般会用主从架构加哨兵做高可用,问题就出在主从复制是异步的:客户端在主节点上加锁成功了,这个写操作还没来得及同步到从节点,主节点突然挂了,哨兵把某个从节点提成新主节点,这个新主节点上根本没有这把锁的数据。于是另一个客户端在新主节点上又能重新加锁成功,同一时刻两把"同一把锁"就都活着。
Redlock 就是奔着解决这个问题去的。思路是别只依赖一个 Redis 实例,而是部署多个相互独立的 Redis 节点(官方建议至少 5 个,且要真的独立,不是一主多从这种有复制关系的)。加锁的时候依次向这几个节点发起加锁请求,只有在多数节点(超过一半)都加锁成功、并且总耗时小于锁的有效期时,才认为这把锁真正加锁成功。这样即便个别节点挂了或者没来得及同步,只要多数节点是一致的,锁的状态就是可信的。
争议来了:Kleppmann 说 Redlock 不安全
2016 年,剑桥大学做分布式系统研究的 Martin Kleppmann 写了一篇很长的文章,点名批评 Redlock 在正确性上有硬伤。他的核心论点有两条。
第一条是 Redlock 没有 fencing token(防护令牌)机制。理想情况下,每次加锁成功都应该拿到一个单调递增的编号,下游存储系统凭这个编号判断"这次写入是不是来自最新的锁持有者",从而拒绝掉过期的写请求。Redlock 完全没有这个东西,锁本身只是个"我认为我持有锁"的信号,没法给下游提供任何硬保证。
第二条更狠:Redlock 的正确性建立在"网络延迟、进程暂停、时钟误差都是有界的"这个假设上,但生产环境里这三者全都可能失控。举个例子,客户端拿到锁之后发生了一次长达几十秒的 GC 停顿(老年代 Full GC 在大堆场景下并不罕见),恢复过来的时候客户端完全不知道锁其实早就过期了,它会带着一个"我以为我还持有锁"的错觉继续往下游写数据,这时候锁其实已经在别人手里了。
Redis 作者 antirez(Salvatore Sanfilippo)随后写了篇反驳文章,核心意思是:Redlock 从设计上就不是为了保护数据正确性(correctness lock),而是为了提升效率、避免重复劳动(efficiency lock),比如避免同一个定时任务被多个节点重复跑一遍。如果业务确实需要保证正确性(比如涉及资金变动),那本来就不该只靠一把分布式锁兜底,得配合 fencing token,或者干脆换成 ZooKeeper、etcd 这类基于强一致协议的方案。
这场争论最后没有谁说服谁,但结论其实挺有参考价值:分布式锁不是万能药,用之前先想清楚自己要的是"防重复"还是"保正确",这两者需要的方案压根不是一回事。
面试的时候应该怎么答
如果只是被问"怎么实现分布式锁",按上面的演进顺序讲一遍就够立住了:SETNX 两条命令的原子性问题、SET NX PX 的改进、看门狗解决续期、主从异步复制导致的锁失效、Redlock 的多数派思路。
如果面试官继续往深了问"Redlock 安全吗",就可以把 Kleppmann 和 antirez 的分歧带出来,顺带说一句自己的判断:日常业务场景(定时任务防重、接口限流之类)用 Redisson 的 SETNX + 看门狗基本够用;真正涉及资金、库存扣减这类不能出错的场景,光靠 Redis 锁是不够的,得加乐观锁做兜底,或者上更强一致性的协调服务。这种能说出取舍的回答,比背答案要有说服力得多。
我自己平时会用面灵这类 AI 面试工具刷押题,发现网上关于这道题的资料确实大多还停在 SETNX+EXPIRE 那一层,没讲清楚后面这几层是怎么一步步演进出来的,这也是我把这篇整理出来的原因。
参考资料:Martin Kleppmann《How to do distributed locking》、antirez《Is Redlock safe?》、Redis 官方文档 Distributed Locks with Redis、Redisson 官方文档看门狗机制说明。