Skip to content
Charles Shao
Go back

分布式锁深挖 · Redis Redlock vs ZooKeeper/etcd

views

单机上要保证「同一时刻只有一个线程进临界区」,一个 synchronizedReentrantLock 就够了——因为所有线程共享同一块内存、同一个 JVM。可一旦服务部署成多个进程、跨多台机器,这个前提就没了:进程 A 的锁对象和进程 B 的锁对象是两个毫不相干的东西。于是我们需要把「锁」这件事外置到一个所有进程都能访问的公共存储上——这就是分布式锁。

听起来只是「把锁挪到 Redis / ZooKeeper 上」,但真正难的从来不是加锁,而是在进程崩溃、GC 停顿、网络分区、时钟漂移这些异常下,锁还正不正确。这一篇就把 Redis、ZooKeeper、etcd 三条主流路线拆到机制级,讲清它们各自「防住了什么、防不住什么」。

本文是分布式系统系列的第 3 篇。 系列顺序:① 分布式共识(开篇)· Paxos 与 Raft 图解;② ZooKeeper 与 etcd 深挖 · ZAB、Raft、Watch 与租约;③ 分布式锁深挖 · Redis Redlock vs ZooKeeper/etcd(本篇);④ 分布式事务深挖 · 2PC、TCC、Saga 与 Outbox;⑤ 分布式 ID 深挖 · 雪花算法及其变体

一句话定位:分布式锁不是「把 synchronized 搬到网络上」,而是「在没有共享内存、随时可能崩溃与分区的世界里,尽量逼近互斥」——它永远是正确性、性能与复杂度之间的一场取舍,而不是一个能 100% 保证的原语。

TL;DR

Table of contents

Open Table of contents

1. 为什么需要分布式锁:四条正确性要求

先把问题定义清楚。单机锁依赖「共享内存 + 操作系统调度」,多进程跨机后这两个前提都不在了,我们要用一个外部存储来协调。但一个够格的分布式锁,必须同时满足四条要求——事故几乎都是其中某一条没兜住:

  1. 互斥(Mutual Exclusion):任意时刻,至多一个客户端持有锁。这是锁存在的意义。
  2. 防死锁(Deadlock-free):持有锁的客户端崩溃 / 断网 / 被 kill 后,锁必须能被释放,否则临界区永远进不去。这条决定了「锁一定要有超时或会话绑定」——不能只靠客户端主动 unlock
  3. 容错(Fault Tolerance):锁服务本身部分节点故障时,加解锁仍能进行。这条把我们推向「多副本」——单实例 Redis 一挂锁就全没了。
  4. 可重入(Reentrant):同一持有者(同一线程 / 同一请求链路)可以重复加同一把锁而不自锁死。实现上靠「持有者标识 + 计数器」。

这四条之间是互相拉扯的。比如:为了「防死锁」给锁加 TTL,就引入了「持有者还在干活、锁却过期了」的风险(§2、§3 的主线);为了「容错」上多副本,又要面对「副本间不一致 / 主从切换丢锁」(§6 的 CP vs AP)。没有免费的分布式锁——你总在拿一条要求换另一条。

记住这四条,后面每个方案都可以拿它来「验收」:Redis 单实例满足前两条、容错弱;Redlock 补容错但仍受 TTL 之困;ZooKeeper/etcd 用共识 + 会话把四条都做扎实,代价是更重、更慢。

2. Redis 单实例锁:SET NX + 唯一 value + Lua 释放 + 看门狗

绝大多数团队的分布式锁是从「一台 Redis」开始的。它足够快、足够简单,但每一个细节都有坑。

2.1 加锁:一条 SET 搞定原子性

错误示范是「先 SETNXEXPIRE」两条命令——如果 SETNX 成功后进程崩了,EXPIRE 没执行,这把锁就永不过期,直接违反「防死锁」。正确写法是用一条命令同时完成「不存在才设 + 设超时」:

SET lock:order:42 <uuid> NX PX 30000

2.2 释放:为什么必须走 Lua

释放锁的「直觉写法」是 DEL lock:order:42——大错特错。考虑这个交错:

  1. 客户端 A 加锁成功,TTL 30s;
  2. A 的业务卡了 31s,锁自动过期
  3. 客户端 B 获取到锁(合法,因为 A 的锁已过期);
  4. A 业务终于跑完,执行 DEL——把 B 的锁删了

于是 B 还在临界区里,锁却没了,第三个客户端 C 又能进来……互斥彻底崩坏。根因是 DEL 没有校验「这把锁还是不是我的」。正确释放要「先比对 value 是不是自己的 uuid,是才删」,而「比对 + 删」这两步必须原子,所以用 Lua(Redis 执行 Lua 脚本是单线程原子的,见 Redis 线程模型与性能):

