如何用 Zookeeper 实现分布式锁?
结论先行:标准做法是“临时顺序节点 + 监听前驱”。每个竞争者在自己名下创建 /lock/xxx-000000000N,序号最小者持有锁;其余只监听比自己小的那个节点,前驱删除后自己被唤醒。相比“抢注同一个临时节点”,它避免了羊群效应,且天然公平。
一、两种方案对比
| 方案 | 做法 | 问题 |
|---|
| 抢注单节点 | 所有客户端 create -e /lock,成功者持锁 | 失败者全量 watch 该节点,释放时惊群;不公平 |
| 临时顺序节点 | 各自建顺序节点,最小者持锁,监听前驱 | 实现稍复杂,需要处理前驱消失 |
二、顺序节点排队模型
/lock
├── lock-0000000001 ← 持锁(序号最小)
├── lock-0000000002 ← 监听 0001
├── lock-0000000003 ← 监听 0002
└── lock-0000000004 ← 监听 0003
0001 释放(删除节点)
│
▼
0002 收到 NodeDeleted → 重新判断自己是否最小 → 是 → 持锁
(0003、0004 不受任何影响,无惊群)
三、用 Curator 实现
CuratorFramework client = CuratorFrameworkFactory.newClient(
"127.0.0.1:2181", new ExponentialBackoffRetry(1000, 3));
client.start();
InterProcessMutex lock = new InterProcessMutex(client, "/lock/order-1");
try {
if (lock.acquire(3, TimeUnit.SECONDS)) { // 最多等 3 秒
doBusiness(); // 临界区
}
} finally {
if (lock.isAcquiredInThisProcess()) {
lock.release(); // 删除自己的顺序节点
}
}
InterProcessMutex 是可重入的:同一线程再次 acquire 只增加本地计数,不会重复建节点。
四、自己实现要注意的四件事
| 问题 | 处理 |
|---|
前驱节点在你 getChildren 之后、exists 之前消失 | 捕获 NoNodeException 后重新 getChildren 判断 |
| 会话过期导致锁“凭空消失” | 临界区内定期检查 client.getState(),过期即中止业务 |
| 重入 | 用 ThreadLocal<Map<path, count>> 记录持有次数 |
| 释放顺序 | 只有持有者能删自己的节点,删除前校验 ephemeralOwner |
// 会话过期检测(临界区内的“护栏”)
if (!client.getZookeeperClient().isConnected()) {
throw new IllegalStateException("session expired, abort critical section");
}
五、ZK 锁 vs Redis 锁
| 维度 | ZK | Redis |
|---|
| 一致性 | CP,锁状态强一致 | AP,主从切换可能丢锁 |
| 公平性 | 顺序节点天然先来先得 | 需要额外排队结构 |
| 性能 | 每次加解锁都写 ZK,QPS 低 | 内存操作,QPS 高 |
| 持有者宕机 | 会话超时自动释放 | 依赖 TTL 过期 |
| 适用 | 低频、强一致、需要公平 | 高频、短临界区 |
常见追问
- 追问:为什么不用“最小节点 + 全量 watch”?所有等待者都监听父节点,一次释放会唤醒全部客户端,形成羊群效应,ZK 集群瞬间承受 N 次读。
- 追问:会话超时和业务执行时间谁长?业务必须短于会话超时,否则锁已释放而业务还在跑,出现并发写。长任务要在临界区内续期并检查会话。
- 追问:ZK 锁能防脑裂吗?能,写锁必须过半确认,少数派分区无法创建节点,不会出现两个持有者。
小结
- 临时顺序节点排队,最小序号持锁,只监听前驱,避免惊群。
- 锁的释放有两条路:主动删除、会话超时删除。
- 可重入靠本地计数,安全性靠会话状态校验。
- 强一致、公平、低频选 ZK;高频短临界区选 Redis。