分布式锁:Redis、Zookeeper 与 Redlock 的工程取舍 — editorial cover photo

分布式锁:Redis、Zookeeper 与 Redlock 的工程取舍

从 SETNX 到 Redlock 再到 ZK 临时顺序节点,分布式锁的每一步演进背后都有血泪教训,没有银弹只有取舍。

2025年4月8日·返回文章列表
文章大纲

分布式锁大概是后端面试里被问得最多、也是生产环境里被坑得最多的一个组件。问的时候大家都答得头头是道,SETNX、Redlock、ZK 临时节点,一套套的。真到线上出了问题,往往是锁本身带来的。

我见过因为分布式锁导致的几种典型故障。锁加了但没释放(进程 crash),整个资源被永久锁死。锁的超时时间设短了,业务还没做完锁就过期了,别人拿到锁并发执行。网络抖动导致锁判断错了,两个节点同时以为自己持锁。每一种都真实发生过,每一种的代价都不小。

这篇想讲清楚的不是分布式锁怎么用,是每种方案的代价是什么、什么场景该选什么、以及为什么没有一种完美的分布式锁。

图:AP 场景用 Redis,强一致 CP 场景选 ZK / etcd

PlantUML
正在加载图表…
PlantUML diagram

图:三种方案的 CAP 定位与核心取舍——金融扣款选 ZK/etcd,限流计数选 Redis

单机 Redis 锁,最常用也最容易踩坑

最朴素的 Redis 分布式锁就是 SETNX。给一个 key 设个值,设成功就拿到锁,设失败说明别人持着锁。再加个过期时间,防止持锁进程 crash 后锁永远不释放。

Terminal window
SET lock_key <requester_id> NX PX 30000

这套方案看起来简单,坑却不少。最经典的是「锁过期但业务没做完」。你设了 30 秒过期,结果某个慢查询让业务执行了 40 秒,第 30 秒锁过期了,别的节点拿到锁开始执行,第 40 秒你执行完了去删锁,删的是别人刚加的锁。

解决这个问题的常见做法是「看门狗」(watchdog),也就是 Redisson 的实现思路。持锁期间开个后台线程定期续期,只要进程还活着就不断把过期时间往后推。这能解决大部分问题,但引入了新的复杂度,续期线程本身也可能出问题。

还有一个坑是释放锁的原子性。判断「这个锁是我的」和「删锁」必须是原子的,不然你判断完是自己的锁、正准备删的瞬间锁过期了别人拿到了,你这一删又删错了。这要用 Lua 脚本把两步合成一个原子操作。

单机 Redis 锁的根本问题是,它是 AP 的不是 CP 的。Redis 主从切换的时候,可能丢锁信息。主节点加锁成功返回了,还没来得及同步到从节点就挂了,从节点晋升成新主,没有这把锁的信息,别人又能加锁成功。两个节点同时持锁,互斥被打破。

PlantUML
正在加载图表…
PlantUML diagram

图:锁过期 + 非原子释放 = 互斥失效。看门狗续期与 Lua 原子释放是两道防线

Redlock,试图解决主从切换问题的方案

Antirez(Redis 作者)提出了 Redlock 算法,试图解决这个问题。思路是,不用单个 Redis,用多个独立的 Redis 实例(通常 5 个),向 majority(多数)加锁成功才算锁成功。这样即使一两个实例挂了,锁的安全性还有保证。

算法大致是这样。客户端记录开始时间,依次向 5 个 Redis 实例发加锁请求,每个请求带一个短超时。统计成功数,如果 majority(至少 3 个)成功,并且总耗时小于锁的过期时间,算加锁成功。否则算失败,要向所有实例发释放请求。

这个算法在工程上有不少争议。Martin Kleppmann(《DDIA》作者)专门写文章质疑过它的安全性,主要的点是,Redlock 的正确性依赖时间假设,而在真实的分布式系统里,时钟可能跳变(NTP 同步、GC pause 导致进程暂停),这些都会破坏 Redlock 的前提。

比如进程在加锁成功后遇到一次长时间的 GC pause,暂停期间锁过期了,醒来后它以为自己还持着锁,其实锁早过期别人拿到了。Redlock 没法防止这类问题,因为它依赖客户端能及时检测到自己的锁过期。

我自己的判断是,Redlock 在大多数业务场景里是「够用」的,它的安全性比单机 Redis 强一档。但如果你需要的是严格的正确性(比如金融扣款这种绝不能错的场景),别指望任何基于 Redis 的锁,它们在极端情况下都可能出问题。