-- KEYS[1]=lock key, ARGV[1]=本客户端的 uuid
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0    -- 不是我的锁,绝不删
end

Redis 单实例锁的正确时序图:客户端 A 用 SET lock:order uuid NX PX 30000 一条命令原子加锁成功,value 用唯一 uuid 标记持有者;随后注册看门狗线程,看门狗每 TTL/3 约 10 秒触发一次,执行 Lua 脚本比对 value 等于 uuid 后 PEXPIRE 续期 30 秒,业务没跑完就一直续,因此业务执行时间即使超过 TTL 锁也不会中途过期被别人抢走;业务完成后客户端执行 Lua 脚本比对 value 等于 uuid 才 DEL 原子释放并停止看门狗;底部提示误删陷阱:释放若直接 DEL 而不先比对 value,可能删掉你超时后已被他人重新获得的那把锁,所以必须用 Lua 判断后再删。

Redis 锁的完整生命周期:SET NX PX 原子加锁 + 唯一 value 标识持有者 + Lua 比对后释放,再叠一个看门狗周期续期。三样缺一,锁就在某个异常路径上失守。

2.3 误删问题:唯一 value 是安全释放的前提

§2.2 的交错说明了:value 必须唯一。如果所有客户端都用固定 value(甚至没有 value),Lua 里的 GET == ARGV[1] 就形同虚设,误删照样发生。所以 value 要能唯一标识「本次持有」——常见做法是 UUID:threadId。可重入锁(§2.5)也是靠这个 value 来判断「是不是同一个持有者又来加锁」。

2.4 锁续期:看门狗(Redisson watchdog)

给锁设 TTL 是为了防死锁,但 TTL 带来一个新矛盾:TTL 设短了怕业务没跑完锁就过期,设长了怕持有者崩溃后锁迟迟不释放

Redisson 的解法是 watchdog(看门狗)lock() 时若不显式指定 leaseTime,Redisson 会给一个默认 30s 的 TTL,并启动一个后台线程,每 TTL/3(≈10s)检查一次——只要持有者还活着、业务还没结束,就用 Lua 把锁续回 30s。这样:

RLock lock = redisson.getLock("lock:order:42");
lock.lock();                    // 不传 leaseTime → 启用看门狗,默认 30s 自动续期
try {
    doBusiness();               // 哪怕跑 5 分钟,watchdog 也会一路续到业务结束
} finally {
    lock.unlock();              // 释放并停狗(内部走 Lua 比对 value)
}

// 对比:显式 leaseTime 则「不续期」,到点即释放,适合能严格约束耗时的场景
lock.lock(10, TimeUnit.SECONDS);

看门狗解决了「TTL 与业务耗时」的矛盾,但它解决不了 GC 停顿:如果持有者 STW 停顿超过了 TTL,看门狗线程也一起被冻住、无法续期,锁照样过期——这正是 §3 Kleppmann 质疑的核心,也是为什么「续期」只是缓解、fencing token 才是根治。

2.5 可重入:value + 计数器

可重入锁要记录「持有者是谁」和「重入了几层」。Redisson 用 Hash 结构实现:field 是持有者标识(UUID:threadId),value 是重入计数。同一线程再次 lock() 时计数 +1,unlock() 时 -1,减到 0 才真正删 key。这样递归调用、同一请求链路里的多次加锁不会自锁死。

3. Redlock 算法与 Kleppmann 的质疑

3.1 单实例的致命弱点:主从切换丢锁

§2 的单实例锁有个绕不过去的问题:Redis 挂了怎么办? 上主从 + 哨兵(见 Redis 持久化与高可用)能提高可用性,但主从复制是异步的,于是:

  1. 客户端 A 在 master 上加锁成功;
  2. master 还没把这条写同步给 slave 就宕机了;
  3. 哨兵把 slave 提升为新 master——新 master 上没有这把锁
  4. 客户端 B 在新 master 上又加锁成功 → A、B 同时持锁,互斥破坏。

这是异步复制的固有问题:锁的「已提交」在故障切换时可能丢失

3.2 Redlock:多实例多数派

