【大白话说Java面试题 第205题】【09_Zookeeper篇】第6题:ZooKeeper 实现分布式锁的原理

📌 PDF :大白话说Java面试题 --- 09_Zookeeper篇

第6题:ZooKeeper 实现分布式锁的原理

📚 回答:

  • 核心考点 : ZooKeeper 分布式锁是分布式协调的经典场景,大厂面试不会只问"创建临时顺序节点、判断最小序号",而是深入考察 从临时节点到临时顺序节点的演进动机 (羊群效应问题)、锁获取与释放的完整状态机可重入锁的 ThreadLocal 实现Curator 框架的源码级封装LockInternalsattemptLock 流程),以及 ZooKeeper 锁与 Redis/Redisson 锁的选型权衡(CP vs AP、性能 vs 可靠性)。面试官真正想判断的是:你是否理解分布式锁的本质是"分布式环境下的互斥原语",以及能否根据业务场景做出正确的技术选型。

1. 分布式锁的本质要求

分布式锁必须满足四个核心条件:

条件 说明 ZooKeeper 实现方式
互斥性 同一时刻只有一个客户端持有锁 同级节点唯一性 / 最小序号判定
防死锁 客户端崩溃后锁能自动释放 临时节点会话绑定,超时自动删除
可重入性 同一线程可多次获取锁 ThreadLocal 记录加锁次数
公平性 等待锁的客户端按顺序获取 临时顺序节点天然 FIFO

2. 从临时节点到临时顺序节点:演进与优化
2.1 方案一:基于普通临时节点的简单分布式锁(存在羊群效应)

实现思路 :所有客户端在 /exclusive_lock 下创建同名临时节点 /exclusive_lock/lock,利用 ZooKeeper 的"同级节点唯一性",只有第一个创建成功的客户端获得锁 citation:0

java 复制代码
// 所有客户端竞争创建同名节点
try {
    zk.create("/exclusive_lock/lock", data, acl, CreateMode.EPHEMERAL);
    // 创建成功,获取锁
} catch (KeeperException.NodeExistsException e) {
    // 节点已存在,获取锁失败,注册 Watch 等待
    zk.exists("/exclusive_lock/lock", watcher);
}

致命缺陷------羊群效应(Herd Effect)

  • 未获取锁的客户端都在 /exclusive_lock 上注册 NodeChildrenChanged Watch;
  • 当锁释放(节点删除)时,所有等待的客户端同时被唤醒
  • 只有一个客户端创建成功,其余客户端再次失败并重新注册 Watch;
  • 大量无效的唤醒和竞争导致 ZooKeeper 服务端压力激增,性能急剧下降 citation:0
2.2 方案二:基于临时顺序节点的优化分布式锁(生产级)

核心思想:将"所有客户端竞争一个节点"优化为"每个客户端只监听前一个节点",彻底避免羊群效应 citation:0

获取锁的完整流程

java 复制代码
// Step 1: 在 /locks 下创建临时顺序节点
String myNode = zk.create("/locks/lock-", data, acl, CreateMode.EPHEMERAL_SEQUENTIAL);
// 返回: /locks/lock-0000000003

// Step 2: 获取 /locks 下所有子节点并排序
List<String> children = zk.getChildren("/locks", false);
Collections.sort(children); // [lock-0000000001, lock-0000000002, lock-0000000003]

// Step 3: 判断自己是否为最小序号
String myNodeName = myNode.substring(myNode.lastIndexOf('/') + 1);
if (myNodeName.equals(children.get(0))) {
    // 序号最小,获取锁成功
    System.out.println("Lock acquired!");
} else {
    // 获取锁失败,找到前一个节点并注册 Watch
    int myIndex = children.indexOf(myNodeName);
    String prevNode = children.get(myIndex - 1); // lock-0000000002

    // 关键优化:只监听前一个节点的删除事件,而非父节点
    zk.exists("/locks/" + prevNode, new Watcher() {
        @Override
        public void process(WatchedEvent event) {
            if (event.getType() == EventType.NodeDeleted) {
                // 前一个节点删除,重新尝试获取锁
                tryAcquireLock();
            }
        }
    });
}

释放锁流程

  1. 业务执行完毕,客户端主动删除自己的临时顺序节点;
  2. 客户端崩溃,会话超时后服务端自动删除节点;
  3. 节点删除触发 Watch,下一个等待的客户端(监听该节点的客户端)被唤醒并重新判断序号。

两种方案对比

