主题
用 ZooKeeper 做分布式锁
一句话:ZK 锁 = 临时顺序节点 + Watch 前驱。所有竞争者排队,序号最小的持有锁;会话过期,临时节点自动删除,锁随之释放。
适用版本:ZooKeeper 3.8/3.9、Curator 5.9.x。Curator 5.x 的 API 与 4.x 基本一致,依赖版本以官方文档为准。
为什么需要
多进程互斥不能只靠数据库行锁:连接断了行锁可能残留,不同库还要额外协调。ZK 的临时节点把「锁的持有」绑定到「会话还活着」:进程崩溃、网络断开、GC 停顿导致会话过期,节点都会被删除,不会留下永久死锁。顺序节点再补上公平性——先到先排队,不靠抢。
核心机制
四种节点
| 类型 | 生命周期 | 典型用途 |
|---|---|---|
| PERSISTENT | 一直存在,除非显式删除 | 配置、目录 |
| EPHEMERAL | 随 Session 存在,会话过期删除 | 实例注册、选主 |
| PERSISTENT_SEQUENTIAL | 持久 + 父节点分配的递增序号 | 全局序列、任务编号 |
| EPHEMERAL_SEQUENTIAL | 临时 + 递增序号 | 分布式锁 |
顺序后缀来自父节点的 cversion,不是 Leader 的内存计数器。cversion 属于 DataTree,会随事务日志持久化,Leader 切换不会重置或跳号。节点名形如 lock-0000000012,同一父节点下单调递增;但父节点被删除再重建时,cversion 会从新节点重新开始,所以锁的父路径必须是长期存在的持久节点。
加锁与释放
资源路径 /locks/order/123 是持久父节点。每个竞争者创建 EPHEMERAL_SEQUENTIAL 子节点:
create -e -s /locks/order/123/lock-,拿到自己的节点名。getChildren取所有子节点,按序号排序。- 自己最小 → 获得锁;否则对前一个节点注册
exists(pred, watch=true)。 - 前驱删除触发通知,重新取子节点判断;仍然不是最小就继续 Watch 新的前驱。
- 释放就是删除自己的节点。
不 Watch 最小节点,是因为最小节点删除会同时唤醒所有等待者,形成羊群效应;Watch 前驱让每次只有一个人被唤醒,唤醒量从 O(N) 降到 O(1)。也不要用「抢建同一个临时节点」的方案,它同样会让所有失败者 Watch 同一个节点。
会话过期就是失锁,没有续期
ZK 没有「锁续期」这个概念。客户端在会话超时内持续 ping 只能维持会话,不能延长一个已经协商好的超时;会话一旦过期,所有临时节点立即删除,锁确定丢失。
Curator 也不会「在过期前重建锁」。它做的是连接管理:ConnectionState.LOST 表示会话已过期,此时锁已经没了;RECONNECTED 表示连接恢复,如果仍是同一个会话,节点还在,锁通常仍持有;如果是新会话,必须重新 acquire()。所以业务在 LOST 之后不能继续写,必须把它当作「锁已释放」处理。
和 Redis 锁的真实差异
「Redis 锁没有原子性的检查归属再释放」已经过时:Lua 脚本可以在 Redis 端原子执行「value 等于我的标识才 DEL」,Redisson 也封装了这套逻辑。真正的差异是租约模型:
- Redis 锁靠 TTL 过期,持有者 GC 停顿超过 TTL,锁会被别人拿走,而原持有者可能还以为自己持有;Redisson 的 watchdog 能续期,但极端停顿仍会断。
- ZK 锁靠会话,GC 停顿超过会话超时,临时节点被删除,问题同样存在。
- 所以两者在「进程暂停」面前都需要 fencing token:锁服务发放单调递增的令牌,下游存储拒绝比已见过令牌更小的写。ZK 可以用 zxid 或顺序号做令牌,Redis 需要额外生成。
选型因此不是「ZK 安全、Redis 不安全」,而是:需要严格 FIFO、低锁竞争、能接受较高写延迟,选 ZK;需要高吞吐、短临界区、已有 Redis 集群,选 Redis,并自己补 fencing。
Curator 常用 Recipe
| Recipe | 用途 |
|---|---|
InterProcessMutex | 可重入互斥锁,顺序节点 + Watch 前驱 |
InterProcessSemaphoreV2 | 控制并发数的信号量 |
LeaderLatch / LeaderSelector | 选主 |
NodeCache / TreeCache | 节点/子树缓存,自动重注册 Watch |
实战场景
一、清理脚本删了锁父节点,序号从头开始
现象:运维写了个“定期清理僵尸锁”的任务,删除 /locks/order/123 下长期为空的父目录。某天该目录被整个删掉又重建,之后两个实例同时拿到“锁”并各自执行了一轮扣减,出现 4 笔重复扣减(经验值,需校准)。日志里两个实例创建的节点名完全相同:lock-0000000001。
排查:顺序后缀来自父节点的 cversion,它会随新父节点归零。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 锁节点名是否重复 | 两个持有者的节点名与序号 | 同名即序号空间被重置 |
| 父节点是否被重建 | stat /locks/order/123 的 cversion、czxid、ctime | ctime 是当天 → 刚重建过 |
| 清理任务的作用范围 | 脚本是否删到持久父路径 | 删父节点即破坏序号单调性 |
| 竞争是否跨路径 | 是否有的实例锁 /locks/order/123、有的锁 /locks/order//123 | 路径差异会让“同一资源”变两个锁 |
| 锁的隔离层级 | 是否多个业务共用 /lock | 共用会互相阻塞,也更容易被误清理 |
顺序节点在同一父节点下单调递增、持久化,Leader 切换不会重置;但父节点被删除再重建时,cversion 从新节点重新开始。
对症做法:
text
1. 锁的父路径必须是长期存在的持久节点,禁止任何清理任务删除:
清理只删自己的临时子节点(会话过期会自动删),或删持久父节点下的“历史标记”节点。
2. 需要“锁的目录可回收”时,加一个不可删的根 + 按业务分层的中间层:
/locks(持久,运维禁改)→ /locks/order(持久)→ /locks/order/123(持久,只增不删)
3. 用 Curator namespace 做应用级隔离,避免不同应用在同一路径竞争。
4. 给序号加一个代际前缀,重建后也不会撞车:
lock-{epoch}-{seq},epoch 来自父节点创建时的 czxid,写入父节点数据里。java
// 关键资源路径预先创建,且创建失败不回滚删除
public void ensureLockRoot(String path) throws Exception {
if (zk.exists().creatingParentContainersIfNeeded().forPath(path) == null) {
zk.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(path);
}
}
// 清理任务的白名单:只允许删除 EPHEMERAL 节点,持久节点删除操作直接拒绝复盘要点:判据是“锁路径的持久父节点 ctime 长期不变”,可以每天快照 stat 做 diff;“同名锁节点被两个实例持有”应该做成实时告警(按 lock- 全路径聚合 sessionId)。另外要承认一点:靠“定期清理僵尸锁”来兜底本身就说明生命周期设计不完整——临时节点的价值就是随会话自动消失,不该有僵尸。
二、所有等待者 Watch 同一个节点,主一换全组抖
现象:自研的 ZK 锁实现是“抢建同一个临时节点 /locks/order/123/owner,失败者 Watch 它”。日常没问题,一次 Leader 短暂 GC 导致临时节点被删,几百个等待实例同时收到通知、同时 getChildren/create,ZK 侧 zk_outstanding_requests 冲到 2 万,随后更多实例会话超时、失锁,形成二次风暴。
排查:唤醒量与 Watch 目标的选择直接相关。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| Watch 目标 | 等待者 Watch 的是“最小节点”还是“自己的前驱” | Watch 公共节点 → 一次删除唤醒全部 |
| 唤醒量 | echo wchs | nc 127.0.0.1 2181 的 watch 总数、zk_watch_count | 单一路径上挂几百个 Watch 即命中 |
| 请求堆积 | zk_outstanding_requests、zk_max_latency | 唤醒即全组重读,写请求排队 |
| 通知是否重复注册 | 一次性触发后是否重新注册 | 未重注册则后续变更收不到(另一个方向的故障) |
| 会话是否被拖垮 | 客户端 ConnectionLoss/LOST 数量与时间 | 处理通知的逻辑阻塞就会超时会话 |
InterProcessMutex 的做法是“自己不是最小就只 Watch 前一个节点”,前驱删除时只唤醒一个人,唤醒量从 O(N) 降到 O(1)。“抢建同一个临时节点”会让所有失败者 Watch 同一个节点,正是被避免的写法。
对症做法:
java
// 用 Curator 的 recipe,不要自己实现锁协议
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(cs).namespace("trade")
.sessionTimeoutMs(30000).connectionTimeoutMs(5000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3)) // 只重试可重试错误
.build();
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order/" + orderId);
if (lock.acquire(3, TimeUnit.SECONDS)) { // 等待要有上限
try { doCriticalWork(); } finally { lock.release(); }
}
// 顺序节点 + Watch 前驱、重注册、可重入计数都由 recipe 处理text
1. 通知回调里不做重活:Watch 处理线程被阻塞会导致会话无法维持,
把“重读 + 决策”放到独立线程池,回调只投递事件。
2. 竞争度本身就是信号:几百个实例抢同一把锁,说明锁粒度错了,
应该按资源分片(如 /locks/order/{shardId})或改用队列串行化。
3. SessionExpired 不重试同一个锁对象:必须重新创建、重新竞争。
4. 锁路径按业务域隔离:/locks/{domain}/{resource},不让不相关的请求互相阻塞。复盘要点:判据是“单一路径上的 Watch 数量”和“锁等待队列长度”,两个都进监控。锁的正确性演练要包含“持锁者会话过期”这一路径,验证只唤醒前驱的后继者、其他等待者不产生额外请求。可重入锁还要额外注意跨线程:InterProcessMutex 按线程计数,A 线程 acquire、B 线程 release 会破坏计数,见 生产踩坑与运维。
三、锁丢了还在写:缺一个 fencing token
现象:账务批处理用 ZK 锁保证同一账期只有一个实例写入。某实例 GC 停顿 40 秒超过会话超时,锁被另一个实例获得;停顿结束的第一个实例继续按内存里的“我持有锁”执行写入,两个实例对同一账期分别写入不同金额,最终账面少记 3 天流水,人工核对两天才定位。
排查:客户端视角的“持锁”与服务端视角的“锁归属”可以不一致。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 会话是否过期 | 该实例的 ConnectionState.LOST 日志与时间 | LOST 即锁已丢失,不是“连接抖动” |
| 是否继续执行 | 写入日志时间是否在 LOST 之后 | 命中说明代码路径没检查状态 |
| 锁的临时节点 owner | 事件时间点上 ephemeralOwner 是谁 | 证明当时锁已归别人 |
| 下游是否有拒绝能力 | 存储层是否记录并校验“见过的最大令牌” | 无校验就无法拒绝旧持有者 |
| 临界区是否含长 IO | 停顿、远程调用、慢 SQL | 决定会话超时被撑爆的概率 |
ZK 锁与 Redis 锁在这里的差别不是“谁更原子”——Lua 已经能原子地做“值等于我的标识才 DEL”——而是租约模型:Redis 靠 TTL 过期,ZK 靠会话过期,进程停顿超过时限都会失去锁。两者在“进程暂停”面前都需要 fencing token。
对症做法:
java
// 1. 抢锁时取一个单调递增令牌:顺序节点序号或 zxid 都可以
long token = parseSeqFromNodeName(myNodeName); // lock-0000000042 → 42
// 2. 下游存储拒绝比已见过令牌更小的写
boolean accepted = ledger.compareAndAdvanceToken(periodId, token);
// UPDATE ledger SET amount=?, token=?
// WHERE period_id=? AND token < ? → affected rows 为 0 表示“我已经被更新的了”
if (!accepted) {
log.error("stale lock holder, token={} < current", token);
roleManager.demote();
return;
}java
// 3. 临界区内每次关键写之前都复核状态,不要只在入口检查一次
if (lost.get() || !lock.isCurrent(lock)) throw new LockLostException();text
4. 令牌空间必须单调且持久:ZK 侧可以用顺序节点序号(随 cversion 持久化);
自己维护计数器就要保证 Leader 切换不回退,否则令牌失去可比性。
5. 会话超时要与最坏停顿成比例(P99.9 停顿 × 3 以上,经验值需校准),
调大超时是拿真实故障的发现时间换 GC 余量,属于最后手段。复盘要点:判据是“下游对旧令牌的拒绝次数可观测”——它不是错误,而是防线的证据;出现拒绝说明锁丢失发生过,但数据没被污染。选型口径因此要说准:需要严格 FIFO、低锁竞争、能接受较高写延迟选 ZK;需要高吞吐、短临界区、已有 Redis 集群选 Redis 并自己补 fencing。无论选哪个,“拿到锁”不等于“一直持有锁”,这一点必须由令牌在数据层兜住,见 Redis 分布式锁。
最小上手
Maven 坐标:org.apache.curator:curator-recipes:5.9.0。
java
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString("127.0.0.1:2181")
.sessionTimeoutMs(30_000)
.connectionTimeoutMs(15_000)
.retryPolicy(new ExponentialBackoffRetry(1_000, 3))
.namespace("demo")
.build();
client.start();
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order/123");
if (!lock.acquire(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("acquire timeout");
}
try {
// 临界区:写业务数据前确认自己的锁仍有效
doBusiness();
} finally {
if (lock.isAcquiredInThisProcess()) {
lock.release();
}
}acquire(10, TimeUnit.SECONDS) 是最长等待时间,不是锁的租期。InterProcessMutex 在同一线程内可重入;跨线程/跨进程仍然是互斥的。跑通验证:zkCli.sh 中 ls /locks/order/123 能看到临时顺序子节点,进程退出后子节点消失。
生产动作
会话超时要和 tickTime 一起算。 客户端请求的 sessionTimeoutMs 会被服务端夹在 2×tickTime ~ 20×tickTime。默认 tickTime=2000ms,请求 30s 可以;请求 60s 会被截到 40s。真要 60s,先把服务端 tickTime 调到 3000ms。超时太短,GC 一停顿就失锁;太长,真宕机后要等更久才释放。
重试策略只重试可重试的错误。 ExponentialBackoffRetry(1000, 3) 适合连接抖动;SessionExpired 不应重试同一个锁对象,必须重新创建/重新竞争。
锁路径按业务域隔离。 用 /locks/{domain}/{resource},并通过 Curator 的 namespace 做应用级隔离。不要多个业务共用 /lock,否则不相关的请求会互相阻塞。
连接状态要显式处理:
java
client.getConnectionStateListenable().addListener((c, state) -> {
if (state == ConnectionState.LOST) { /* 会话过期,锁已丢失,停止写 */ }
if (state == ConnectionState.RECONNECTED) { /* 同会话重连锁仍在;新会话需重新 acquire */ }
});异常处理模板:
java
try {
lock.acquire(10, TimeUnit.SECONDS);
// 临界区
} catch (KeeperException.SessionExpiredException e) {
// 锁确定丢失:放弃本次临界区,绝不能继续写
} catch (KeeperException.ConnectionLossException e) {
// 状态不确定:查自己的顺序节点是否仍存在,再决定继续或放弃
} finally {
if (lock.isAcquiredInThisProcess()) lock.release();
}关键写操作带 fencing。 即使锁看起来还在,也要让下游校验令牌。典型做法是把顺序号或 zxid 作为 fence 随写请求传入,数据库执行 UPDATE ... WHERE fence < #{fence},拒绝旧持有者的迟到写。
监控 GC 停顿。 停顿接近会话超时就是高危;G1 的 MaxGCPauseMillis 只能设目标,不能保证,仍要配合 fencing。
边界
- ZK 锁不是事务。 它只保证互斥,不保证临界区内的多步写原子;事务边界仍由数据库负责。
- 写吞吐有限。 所有锁操作都走 Leader 和多数派落盘;几千 QPS 的短锁不适合 ZK,优先 Redis。
- 没有续期。 不要设计「长任务持有锁几十分钟」;把长任务拆成短临界区,或改用带租约续期的系统。
- 会话过期会造成双持有窗口。 原持有者 GC 恢复后可能继续执行,必须用 fencing token 让下游拒绝旧令牌。
- 不要删除锁的父节点。 父节点重建会让顺序号重置,破坏排队语义。
- 公平不等于高性能。 顺序节点保证 FIFO,但每次释放只唤醒一个等待者,吞吐低于 Redis 的集中式争抢。
讲清楚
ZK 分布式锁的做法是:每个竞争者在一个持久父节点下创建临时顺序节点,序号最小的拿锁,其余只 Watch 自己前一个节点。前一个删除时,后一个被唤醒,再检查自己是不是最小。这样既是公平排队,又避免了所有人同时被唤醒的羊群效应。
顺序号来自父节点的
cversion,是持久化的,Leader 切换不会重置。锁的生命周期绑定会话:会话过期,临时节点删除,锁释放。ZK 没有锁续期,Curator 也不会在过期前帮你重建;收到LOST就必须认为锁已经丢了。和 Redis 锁比,真正的差异不是「Redis 不能原子释放」——Lua 和 Redisson 已经解决了——而是租约模型和停顿风险。两者在 GC 长暂停下都可能出现双持有,所以关键写操作要用 fencing token 兜底。选 ZK 还是 Redis,看你要公平和一致性,还是要吞吐和低延迟。