跳到正文

用 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 子节点:

  1. create -e -s /locks/order/123/lock-,拿到自己的节点名。
  2. getChildren 取所有子节点,按序号排序。
  3. 自己最小 → 获得锁;否则对前一个节点注册 exists(pred, watch=true)。
  4. 前驱删除触发通知,重新取子节点判断;仍然不是最小就继续 Watch 新的前驱。
  5. 释放就是删除自己的节点。

不 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、ctimectime 是当天 → 刚重建过
清理任务的作用范围脚本是否删到持久父路径删父节点即破坏序号单调性
竞争是否跨路径是否有的实例锁 /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,看你要公平和一致性,还是要吞吐和低延迟。

把一门技术讲透到既懂也会用