维度 普通临时节点锁 临时顺序节点锁
羊群效应 ❌ 严重 ✅ 完全避免
公平性 ❌ 无序竞争 ✅ 天然公平(FIFO)
死锁风险 ⚠️ 客户端崩溃需等超时 ✅ 会话超时自动释放
性能 低(大量并发唤醒) 高(仅唤醒下一个节点)
可重入 ❌ 不支持 ✅ 支持
实现复杂度

3. Curator 框架的分布式锁实现

生产环境中绝不手写 ZooKeeper 分布式锁,应使用 Curator 框架。Curator 封装了四种锁实现,其中 InterProcessMutex 是最常用的可重入排他锁 citation:0

3.1 Curator 四种锁类型
锁类型 类名 特性 底层节点类型
可重入排他锁 InterProcessMutex 同一线程可多次获取,释放时递减计数 临时顺序节点
不可重入排他锁 InterProcessSemaphoreMutex 同一线程不可重复获取 临时顺序节点
分布式读写锁 InterProcessReadWriteLock 读读共享、读写互斥、写写互斥 临时顺序节点
多锁容器 InterProcessMultiLock 将多个锁作为原子整体获取/释放 临时顺序节点
3.2 InterProcessMutex 获取锁源码解析
java 复制代码
// 1. 调用 acquire() 入口
public void acquire() throws Exception {
    if (!internalLock(-1, null)) {
        throw new IOException("Lost connection while trying to acquire lock: " + basePath);
    }
}

// 2. internalLock 方法:检查可重入
private boolean internalLock(long time, TimeUnit unit) throws Exception {
    Thread currentThread = Thread.currentThread();
    LockData lockData = threadData.get(currentThread); // ThreadLocal 检查

    if (lockData != null) {
        // 已持有锁,加锁次数 +1,实现可重入
        lockData.lockCount.incrementAndGet();
        return true;
    }

    // 第一次获取锁,调用 LockInternals.attemptLock
    String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
    if (lockPath != null) {
        LockData newLockData = new LockData(currentThread, lockPath);
        threadData.put(currentThread, newLockData); // 记录到 ThreadLocal
        return true;
    }
    return false;
}
3.3 LockInternals.attemptLock 核心逻辑
java 复制代码
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception {
    final long startMillis = System.currentTimeMillis();
    final Long millisToWait = (unit != null) ? unit.toMillis(time) : null;

    while (!isDone) {
        isDone = true;
        try {
            // 创建临时顺序节点
            ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
            // 内部循环:判断是否为最小节点,不是则监听前一个节点
            hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
        } catch (KeeperException.NoNodeException e) {
            // 网络中断或 session 过期,根据重试策略决定是否重试
            if (client.getZookeeperClient().shouldRetry(e)) {
                isDone = false;
            } else {
                throw e;
            }
        }
    }
    return hasTheLock ? ourPath : null;
}

// 创建临时顺序节点(withProtection 防止重复创建)
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception {
    return client.create()
        .creatingParentContainersIfNeeded()
        .withProtection()  // 防止网络重连导致重复创建
        .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
        .forPath(path, lockNodeBytes);
}
3.4 可重入实现原理
java 复制代码
// LockData 结构:记录线程、锁路径、加锁次数
private static class LockData {
    final Thread owningThread;      // 持有锁的线程
    final String lockPath;          // 锁对应的 ZK 节点路径
    final AtomicInteger lockCount = new AtomicInteger(1); // 加锁次数
}

// 存储在 ConcurrentMap<Thread, LockData> threadData 中
private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();

可重入边界 :Curator 的可重入仅限 同一 JVM 内的同一线程。跨 JVM 或跨线程的业务级可重入需要在应用层设计 citation:0

3.5 释放锁源码解析
java 复制代码
public void release() throws Exception {
    Thread currentThread = Thread.currentThread();
    LockData lockData = threadData.get(currentThread);

    if (lockData == null) {
        throw new IllegalMonitorStateException("You do not own the lock: " + basePath);
    }

    int newLockCount = lockData.lockCount.decrementAndGet();
    if (newLockCount > 0) {
        return; // 加锁次数未归零,不释放锁
    }

    if (newLockCount < 0) {
        throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath);
    }

    try {
        // 加锁次数归零,删除 ZK 节点释放锁
        internals.releaseLock(lockData.lockPath);
    } finally {
        threadData.remove(currentThread); // 从 ThreadLocal 移除
    }
}

