如何用 Zookeeper 实现分布式锁?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

当面试官提出 “如何用 Zookeeper 实现分布式锁?” 这个问题时,他不仅仅是在问一个 API 的调用方法。其核心考察点在于:

  1. 对分布式协调服务核心概念的理解:候选人是否理解 Zookeeper 的数据模型(ZNode)、节点类型(持久、临时、顺序)以及 Watch 机制。面试官想听你解释这些基础组件是如何为分布式锁提供支持的。
  2. 对分布式锁核心问题的洞察:面试官想考察你是否理解一个分布式锁方案需要解决的三个核心问题:
    • 互斥性:如何保证在同一时刻,只有一个客户端能持有锁。
    • 避免死锁:如何确保锁最终能被释放(即使持有锁的客户端崩溃)。
    • 容错性:在分布式环境下,如何应对网络分区和节点故障。
  3. 方案设计与实现能力:你是否能基于 Zookeeper 的特性,设计出一个可行的实现方案。这里面试官更想知道你为什么选择某种方案,而不仅仅是步骤。
  4. 对比与权衡思维:在分布式锁的众多实现中(如 Redis、数据库等),为什么选择 Zookeeper?它的优势和劣势是什么?这考察了候选人的技术选型能力。

核心答案

利用 Zookeeper 实现分布式锁,最经典和可靠的方式是使用 临时顺序节点。核心流程如下:

  1. 加锁:所有客户端尝试在指定的锁节点(如 /locks/myLock)下创建一个临时顺序节点(如 /locks/myLock/lock-000000001)。
  2. 判断与等待:客户端获取 /locks/myLock 下所有子节点,并判断自己创建的子节点序号是否最小
    • 如果是最小序号节点,则获取锁成功
    • 如果不是最小,则对序号排在自己前一位的节点设置一个 Watch 监听。
  3. 监听与唤醒:未能获取锁的客户端进入等待。当前一位节点被删除(即锁被释放)时,Zookeeper 会通过 Watch 通知该客户端。客户端被唤醒后,回到步骤 2 重新判断自己是否变为最小节点。
  4. 释放锁:客户端业务逻辑执行完毕后,主动删除自己创建的那个临时顺序节点。由于是临时节点,如果客户端会话(Session)失效(如进程崩溃),节点也会被自动删除,从而避免了死锁。

深度解析

原理与机制

  • 临时节点(Ephemeral):绑定客户端会话。客户端断开连接(正常或异常)后,节点自动消失。这完美解决了避免死锁的问题。
  • 顺序节点(Sequence):节点名由 Zookeeper 自动附加单调递增序号。这为实现公平的 FIFO 队列提供了基础,每个锁竞争者都能按其到达顺序排队。
  • Watch 机制:客户端可以在 ZNode 上设置监听。当该节点发生变化(如被删除)时,Zookeeper 会主动通知注册了 Watch 的客户端。这实现了高效的阻塞与唤醒,避免了轮询带来的性能消耗。
  • 羊群效应:早期的 “临时节点” 方案(所有竞争者监听同一个节点)会在锁释放时惊动所有等待者,造成网络压力,这就是“羊群效应”。而监听前一个顺序节点的方案,每次锁释放只精确通知下一个竞争者,完美解决了这个问题。

代码示例(伪代码/核心逻辑)

public boolean tryLock() throws Exception {
    // 1. 创建临时顺序节点,返回节点路径,如:/locks/myLock/lock-000000001
    String currentLockPath = zk.create(basePath + "/lock-", 
                                        new byte[0], 
                                        ZooDefs.Ids.OPEN_ACL_UNSAFE, 
                                        CreateMode.EPHEMERAL_SEQUENTIAL);
    
    // 2. 获取锁目录下所有子节点,并排序
    List<String> children = zk.getChildren(basePath, false);
    Collections.sort(children);
    
    // 3. 判断当前节点是否是最小节点
    if (currentLockPath.equals(basePath + "/" + children.get(0))) {
        // 是最小节点,成功获取锁
        return true;
    } else {
        // 4. 不是最小节点,找到前一个节点
        String previousNodePath = basePath + "/" + getPreviousNodeName(children, currentLockPath);
        // 5. 在前一个节点上设置 Watch,并同步等待
        CountDownLatch latch = new CountDownLatch(1);
        Stat stat = zk.exists(previousNodePath, event -> {
            if (event.getType() == EventType.NodeDeleted) {
                latch.countDown(); // 前一个节点被删除,唤醒
            }
        });
        if (stat != null) { // 前一个节点还存在,才需要等待
            latch.await();
        }
        // 6. 被唤醒后,递归或循环尝试再次判断(此时自己应该是最小节点了)
        return tryLock(); // 简单示例,实际需处理并发和异常
    }
}

public void unlock() {
    // 删除自己创建的临时节点
    zk.delete(currentLockPath, -1);
}

对比分析:Zookeeper 分布式锁 vs Redis 分布式锁

特性Zookeeper 分布式锁Redis 分布式锁(SETNX + Lua)
一致性模型CP(强一致性)。锁状态在集群内同步后,绝对可靠。AP(高可用)。主从异步复制下,极端情况可能出现锁失效。
实现复杂度较复杂,需理解 ZNode、Watch 等概念。较简单,核心是 SETNX 命令和过期时间。
性能相对较低。写操作(创建节点)需集群多数节点确认,网络开销大。非常高,基于内存操作。
功能特性天然公平锁(顺序节点)、可重入(需客户端记录)、无过期时间(会话保活)。非公平锁需设置超时防止死锁(但带来锁续期问题)。
适用场景强一致性要求高的核心业务,如金融交易、分布式协调。高性能、高并发要求高,且可容忍极小概率锁失效的场景,如秒杀库存扣减。

常见误区与最佳实践

  • 误区:忽视 Session 超时:Zookeeper 客户端需维持心跳。网络严重抖动导致 Session 过期,即使业务仍在运行,临时节点也会被删除,导致锁意外释放。最佳实践是使用成熟的客户端(如 Curator),它提供了可重入锁、锁续约等高级封装。
  • 误区:在 Watch 回调中处理复杂业务:Watch 回调在 Zookeeper 的事件线程中执行,应尽快处理,避免阻塞。最佳实践是仅做状态标记(如 CountDownLatch.countDown()),将业务逻辑放到其他线程。
  • 最佳实践:生产环境强烈建议使用 Curator 框架InterProcessMutex 等现成实现,它已处理好所有边缘情况(如连接重试、可重入、锁续约),远比自行实现稳定可靠。

总结

使用 Zookeeper 实现分布式锁,本质上是利用其临时顺序节点Watch 监听机制来构建一个公平的、可靠的分布式协调队列。其 CP 特性保证了锁的强一致性,适用于对正确性要求严苛的核心场景,但性能是其需要权衡的代价。