为了不依赖主从复制,Redis 作者 antirez 提出了 Redlock:部署 N 个完全独立(无主从关系)的 Redis 实例(通常 N=5),加锁时:

  1. 记录开始时间,用相同的 key 和 value依次向 N 个实例请求加锁(每次请求带一个远小于锁 TTL 的超时,避免卡在某个挂掉的实例上);
  2. 只有在**多数派(≥ N/2 + 1,即 5 取 3)**实例上加锁成功,「获取锁总耗时 < 锁 TTL」,才算加锁成功;
  3. 加锁成功后,锁的实际有效时间 = 初始 TTL − 获取锁耗时
  4. 若失败(没拿到多数派 / 总耗时超了),向所有实例发释放请求(包括没加成功的,兜底)。

多数派的意义:即使少数实例宕机,只要多数还在就能加解锁,且两个客户端不可能同时在多数派上成功(鸽巢原理)。这补上了单实例「容错」的短板。

3.3 Kleppmann 的质疑:GC / 网络延迟让锁失效

2016 年,Martin Kleppmann 发表了著名的 How to do distributed locking,指出 Redlock(乃至任何靠 TTL 的锁)在时序假设上是不安全的。核心论点:分布式系统里没有可靠的时间边界——进程可能因 GC、缺页、CPU 调度而任意长时间停顿,网络包可能任意延迟。于是:

GC 停顿导致锁过期、双持有者与 fencing token 拦截的时序图:进程 1 作为原持锁者从锁服务获取锁,拿到 fencing token 等于 33,TTL 30 秒;进程 1 进入临界区开始处理,随后发生长时间 STW GC 停顿 40 秒(超过锁 TTL),期间它仍以为自己持锁;锁在 30 秒到期自动释放;进程 2 获取锁成功拿到 fencing token 等于 34,向存储携带 token 34 写入,存储记录已见过的最大 token 等于 34;此时 GC 结束,进程 1 复活,对锁已失效毫不知情继续往下写,携带过期的 token 33 写入存储,存储发现 33 小于已见最大的 34 于是拒绝这次陈旧写入;底部说明:没有 fencing token 时 GC 期间两个进程先后进入临界区会导致重复扣减、超投或写脏,而单调递增的 token 让存储侧只认更大的、天然拒绝迟到的过期持有者。

Kleppmann 的经典场景:持锁进程 GC 停顿超过 TTL,锁过期被他人获取,「复活」后仍以为自己持锁而写脏。靠锁本身无法阻止——因为锁已经合法地易主了。

时序拆解:① 进程 1 获取锁,TTL 30s;② 进程 1 触发 STW GC 停顿 40s;③ 锁在第 30s 过期,进程 2 合法获取;④ 进程 2 开始写;⑤ GC 结束,进程 1「复活」,它不知道锁早就不是自己的了,继续往下写 → 进程 1、2 的写交错,数据被写坏。

关键在于:这不是 Redlock 特有的 bug,任何用 TTL 自动过期的锁都有这个问题(ZooKeeper 会话超时同理)。看门狗也救不了——GC 期间续期线程也被冻住。

3.4 fencing token:把互斥责任下沉到资源

Kleppmann 给的根治方案是 fencing token(防护令牌):锁服务每次授予锁时,附带一个单调递增的 token(进程 1 拿到 33,进程 2 拿到 34)。客户端每次访问被保护的资源都带上这个 token,而资源侧(存储 / DB / 文件系统)记住见过的最大 token,拒绝任何更小的

于是回到上面的场景:进程 2 带 token=34 写成功,存储记下 max=34;进程 1「复活」带 token=33 来写,存储发现 33 < 34拒绝。即使锁层面出现了「双持有者」,资源层面依然保证了互斥

fencing token 的深刻之处在于:它承认「锁不可能 100% 互斥」,转而把最终的互斥保证下沉到被保护的资源本身。ZooKeeper 的 zxid、etcd 的 revision 天然就是单调递增的,可以直接当 fencing token 用;Redis 则需要你自己维护一个 INCR 计数器。要真正的正确性,光有锁不够,还得让下游认 token。

3.5 争论的结论

antirez 随后回应称 Kleppmann 对时序模型的假设过于苛刻,并强调 Redlock 在实践中的定位。这场争论没有「谁对谁错」的简单答案,但给工程实践留下了清晰的指引:

4. ZooKeeper 锁:临时顺序节点 + 前驱 Watch

ZooKeeper 走的是另一条路——它本身是个 CP 的协调服务(底层 ZAB 协议,见 ZooKeeper 与 etcd 深挖),用它的数据模型能构造出正确性更强的锁。

4.1 两种关键节点

组合起来就是 临时顺序节点(EPHEMERAL_SEQUENTIAL)

4.2 加锁:序号最小者持锁,其余 watch 前驱