关键设计 :释放锁时校验 Thread.currentThread(),防止其他线程误释放本线程持有的锁。


4. 锁获取与释放的状态机
复制代码
[客户端启动]
    │
    ▼
创建临时顺序节点(/locks/lock-000000000N)
    │
    ▼
获取所有子节点并排序
    │
    ├─→ 自己是最小序号?
    │     ├─→ 是 → 获取锁成功 → 执行业务逻辑
    │     │              │
    │     │              ▼
    │     │         释放锁(删除节点)
    │     │              │
    │     │              ▼
    │     │         触发下一个客户端的 Watch
    │     │
    │     └─→ 否 → 找到前一个节点
    │              │
    │              ▼
    │         注册 exists(prevNode) Watch
    │              │
    │              ▼
    │         等待 Watch 通知(阻塞/非阻塞)
    │              │
    │              ▼
    │         前一个节点删除 → 重新判断序号
    │              │
    │              └─→ 循环直到获取锁
    │
    └─→ 会话超时 → 临时节点自动删除 → 锁释放

5. ZooKeeper 分布式锁 vs Redis 分布式锁选型对比
对比维度 ZooKeeper + Curator Redis + Redisson
一致性模型 CP(强一致性) AP(最终一致性)
协议基础 ZAB 协议 单线程 + 主从复制
性能(TPS) 几千~几万 十万级
平均延迟 1~10ms 亚毫秒级
死锁防护 会话超时自动释放 依赖过期时间 + 看门狗续期
公平锁 ✅ 天然支持 ⚠️ 需额外配置
可重入 ✅ 原生支持 ✅ 原生支持
羊群效应 ✅ 完全避免 N/A(无此问题)
主从切换风险 无(ZAB 保证) ⚠️ 主从异步复制可能丢锁
运维复杂度 中(需维护 ZK 集群) 低(已有 Redis 基础设施)
典型场景 金融交易、分布式事务、Leader 选举 秒杀、库存扣减、高并发缓存

压测数据参考(100 并发线程)citation:2

指标 ZooKeeper(3节点) Redis(哨兵模式)
平均响应时间 35.2ms 8.7ms
吞吐量(TPS) 2,840 11,500
P99 延迟 210ms 55ms
CPU 占用 68% 42%

6. 生产环境避坑指南
6.1 严禁使用普通临时节点实现分布式锁

普通临时节点锁会导致严重的羊群效应,高并发下 ZooKeeper 服务端压力激增,甚至引发服务不可用。生产环境必须使用临时顺序节点方案。

6.2 会话超时配置要合理
  • sessionTimeoutMs 过短:网络抖动导致会话过期、锁误释放;
  • sessionTimeoutMs 过长:客户端崩溃后,临时节点长时间不删除,其他客户端长时间等待。
  • 推荐:设置为心跳间隔的 2~3 倍,通常 10~30 秒。
6.3 警惕 GC 停顿导致的锁误释放

客户端因 Full GC 停顿长时间无法发送心跳,会话超时后临时节点被删除,但客户端可能仍在执行业务逻辑。高正确性场景需结合 Fencing Token (如 MySQL 的 auto_increment 或 Redis 的 INCR)防止旧客户端恢复后写入脏数据。

6.4 避免锁持有时间过长

ZooKeeper 分布式锁适合短事务(毫秒级~秒级)。如果业务逻辑执行时间过长(如分钟级),会导致:

  • 其他客户端长时间等待,队列堆积;
  • 会话超时风险增加;
  • 锁粒度不合理,应考虑将大事务拆分为小事务。
6.5 高并发锁竞争场景考虑 Redis 替代

ZooKeeper 写操作需半数以上节点确认(Zab 协议),TPS 通常在几千级别。超高并发锁竞争(如秒杀、大促)应考虑 Redis + Redisson,通过看门狗自动续期保证锁的可靠性 citation:0

6.6 绝不手写锁逻辑,使用 Curator

手写分布式锁容易遗漏:

  • 网络重连导致的重复节点创建(Curator 的 withProtection 解决);
  • 会话过期后的状态恢复;
  • 可重入的线程安全;
  • 异常场景下的锁释放(finally 中释放)。

7. 面试官追问与高分回答模板
追问 1:"ZooKeeper 如何实现分布式锁?"

低分回答:"通过创建临时顺序节点,判断自己是不是最小序号,是就获取锁,不是就监听前一个节点。"(没有解释演进动机和核心优势)

高分回答

