ZooKeeper 做分布式锁是最常见的用法。这篇用 kazoo 的 Lock 在真实集群上做五个实验:互斥是否成立、锁在服务端长什么样、为什么不惊群、持有者崩溃要多久释放,以及最容易被忽略的 —— 持有者没死、只是停顿了一下,会发生什么。
没有锁:最终 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 单调递增这个前提),正确做法是让被保护的资源(数据库、文件服务)校验令牌、拒绝较小的令牌。