Skip to content
Charles Shao
Go back

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

–views

在单机里,要保证「同一时刻只有一个线程进入临界区」,一把 synchronized 或 ReentrantLock 就够了——前提是所有线程共享同一块内存、跑在同一个 JVM。服务一旦裂成多台机器上的独立进程,这块共享内存就不存在了:进程 A 手里的锁对象,进程 B 根本看不到。于是「锁定与放行」只能下沉到所有进程都能访问的公共存储——这就是分布式锁要解决的问题。

听上去像是「把本地锁挪到 Redis 或 ZooKeeper」,真正难的却不是加锁本身,而是在进程崩溃、JVM Full GC 停顿、网络分区和时钟漂移交织时,互斥约束是否仍然成立。本篇把 Redis、ZooKeeper、etcd 三种主流方案拆到底层机制,说明各自挡住了什么、又在什么条件下会暴露缺陷。

本文是 分布式一致性 系列的第 3 篇(分布式锁)。 全系列 5 篇:

  1. 分布式共识机制 · Paxos 与 Raft 图解
  2. 基于共识引擎的协调服务 · ZooKeeper 与 etcd 底层图解
  3. 分布式锁深挖 · Redis Redlock 与 ZooKeeper/etcd(本篇)
  4. 分布式事务深挖 · 2PC、TCC、Saga 与 Outbox
  5. 分布式 ID 深挖 · 雪花算法及其变体

一句话定位:分布式锁不是把 synchronized 平移到网络层,而是在无共享内存、随时可能崩溃与分区的环境里逼近互斥——永远是正确性、吞吐与复杂度之间的妥协,而不是 100% 绝对安全的原语。

TL;DR

Table of contents

Open Table of contents

1. 分布式锁的正确性边界:四大指标

动手写代码之前,先把问题边界钉死。单机锁靠「共享内存 + OS 调度」成立;跨过机器边界后这两样都没了,只能把信任交给第三方存储。一把工程上可用的分布式锁,至少要同时满足下面四条——现网事故几乎都能归到某一条没守住:

  1. 互斥(Mutual Exclusion):任意时刻至多一个客户端持有锁。这是锁的根本约束。
  2. 防死锁(Deadlock-free):持锁客户端若进程崩溃、断网或 OOM Kill,锁必须仍能被释放,否则临界区会永久堵死。因此设计必须引入「TTL 超时」或「会话断连自动删除」——不能假设客户端总能优雅地调用 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 释放与 Watchdog

多数团队接触分布式锁的第一站是「单节点 Redis」。它快、轻,但每个环节都有容易踩的坑。

2.1 加锁:一条 SET 指令的原子闭环

反面写法是拆成「先 SETNX,再 EXPIRE」两条命令——若 SETNX 成功后进程崩溃、EXPIRE 没执行,锁会变成永不过期,直接违反「防死锁」。正确做法是把「仅当不存在时写入 + 设 TTL」绑成原子操作:

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

2.2 释放:为何必须用 Lua 脚本

释放锁若只做 DEL lock:order:42,在下面时序里会错:

  1. 客户端 A 加锁成功,TTL 30 秒;
  2. A 阻塞超过 31 秒,锁因过期被删除;
  3. 客户端 B 合法拿到锁;
  4. A 醒来执行完业务后盲目 DEL——把 B 的锁删掉了。

此时 B 仍以为自己持锁,C 又能加进来……互斥失效。根因是 DEL 没有校验「这把锁是否仍是我持有的那把」。正确释放必须「先比对 value,一致才删除」,且比对与删除必须原子完成。这就要用 Lua(Redis 单线程执行 Lua,保证原子性;参阅 Redis 线程模型与并发底座):

-- KEYS[1]=锁 key, ARGV[1]=本客户端的 uuid
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0    -- value 不符,锁已易主,不删
end

Redis 单实例锁的正确时序图

Redis 单机锁完整路径:SET NX PX 原子占位 + 唯一 value 标识身份 + Lua 校验后删除,外加 Watchdog 续期。少任一环,极端并发下互斥都可能破。

2.3 误删根因:唯一 Value 是释放正确性的前提

§2.2 的时序说明:value 必须全局唯一。若所有客户端共用同一个常量(甚至为空),Lua 里的 GET == ARGV[1] 形同虚设,误删照样发生。因此要用 UUID:threadId 锚定「本轮持锁身份」。后续可重入(§2.5)也靠这枚指纹判断「是不是同一持有者再次进入」。

2.4 锁续期:Watchdog 看门狗

TTL 用来防死锁,却带来新矛盾:TTL 太短,长业务可能中途被剥夺;TTL 太长,进程崩溃后锁空转时间过久。

Redisson 的做法是 Watchdog(看门狗):调用 lock() 且未指定 leaseTime 时,默认 TTL 30 秒,并启动后台线程按 TTL/3(约 10 秒)续期——只要进程仍存活且未 unlock,就用 Lua 把 TTL 续回 30 秒。效果是:

RLock lock = redisson.getLock("lock:order:42");
lock.lock();                    // 未指定 leaseTime → 启用看门狗,默认 30 秒基线续期
try {
    doBusiness();               // 即使耗时很长,看门狗也会续到 unlock
} finally {
    lock.unlock();              // 释放锁并停掉看门狗(内部仍用 Lua 校验 value)
}

// 反例:指定 leaseTime 表示不续期,到期即释放,适合严格限时的短路径
lock.lock(10, TimeUnit.SECONDS);

看门狗解决了「业务时长与 TTL」的矛盾,但挡不住 GC 停顿:持锁进程若陷入超长 STW,且停顿超过 TTL,看门狗线程同样被冻结,锁会过期易主——这正是 §3 Kleppmann 批评的核心,也说明「续期」治标不治本,真正兜底要靠 fencing token。

2.5 可重入:指纹 + 重入计数

可重入既要记「谁持有」,也要记「嵌套了几层」。Redisson 用 Hash:field 是持锁方指纹(UUID:threadId),value 是重入计数。同一线程再次 lock() 时计数加一,unlock() 时减一,减到 0 才删 key。这样递归或嵌套调用不会自死锁。

3. Redlock 多数派与 Kleppmann 的质疑

3.1 单节点风险:主从切换丢失锁状态

单节点方案绕不开:Redis 宕机怎么办? 主从 + 哨兵(见 Redis 数据持久与高可用防线)能提高可用性,但 Redis 主从复制默认是异步的,会留下窗口:

  1. 客户端 A 在 Master 上加锁成功;
  2. Master 在把该写同步到 Slave 之前宕机;
  3. 哨兵把 Slave 提升为新 Master——新 Master 上没有这把锁;
  4. 客户端 B 在新 Master 上再加锁成功 → A、B 同时持锁,互斥被打破。

这是异步复制的固有问题:已经在旧 Master「确认」的锁,在故障切换窗口里可能消失。

3.2 Redlock:跨多节点的多数派协议

为摆脱主从异步复制,antirez 提出了 Redlock:部署 N 个相互独立(无主从关系)的 Redis 实例(常见 N=5),流程是:

  1. 客户端记录起始时间,用同一 Key 与 value,向 N 个实例并发加锁(单次请求设短超时,避免被慢节点拖死);
  2. 仅当在**法定多数(≥ N/2 + 1,五节点至少三)**上成功,且总耗时小于初始 TTL,才算持锁成功;
  3. 成功后,锁的有效剩余时间 = 预设 TTL − 加锁耗时;
  4. 若失败(票数不够或超时),向全部 N 个实例发解锁(包括未加成功的节点),做清理。

多数派的含义:少数节点挂了,只要过半还在,加解锁就能继续;且按鸽巢原理,两个客户端不可能在同一时刻都拿到多数。这补上了单实例的「容错」缺口。

3.3 Kleppmann 的批评:GC 停顿与网络延迟击穿 TTL

2016 年,Martin Kleppmann 在 How to do distributed locking 中指出:Redlock(以及一切强依赖 TTL 的锁)在分布式时序假设上站不住。核心论据是:真实系统没有牢不可破的时钟边界——进程可能因 JVM GC、缺页、CPU 饥饿停顿数十秒,网络也可能任意延迟。后果是:

GC 停顿导致锁过期、双持有者与 fencing token 拦截的时序图

Kleppmann 指出的问题:持锁方 STW GC 停顿超过 TTL,锁过期并被他人拿走;原持有者恢复后仍按「自己持锁」继续写。锁服务对此无能为力——因为锁已按规则合法易主。

时序拆开:① 进程 A 加锁成功,TTL 30 秒;② A 发生约 40 秒的 STW GC;③ 30 秒到点,锁过期,进程 B 合法拿到锁;④ B 进入临界区写入;⑤ A 恢复后不知道锁已易主,仍用过期凭证写入 → A 与 B 的写冲突,数据被破坏。

要点:这不是 Redlock 独有的问题,凡靠 TTL 自动过期兜底的锁都受影响。前面的看门狗也救不了——续期线程本身也被 GC 冻住了。

3.4 Fencing Token:把互斥下沉到被保护资源

Kleppmann 给出的防护是 Fencing Token(防护令牌):锁服务每次颁发锁时,附带一枚单调递增的令牌(例如 A 拿到 33,B 拿到 34)。客户端每次写被保护资源时必须带上该令牌;资源侧(DB / 对象存储 / 分布式文件系统)记录见过的最高令牌,并拒绝任何低于该水位的写。

回到上面的场景:B 带着 34 写入,存储记下 max=34;事后 A 带着 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 结构图

ZooKeeper 锁结构:各客户端在锁根下创建临时顺序节点,序号最小者持锁;其余只 Watch「紧邻前驱」。前驱删除时只唤醒后继一个,避免惊群。

