如何用 Zookeeper 实现分布式锁?

#中间件#面试八股

如何用 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 锁

维度ZKRedis
一致性CP,锁状态强一致AP,主从切换可能丢锁
公平性顺序节点天然先来先得需要额外排队结构
性能每次加解锁都写 ZK,QPS 低内存操作,QPS 高
持有者宕机会话超时自动释放依赖 TTL 过期
适用低频、强一致、需要公平高频、短临界区

常见追问

  • 追问:为什么不用“最小节点 + 全量 watch”?所有等待者都监听父节点,一次释放会唤醒全部客户端,形成羊群效应,ZK 集群瞬间承受 N 次读。
  • 追问:会话超时和业务执行时间谁长?业务必须短于会话超时,否则锁已释放而业务还在跑,出现并发写。长任务要在临界区内续期并检查会话。
  • 追问:ZK 锁能防脑裂吗?能,写锁必须过半确认,少数派分区无法创建节点,不会出现两个持有者。

小结

  • 临时顺序节点排队,最小序号持锁,只监听前驱,避免惊群。
  • 锁的释放有两条路:主动删除、会话超时删除。
  • 可重入靠本地计数,安全性靠会话状态校验。
  • 强一致、公平、低频选 ZK;高频短临界区选 Redis。
笔记加载中…