ZooKeeper 分布式锁的临时顺序节点与前驱 Watch 结构图:顶部是持久节点 /locks/order:42 作为锁根;其下用 create -e -s 创建了三个临时顺序节点,从左到右分别是 lock-0000000001 属于客户端 A、序号最小所以持锁,lock-0000000002 属于客户端 B、watch 前一个节点 lock-…001,lock-0000000003 属于客户端 C、watch 前一个节点 lock-…002;B 和 C 各自只用虚线 watch 自己的前驱而不是父节点;底部说明:谁创建的顺序节点序号最小谁就拿到锁,其余排队,释放时持锁者删除自己的节点或会话断开时临时节点自动消失;只 watch 前一个而非父节点,前驱释放时只唤醒紧邻的后继一个客户端,避免所有等待者同时被唤醒去抢锁的惊群效应;临时节点是防死锁的关键,持锁进程崩溃后 ZK 会话超时节点自动删除锁自动释放,无需依赖 TTL。

ZooKeeper 锁:所有竞争者在锁根下建临时顺序节点,序号最小者持锁;其余各自只监听「前一个」节点。前驱释放时只唤醒紧邻的一个,这是避免惊群的关键设计。

加锁流程:

  1. 在锁根 /locks/order:42 下创建临时顺序节点,拿到自己的序号(如 lock-0000000002);
  2. 列出锁根下所有子节点,如果自己序号最小 → 获得锁
  3. 否则,只对「前一个」序号的节点注册 Watch,然后阻塞等待;
  4. 前驱节点被删除(前驱释放锁或崩溃)时,Watch 触发,客户端被唤醒,回到第 2 步判断——此时自己通常已是最小 → 拿到锁。

释放锁就是删除自己创建的那个节点;若持有者崩溃,会话超时后临时节点被 ZK 自动删除。

4.3 为什么 watch 前驱而不是 watch 父节点:避免惊群

一个朴素实现是「所有等待者都 watch 锁根 / 都 watch 当前持锁节点」。问题是:持锁者一释放,所有等待者同时被唤醒、一起去抢锁,但最终只有一个能成功——这就是惊群效应(herd effect),N 个客户端里 N-1 个是白忙活,还给 ZK 造成瞬时压力。

只 watch 前一个节点就优雅地避开了:形成一条「链」,前驱释放时只唤醒它的直接后继一个客户端。每次锁的交接只涉及一次唤醒,扩展性好得多。这是 ZooKeeper 锁最精妙的设计,Curator 的 InterProcessMutex 就是这么实现的(还自带可重入)。

// Apache Curator:生产级 ZK 锁,已封装临时顺序节点 + 前驱 watch + 可重入
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order:42");
if (lock.acquire(10, TimeUnit.SECONDS)) {   // 带超时地获取
    try {
        doBusiness();
    } finally {
        lock.release();                     // 删除自己的临时顺序节点
    }
}

ZK 锁比 Redis 锁正确性更强:临时节点根治防死锁(无需 TTL)、顺序节点提供排队与 fencing、ZAB 共识保证多数派一致。代价是性能——每次加解锁都是一次要过半数节点确认的写操作(见 §6)。

5. etcd 锁:Lease + Txn(CAS)+ Revision 顺序

etcd 是云原生时代的协调存储(Kubernetes 的元数据就存在它上面),底层是 Raft(见 分布式共识开篇)。它实现锁的思路和 ZK 神似,只是原语不同。

5.1 三个关键原语

5.2 加锁流程(concurrency 包已封装)

etcd 官方的 clientv3/concurrency 包提供了现成的 Mutex,逻辑与 ZK 高度对称:

  1. 创建一个带 TTL 的 Session(内含 lease + 自动 KeepAlive)
  2. 以「公共前缀 + lease id」为 key,用 Txn CAS 把自己 Put 进去,拿到自己的 CreateRevision
  3. 查询该前缀下所有 key,如果自己的 CreateRevision 最小 → 获得锁
  4. 否则 watch 紧邻的前一个 revision 的 key(同样避免惊群),等它被删除后再判断。
cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
// Session 内含 lease + 自动 KeepAlive(续租),会话结束锁自动释放
sess, _ := concurrency.NewSession(cli, concurrency.WithTTL(30))
defer sess.Close()

mu := concurrency.NewMutex(sess, "/locks/order:42")
mu.Lock(context.TODO())      // CAS 抢占 + 按 revision 排队 watch 前驱
defer mu.Unlock(context.TODO())

doBusiness()

命令行也能直接用:etcdctl lock order:42 -- <command>——拿到锁后执行 command,退出即释放。