流程:

  1. 客户端在锁根 /locks/order:42 下创建一枚临时顺序节点,拿到自己的序号(如 lock-0000000002);
  2. 列出锁根下所有竞争节点,若自己序号最小 → 持锁成功;
  3. 否则,只对「序号略小于自己的前驱」挂 Watch,然后等待;
  4. 前驱节点被删除(前驱主动释放或会话断开)时 Watch 触发,回到第 2 步——此时自己通常已是最小序号 → 拿到锁。

释放很直接:删除自己创建的那个节点;若进程中途崩溃,会话结束,ZK 会自动删掉临时节点。

4.3 为何 Watch 前驱而非父节点:避免惊群

粗糙做法是「所有失败者都 Watch 锁根父目录,或 Watch 当前持锁节点」。问题在于:持锁者一释放,成百上千个等待者同时被唤醒去抢同一把锁,最终只有一个成功——这就是惊群(Herd Effect),浪费 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 防死锁、顺序号兼作排队与 Token、ZAB 多数派保证不脑裂。代价是性能更低:每次加解锁都要过半数节点的日志同步(见 §6)。

5. etcd:Lease 租约 + Txn(CAS)+ Revision

云原生里常与 Kubernetes 元数据绑在一起的 etcd,底层是 Raft(见 分布式共识机制 · Paxos 与 Raft 图解)。锁模型与 ZK 高度同构,只是积木名称不同。

5.1 三块核心积木

5.2 加锁流程(concurrency 库封装)

官方 clientv3/concurrency 的 Mutex 把底层操作包掉了:

  1. 创建带 TTL 的 Session(内部分配 lease + 自动 KeepAlive);
  2. 在「锁根前缀 + 本 Session 的 lease」对应 Key 上,用 Txn CAS 占位,拿到自己的 CreateRevision;
  3. 列出前缀下所有 Key,若自己的 CreateRevision 最小 → 持锁成功;
  4. 否则 Watch 紧邻前驱的 revision,前驱消失后再竞争(同样避免惊群)。
cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
// Session:租约 + 自动 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/AP

横向对比:

维度Redis(单节点 / Redlock)ZooKeeperetcd
底层模型内存为主,主从异步复制(偏 AP)ZAB 强共识(CP)Raft 强共识(CP)
互斥单节点可用;主从切换易丢锁;Redlock 赌多数派强(共识 + 顺序节点)强(共识 + revision)
防死锁TTL(需看门狗续期)临时节点(断连即删,最稳)Lease(需 KeepAlive)
排队公平性无(抢占,可能饿死)顺序节点,FIFOrevision 排序,FIFO
避免惊群—(无排队模型)Watch 前驱Watch 前驱 revision
Fencing Token需自行 INCR顺序号可直接用revision 可直接用
性能最高(内存路径,无重共识)中等(每次过半数同步)中等(Raft 过半提交)
运维成本低(单实例即可起步)高(需维护 ZK 集群)中高(需维护 etcd 集群)
适用场景高吞吐、锁仅用于提效、可容忍极低概率穿透强一致协调、Java 生态云原生 / K8s、强一致

选型可以压成一句话:

锁为「减少重复劳动」→ 优先 Redis(快、轻,偶发穿透多算一点通常可接受);锁为「严禁双写同一关键数据」→ 用 ZooKeeper / etcd(CP,且天然有可用作 Fencing Token 的序号),并在存储侧校验令牌。不要指望 Redlock 同时兼得「高性能 + 绝对一致」——这正是 Kleppmann 批评的焦点。多数生产场景:要么「单机 Redis + 下游 Token」,要么直接上 CP。

CP 与 AP 的分水岭,在网络分区时的行为:ZK/etcd 凑不齐多数时会拒绝服务,避免双主持锁(一致性优先);Redis 主从在分区下往往继续服务,代价是切换窗口里可能丢锁(可用性优先)。若目标是强正确性,就要接受 CP 系统在恶劣分区下可能短暂不可用。

7. 何时不该用分布式锁

在并发控制工具箱里,分布式锁是成本最高、故障模式最多的一类。上锁前先问:是否真的必须互斥?有没有更轻的替代? 很多场景其实可以绕开:

7.1 幂等优先

若操作本身幂等(执行一次与执行多次效果相同),互斥往往多余——重放也不会破坏语义。例如「把订单从 待支付 更新为 已支付」,用条件更新即可:

-- 仅当当前状态为「待支付」才更新;并发重试命中 0 行,无副作用
UPDATE orders SET status = 'PAID' WHERE id = 42 AND status = 'UNPAID';

这比「加锁 → 读状态 → 改写 → 解锁」轻得多,也避开了锁相关的崩溃路径。完整讨论见 幂等与一致性架构。

7.2 乐观锁(CAS)代替悲观锁

读多写少、冲突概率低时,用版本号 / 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 底层图解