"ZooKeeper 分布式锁的核心实现基于 临时顺序节点 + Watch 机制,分为四个步骤:

  1. 创建临时顺序节点 :每个客户端在 /locks 下创建临时顺序节点(如 lock-0000000001);
  2. 判断最小序号:获取所有子节点并排序,若自己是最小编号则获取锁成功;
  3. 监听前一个节点 :若不是最小序号,找到前一个节点并注册 exists Watch,等待其删除;
  4. 锁释放与通知 :持有锁的客户端删除节点(或会话超时自动删除),触发下一个客户端的 Watch,该客户端重新判断序号。
    关键优化是 '只监听前一个节点' 而非'监听父节点',这彻底避免了羊群效应。同时,临时节点的会话绑定特性保证了客户端崩溃后锁自动释放,避免死锁。"
追问 2:"为什么用临时顺序节点,而不用普通临时节点?"

低分回答:"因为临时顺序节点有编号,可以判断顺序。"(没有触及羊群效应)

高分回答

"使用临时顺序节点而非普通临时节点,核心是为了解决 羊群效应 和实现 公平锁

  • 普通临时节点锁 :所有客户端竞争创建同名节点,未获取锁的客户端都在父节点注册 Watch。锁释放时,所有等待客户端同时被唤醒,只有一个成功,其余再次失败并重新注册,造成大量无效的网络开销和服务器压力。
  • 临时顺序节点锁 :每个客户端创建唯一序号的节点,只监听 前一个节点 的删除事件。锁释放时,仅唤醒下一个客户端 ,避免了羊群效应。同时,序号最小的节点获得锁,保证了获取锁的顺序与创建顺序一致,天然实现 公平锁
    此外,临时特性保证了客户端崩溃后节点自动删除,避免死锁。"
追问 3:"Curator 的 InterProcessMutex 是如何实现可重入的?"

低分回答:"通过 ThreadLocal 记录加锁次数。"(太浅,没有解释边界)

高分回答

"Curator 的 InterProcessMutex 通过 ThreadLocal + AtomicInteger 实现可重入:

  1. 每个线程维护一个 LockData 对象,包含 owningThread(持有线程)、lockPath(锁节点路径)和 lockCount(加锁次数,AtomicInteger);
  2. 同一线程再次调用 acquire() 时,从 threadDataConcurrentMap<Thread, LockData>)中查到已有 LockDatalockCount 自增后直接返回,不创建新 ZK 节点;
  3. release() 时递减 lockCount,只有当次数降为 0 时才真正删除 ZK 节点;
  4. 同时通过 Thread.currentThread() 校验,防止其他线程释放本线程持有的锁。
    重要边界 :Curator 的可重入仅限 同一 JVM 内的同一线程。跨 JVM 或跨线程的业务级可重入需要在应用层自行设计。"
追问 4:"ZooKeeper 分布式锁和 Redis 分布式锁怎么选?"

高分回答

"选型取决于业务对 一致性性能 的优先级:

  • ZooKeeper + Curator:基于 ZAB 协议保证强一致性(CP),临时节点 + 会话超时机制天然避免死锁,适合对正确性要求极高的场景(如金融交易、分布式事务、Leader 选举)。缺点是写入性能受限于 Zab 协议,TPS 通常在几千级别,不适合超高并发。
  • Redis + Redisson :基于内存操作,性能极高(十万级 TPS),适合高并发场景(如秒杀、库存扣减)。但存在主从延迟、时钟漂移等问题,极端情况下可能丢失锁。Redisson 的看门狗机制通过自动续期缓解了部分问题。
    决策原则:正确性优先选 ZooKeeper,性能优先选 Redis。现代云原生场景也可考虑 etcd(基于 Raft,提供 Lease 和 Fencing Token 原生支持)。"
追问 5:"分布式锁中如何防止客户端崩溃导致的脑裂问题?"

高分回答

"客户端崩溃后,ZooKeeper 通过 临时节点的会话超时机制 自动释放锁,避免了传统锁的'持有者崩溃导致死锁'问题。但存在一个边界情况:GC 停顿或网络分区 导致客户端长时间无法发送心跳,会话超时后临时节点被删除,但客户端可能仍在执行业务逻辑。