PlantUML
正在加载图表…
PlantUML diagram

图:Redlock 用多数派投票对抗单点故障,但正确性依赖时钟假设——GC pause 仍是盲区

Zookeeper 锁,CP 的选择

Zookeeper 提供的是另一种思路,基于 CP(一致性优先)的锁。它用临时顺序节点和 watch 机制实现分布式锁,能保证锁的强一致性。

加锁的过程大致是这样。客户端在锁的父节点下创建一个临时顺序节点,比如 /lock/node-00000001。然后查看自己是不是序号最小的节点,是的话就拿到锁。不是的话,watch 排在自己前面那个节点,前面那个删了(释放锁或者 crash)自己就被唤醒,再判断自己是不是最小。

临时节点的好处是,客户端 session 断了(进程 crash、网络分区),ZK 会自动删掉临时节点,相当于自动释放锁。这就解决了「持锁进程 crash 锁不释放」的问题,不用再设过期时间、不用看门狗。

顺序节点加 watch 解决了「惊群」问题。如果所有人都 watch 同一个节点,锁一释放所有客户端都被唤醒去抢,只有一个能成功,其余白忙。顺序节点让每个人只 watch 前一个,释放时只唤醒一个,优雅得多。

ZK 锁的代价是重。ZK 是个 CP 系统,每次写都要走多数派协议(ZAB),延迟比 Redis 高。ZK 集群的运维也比 Redis 重,它对磁盘和 JVM 调优敏感。而且 ZK 的语义比 Redis 复杂,session、watch、临时节点这些概念,团队需要时间消化。

PlantUML
正在加载图表…
PlantUML diagram

图:ZK 锁靠临时节点自动释放 + 顺序节点防惊群,天然解决 crash 不释放与惊群两大难题

怎么选,回到业务的真实需求

讲完了三种方案,怎么选。核心的判断依据是,你的业务能容忍什么程度的「锁失效」。

如果是 CP 场景,锁绝对不能失效(金融扣款、库存扣减这种错了就是资损的),上 ZK 或者 etcd 这类 CP 系统。它们在极端情况下也能保证强一致,代价是延迟高、运维重。

如果是 AP 场景,锁偶尔失效可以接受(缓存更新、限流计数、防止重复计算这种),用 Redis 锁就够。它的性能好、实现简单、团队都会用。代价是主从切换时可能短暂失效,但业务能容忍的话无所谓。

最怕的是,业务其实是 CP 场景,但因为 Redis 好用就用了 Redis 锁,没意识到极端情况下会失效。出了事才发现「我以为锁是可靠的」这个假设是错的。这种错配是分布式锁最大的风险来源。

别用分布式锁解决能用单机解决的问题

很多人一遇到并发问题就上分布式锁,其实很多场景用单机的手段就够。数据库的行锁、乐观锁(版本号)、CAS 操作,这些都能解决并发冲突,而且比分布式锁简单可靠得多。分布式锁是最后的手段,不是第一选择。每加一个分布式锁,就多一个可能出故障的点。能不用就不用,用了就要想清楚它的失效模式。

一个真实的取舍案例

做营销平台的时候,人群底座的并发更新我们没用分布式锁,用的是乐观锁。人群规则配置是个 20M 的 JSON 画布,并发更新时用版本号 CAS,冲突了重试。这比分布式锁简单,而且没有锁的过期、续期、释放这些坑。

真正用到分布式锁的是大促期间的限流。用 Lua 脚本在 Redis 上做原子的计数器扣减,效果上等价于分布式锁但更轻。之所以敢用 Redis 是因为,限流偶尔误差一两个请求业务能接受,不需要 ZK 那种强一致。

这个判断的逻辑是,先想清楚「这个场景能不能容忍锁失效」,能容忍用 Redis,不能容忍用 ZK。而不是先选个锁再担心它会不会出问题。把失效模式想在前面,选型才能做对。

分布式锁没有银弹,每种方案都是在一致性、可用性、性能、复杂度之间做取舍。理解了这些取舍,才能在自己的场景里选对方案。记住一句话,分布式锁的正确性问题,永远比它的性能问题更值得担心。性能不行可以加机器,正确性出了问题就是资损。

Continue Reading

关联文档推荐

查看全部
多级缓存:从浏览器到 DB 的全链路缓存设计 — vibrant cover for technical blog article读写分离与主从延迟:DB 性能优化的基石 — vibrant cover for technical blog articleRedis 热点 Key 与大 Key 治理 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。