Redis 分布式锁到底靠不靠谱:从 SETNX 到 Redlock,我踩过的坑和业内的争议

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 官方文档看门狗机制说明。

相关推荐
飞天狗1 小时前
TypeScript类型系统其实是个图灵完备的语言
面试·typescript
掘金安东尼2 小时前
中小厂前端候选人简历面试拆解:从 HR 面、技术面到主管面的双赢提问法
前端·面试
用户8524950718421 小时前
解密 JavaScript 中的 this:谁才是真正的调用者?
javascript·面试
Heo21 小时前
Vite进阶用法详解
前端·javascript·面试
洛卡卡了21 小时前
Claude Code rules 要怎么用,团队协作时如何统一代码规范呢?
面试·agent·claude
不好听6131 天前
JavaScript 的 this 到底指向谁?
javascript·面试
烬羽1 天前
面试官:聊聊 LocalStorage 和 this 指向?看这篇就够了
面试·程序员
weedsfly1 天前
JS垃圾回收:从原理到项目实战,彻底根治内存泄漏
前端·javascript·面试