解决这个问题的方案是引入 Fencing Token(隔离令牌)

  1. 获取锁时,ZooKeeper 返回节点的序号(如 0000000003)作为 Fencing Token;
  2. 客户端执行业务操作时,将 Fencing Token 写入共享资源(如数据库、Redis);
  3. 共享资源服务端校验 Fencing Token,若收到旧 Token(来自已释放锁的客户端)则拒绝操作。
    这样即使旧客户端恢复后继续执行,其操作也会被拒绝,保证数据一致性。"
追问 6:"如果 ZooKeeper 集群发生网络分区,分布式锁还能正常工作吗?"

高分回答

"ZooKeeper 基于 ZAB 协议,在网络分区场景下的行为取决于分区范围:

  1. Leader 在多数派(quorum)一侧:少数派一侧的客户端无法与 Leader 通信,写操作(包括创建临时顺序节点)会被阻塞或失败。这些客户端无法获取锁,但已获取锁的客户端(在多数派一侧)不受影响;
  2. Leader 在少数派一侧:Leader 无法获得多数派确认,会主动降级为 Follower,多数派一侧重新选举新 Leader。少数派一侧的客户端会话超时,临时节点被删除,锁自动释放;
  3. 客户端在分区恢复后重连 :如果会话未过期,客户端可以继续持有锁;如果会话已过期,需要重新竞争锁。
    ZooKeeper 的 quorum 机制(半数以上节点可用)确保了在网络分区时,最多只有一个分区能继续提供服务,从而避免了脑裂导致的双主问题。这是 ZK 分布式锁相比 Redis 的核心优势之一。"

8. 方案选型速查表
业务场景 推荐方案 核心理由
金融交易、支付对账 ZooKeeper + Curator 强一致性,零锁丢失容忍
库存扣减、秒杀系统 Redis + Redisson 高并发,看门狗自动续期
Leader 选举 ZooKeeper + Curator 临时顺序节点天然支持
分布式事务协调 ZooKeeper + Curator 强一致性,支持 Fencing Token
缓存热点数据更新 Redis + Redisson 高性能,低延迟
批量数据处理(长任务) ZooKeeper + Curator 无过期时间风险,会话绑定
跨数据中心部署 etcd / Redis RedLock ZK 跨数据中心延迟高
已有 ZK 基础设施(如 Kafka) ZooKeeper + Curator 复用现有集群,降低运维成本

💡 面试官想要的满分总结

ZooKeeper 分布式锁的实现精髓在于 临时顺序节点 + 监听前一个节点 的设计。它通过将"所有客户端竞争一个节点"优化为"每个客户端只监听前一个节点",优雅地解决了羊群效应问题,同时利用临时节点的会话绑定特性天然避免了死锁。

理解 ZK 分布式锁必须抓住三个关键点:

  1. 演进动机 :从普通临时节点到临时顺序节点的演进,不是为了"有编号",而是为了避免羊群效应 和实现公平锁
  2. 可重入边界:Curator 通过 ThreadLocal 实现的可重入仅限同一 JVM 内同一线程,跨 JVM 不可重入;
  3. 选型权衡:ZooKeeper 是 CP 系统,强一致性但性能受限;Redis 是 AP 系统,高性能但存在主从延迟风险。正确性优先选 ZK,性能优先选 Redis。

生产环境中,绝不手写 ZK 分布式锁 ,应使用 Curator 的 InterProcessMutex。同时要注意 GC 停顿导致的锁误释放 问题,高正确性场景必须引入 Fencing Token 作为兜底。最后,ZK 锁适合短事务,长事务应考虑拆分或改用其他方案。


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯

相关推荐
KobeSacre1 天前
红黑树规则
java
caishenzhibiao1 天前
无极多空共振 同花顺期货通指标
java·c语言·c#
jaysee-sjc1 天前
【JavaWeb】JDBC与Mybatis零基础入门详解
java·开发语言·前端·mybatis
森叶1 天前
用 DataTransfer 模拟“用户选择本地文件“:不用系统对话框,也能给 `<input type=“file“>` 塞进真实文件
java·开发语言·log4j
January丶1 天前
docker-compose安装ZooKeeper集群
zookeeper
January丶1 天前
CyclicBarrier、CountDownLatch、Semaphore对比
java
我命由我123451 天前
Jetpack Room - TypeConverter
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
2601_963870181 天前
【计算机毕业设计】基于Spring Boot的在线教学平台的设计与实现
java·spring boot·后端
long3161 天前
LLD与HLD
java·数据结构·spring boot
小子宝丁1 天前
【VS Code】 Spring Boot `main` 方法不显示 Run | Debug 经验总结
java·spring boot·后端