bigywater.tech
ZooKeeper · 79

ZooKeeper 分布式锁

ZooKeeper 做分布式锁是最常见的用法。这篇用 kazoo 的 Lock 在真实集群上做五个实验:互斥是否成立、锁在服务端长什么样、为什么不惊群、持有者崩溃要多久释放,以及最容易被忽略的 —— 持有者没死、只是停顿了一下,会发生什么。

35 → 200
10 个客户端累加 200 次,无锁 vs 有锁的最终值
8 vs 29
30 人排队时每次释放服务端多收到的包
2.9 s
kill -9 持有者后下一个拿到锁的时间(会话超时 3 s)

互斥

实测 · 本机执行输出
  没有锁:最终 35 / 期望 200(丢失 165 次更新),用时 0.2 s
  有锁:  最终 200 / 期望 200,用时 2.0 s

计数器放在 ZooKeeper 的一个 znode 里,「读 → 加 1 → 写回」不是原子的,所以无锁时更新互相覆盖。加锁后结果正确,但 200 次累加用时被串行化。

锁在服务端的样子

实测 · 本机执行输出
  /lock2 的子节点(按序号;名字是 <客户端 uuid>__lock__<10 位序号>): ['f0c315…__lock__0000000000', '52d894…__lock__0000000001', '0a9160…__lock__0000000002', '41cab3…__lock__0000000003', '63c107…__lock__0000000004', 'd93d23…__lock__0000000005']
  服务端的 watch: 5 connections watching 5 paths Total watches:5
  持有者释放后,下一个排队者拿到了锁;/lock2 的子节点数 = 5

节点是临时节点(持有者会话没了就消失)加顺序节点(序号决定先后)。持有锁的是序号最小的;其余的每个只 watch 比自己小的上一个。

为什么不会惊群

实测 · 本机执行输出
  每次释放,服务端多收到的请求包:kazoo 锁 8 个;朴素做法(30 人 watch 父节点)29 个

惊群的原因见 watch 风暴。「包」是 mntr 里 zk_packets_received 在一次释放前后的差值(5 次释放取平均),包含 ping 等所有包,不是严格的请求数。

持有者崩溃

实测 · 本机执行输出
  kill -9 后 2.9 s,B 拿到了锁(会话超时 3 s)

锁靠会话保活,所以崩溃释放的延迟取决于会话超时。超时设得越小,释放越快,但网络抖动或 GC 时越容易误判。

持有者没死、只是冻结

实测 · 本机执行输出
  A 被冻结;B 拿到了锁
  A 醒来后的日志(时间顺序):
    A ACQUIRED
    A STATE SUSPENDED
    A STATE LOST
    A STATE CONNECTED
    A STILL_THINKS_HELD is_acquired=True
  fencing token:锁节点的 czxid 单调递增,可以让被保护的资源拒绝旧 token(第 4 步的实现留给应用)

这是锁真正的陷阱:ZooKeeper 保证的是「同一时刻锁节点只属于一个有效会话」,不保证持有者的代码知道自己已不再持有。我这里没有实现 fencing token(只确认了 czxid 单调递增这个前提),正确做法是让被保护的资源(数据库、文件服务)校验令牌、拒绝较小的令牌。

分步演示

分布式锁
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 / ZooKeeper 3.9.6(brew 安装的 jar,用 java -cp 直接启动自己的实例,OpenJDK 17 Temurin,JVM 堆 128 MiB)/ 本机回环端口 7300+,数据在临时目录;默认 tickTime=2000;计时用 time.perf_counter,数字每次运行会有小幅抖动。kazoo 2.11.0 的 Lock;「冻结」用 SIGSTOP/SIGCONT,持有者在子进程里;会话超时 3 s
    • 没有验证:fencing token 的完整实现、读写锁(ReadWriteLock)、跨机房网络延迟下的锁行为、Curator 的 InterProcessMutex(见第 93 篇)
    京ICP备2026067502号-1京公网安备11010502063487号