如何用 Zookeeper 实现分布式锁?


面试考察点

  1. 方案演进思维:面试官不仅仅是想知道最终方案,更是想看你是否理解 "简单互斥锁 → 公平锁 → 可重入锁" 这个演进过程,以及每一步解决了什么问题。
  2. ZooKeeper 特性运用:临时节点、顺序节点、Watch 机制这三大特性如何在锁方案中组合使用,你能否讲清每个特性解决的是什么问题?
  3. 工程实践意识:生产环境中的锁超时、可重入、羊群效应、Curator 框架的使用这些你是否了解?

核心答案

ZooKeeper 实现分布式锁有三种方案,从简到繁逐步演进:

方案实现方式优点缺点
方案一:普通临时节点创建同一个临时节点,成功即获锁简单直接羊群效应,惊群问题
方案二:临时顺序节点创建临时顺序节点,序号最小者获锁公平锁,避免惊群实现较复杂
方案三:Curator 封装直接用 InterProcessMutex可重入、生产可用需引入框架

生产环境直接用方案三(Curator 框架),面试重点讲方案二的原理。

深度解析

一、方案一:普通临时节点(不推荐)

最朴素的思路:所有客户端争抢创建同一个临时节点,比如 /lock,谁创建成功谁就获得锁。

上图展示了方案一的加锁流程。核心逻辑很简单:尝试创建 /lock 临时节点,成功了就获得锁,失败了就 Watch 等。

致命问题:羊群效应(Herd Effect)

当锁释放(/lock 节点被删除)时,所有等待的客户端都会收到 Watch 通知,然后同时涌上来争抢创建节点。假设有 1000 个客户端在等锁,锁一释放就是 1000 个并发写请求砸向 ZooKeeper,而最终只有一个能成功,其余 999 个白跑一趟。

这就是 "惊群" 问题——大量客户端被无效唤醒,给 ZooKeeper 造成巨大压力,严重时甚至会导致集群性能抖动。

二、方案二:临时顺序节点(推荐理解原理)

这是 ZooKeeper 分布式锁的 标准实现方案,解决了羊群效应,实现了公平锁。

上图展示了方案二的完整加锁流程。和方案一相比,核心改进在于:

  • 每个客户端创建自己的顺序节点,不再是争抢同一个节点。ZooKeeper 保证顺序节点的序号是递增的,所以先来的客户端序号一定更小。
  • 序号最小的客户端获得锁,不需要争抢。
  • 每个客户端只 Watch 前一个节点,而不是 Watch /lock 父节点。这样当锁释放时,只有下一个排队者被通知,其余客户端完全不受影响——彻底解决了羊群效应。
  • 天然公平:先来先得,按创建节点的顺序排队。

释放锁的流程分两种情况:

  • 正常释放:客户端完成业务后,主动删除自己创建的临时顺序节点。
  • 异常释放:客户端宕机或会话超时,临时节点自动删除。下一个等待的客户端收到 Watch 通知,判断自己为最小序号后获得锁。这就是临时节点的价值——保证锁一定会被释放,不会死锁。

三、方案三:Curator 框架封装(生产推荐)

方案二的原理搞清楚后,生产环境直接用 Apache Curator 封装好的 InterProcessMutex,不用自己手写。

// 创建 CuratorFramework 客户端
CuratorFramework client = CuratorFrameworkFactory.builder()
    .connectString("127.0.0.1:2181")
    .sessionTimeoutMs(5000)
    .retryPolicy(new ExponentialBackoffRetry(1000, 3))
    .build();
client.start();

// 创建分布式锁
InterProcessMutex lock = new InterProcessMutex(client, "/order-lock");

try {
    // 加锁(支持超时)
    if (lock.acquire(3, TimeUnit.SECONDS)) {
        try {
            // 执行业务逻辑
            processOrder();
        } finally {
            // 释放锁
            lock.release();
        }
    }
} catch (Exception e) {
    // 加锁超时或异常处理
    log.error("获取分布式锁失败", e);
}

InterProcessMutex 的优势:

特性说明
可重入同一个线程可以多次获取同一把锁,内部用 ConcurrentHashMap 记录重入次数
超时机制支持 acquire(timeout) 带超时的加锁,避免无限等待
锁自动释放基于临时节点,客户端宕机后锁自动释放
连接恢复内部处理了 ZooKeeper 连接断开重连后锁状态的恢复

四、三种常见锁的对比

面试官可能会让你比较 ZooKeeper 锁和 Redis 锁:

选型建议一句话:金融级、锁可靠性要求高的场景选 ZooKeeper;高并发、性能优先的场景选 Redis;没有中间件的小项目用数据库锁凑合。

面试高频追问

  1. ZooKeeper 分布式锁和 Redis 分布式锁有什么区别?怎么选?

    核心区别在一致性模型。ZooKeeper 是 CP,写操作需要半数以上节点确认,所以锁的可靠性高,不会出现 "两个人同时拿到锁" 的情况。Redis 是 AP,主从异步复制,主节点宕机时可能锁信息还没同步到从节点,导致锁丢失——这就是 RedLock 试图解决的问题。

    不过 Redis 的性能确实比 ZooKeeper 高很多,大部分互联网场景用 Redis 锁就够了。金融交易这种对锁可靠性要求极高的场景才需要 ZooKeeper。

  2. ZooKeeper 的临时节点在什么情况下会被误删?

    网络抖动导致心跳超时,会话失效,临时节点被删除。但此时客户端其实还活着,只是跟 ZooKeeper 的网络不通了。这就是 "假死" 问题——客户端以为锁还在,实际上锁已经被 ZooKeeper 删了,其他客户端已经拿到了新锁。

    解决方案:给锁加一个版本号(利用 ZNode 的 version 字段),释放锁时检查版本号是否匹配,不匹配说明锁已经被别人拿了。

  3. Curator 的 InterProcessMutex 是怎么实现可重入的?

    内部维护了一个 ThreadData 对象,用 ConcurrentHashMap<Thread, LockData> 存储每个线程的锁计数。同一线程第一次加锁计数为 1,每重入一次 +1,每释放一次 -1,减到 0 才真正删除节点释放锁。

常见面试变体

  • "ZooKeeper 分布式锁和 Redis 分布式锁的区别?"
  • "ZooKeeper 分布式锁的羊群效应是什么?怎么解决?"
  • "如何保证分布式锁的可靠性?"
  • "Curator 的 InterProcessMutex 实现原理是什么?"

记忆口诀

三种方案演进:争一个节点(惊群)→ 排队等前一个(公平)→ 用框架(省心)。

方案二核心:创建顺序节点排队 → 序号最小获锁 → Watch 前一个 → 释放通知下一个。

为什么用临时节点:人走了自动释放,不死锁。

总结

ZooKeeper 分布式锁的核心是方案二(临时顺序节点 + Watch 前驱节点),实现了公平锁并解决了羊群效应。面试时重点讲清三点:为什么从方案一演进到方案二(解决惊群)、为什么用临时节点(异常自动释放,不死锁)、为什么只 Watch 前一个节点(避免羊群效应)。最后提一句 Curator 的 InterProcessMutex 可重入实现,加分项。