etcd 锁 = ZK 锁的「Raft 版」:Lease ↔ 临时节点、Revision ↔ 顺序号、Txn CAS ↔ 创建即排队、watch 前驱 ↔ 避免惊群。两者都是 CP,正确性同级;选型更多看技术栈(云原生 / K8s 生态选 etcd,Java / Hadoop 生态选 ZK)。

6. 三方案对比:性能、正确性、复杂度、CP vs AP

把三条路线放到一张表里横向比:

维度Redis(单实例 / Redlock)ZooKeeperetcd
底层模型内存 KV,主从异步复制(偏 APZAB 共识(CPRaft 共识(CP
互斥保证单实例强、主从切换可能丢锁;Redlock 靠多数派强(共识 + 顺序节点)强(共识 + revision)
防死锁TTL 自动过期(需看门狗续期)临时节点(会话断开自动删,最可靠)Lease(需 KeepAlive 续租)
排队 / 公平无原生排队(抢占式,可能饿死)顺序节点,天然 FIFO 公平revision 排序,天然 FIFO 公平
避免惊群—(抢占式)watch 前驱watch 前驱 revision
fencing token需自己维护 INCR顺序号天然可用revision 天然可用
性能 / 延迟最高(内存操作,无共识)中(每次加解锁要过半数确认)中(Raft 过半确认)
复杂度 / 运维低(一个 Redis 就能起)高(要维护 ZK 集群)中高(要维护 etcd 集群)
适用场景高并发、锁只为提效、能容忍偶发失效强一致协调、Java 生态云原生 / K8s 生态、强一致

一句话选型口诀:

锁只为「效率」(避免重复劳动)→ Redis(快、简单,偶尔失效无非多做一次);锁关乎「正确性」(绝不能双持有者写同一资源)→ ZooKeeper / etcd(CP、有天然 fencing token),且下游最好再认一次 token别用 Redlock 去追求「既快又强一致」——那正是 Kleppmann 质疑的灰色地带,多数场景要么退回单实例 Redis + fencing,要么直接上 CP。

CP vs AP 的本质区别在网络分区时的取舍:ZK/etcd 在分区、无法达成多数派时会拒绝服务(不可用)以保证不出现两个持有者(一致性优先);Redis 主从在分区时倾向继续可用,代价是可能出现「老 master 和新 master 各授一把锁」(可用性优先)。这也是为什么「要正确性就得忍受 CP 系统在极端情况下的不可用」。

7. 什么时候「别用分布式锁」

分布式锁是并发控制里最重、最容易用错的工具。上锁之前,先问一句:这个问题真的需要互斥吗,还是有更轻的办法? 很多时候答案是后者:

7.1 能幂等就别加锁

如果操作天然幂等(执行一次和执行 N 次结果相同),根本不需要互斥——重复执行无害。例如「订单状态从 待支付 改为 已支付」,用条件更新即可:

-- 只有当前是「待支付」才更新,重复执行第二次影响 0 行、无副作用
UPDATE orders SET status = 'PAID' WHERE id = 42 AND status = 'UNPAID';

这比「加锁 → 读状态 → 判断 → 改状态 → 解锁」轻太多,且没有锁的任何异常路径。幂等设计的系统方法见 幂等与一致性

7.2 能乐观锁就别用悲观锁

读多写少、冲突概率低时,用版本号 / CAS(乐观锁)代替分布式锁(悲观锁):读时带上版本,写时校验版本没变才提交,变了就重试。没有锁的持有、续期、释放,也没有死锁:

UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 42 AND version = 7;   -- 影响 0 行说明被人改过 → 重试

7.3 能单分区串行化就别跨节点抢锁

如果能把「同一把锁保护的操作」路由到同一个分区 / 同一个线程串行处理,就把「分布式互斥」降维成了「单机串行」——根本不需要锁。典型如:按 订单ID hash 把请求路由到固定的 Kafka 分区,由单消费者线程顺序处理(同分区天然有序);或 actor 模型里同一实体的消息由单 actor 串行处理。这也是缓存一致性里「单 key 串行」思路的延伸。

分布式锁的排序:幂等 > 乐观锁 / CAS > 单分区串行化 > 分布式锁。真到了非用不可(比如「全局只能有一个定时任务实例在跑」「秒杀扣库存又没法做成幂等」),再上锁,并且按 §3 的结论决定要不要叠 fencing token。锁是最后的手段,不是第一反应。

参考


views
Share this post on:

Previous Post
分布式事务深挖 · 2PC、TCC、Saga 与 Outbox
Next Post
ZooKeeper 与 etcd 深挖 · ZAB、Raft、Watch 与租约