欢迎光临
我们一直在努力

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

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

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

📚 回答:

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

1. 分布式锁的本质要求

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

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

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

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

// 所有客户端竞争创建同名节点
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]。

获取锁的完整流程:

// 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();
}
}
});
}

释放锁流程:

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

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

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

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

    3.1 Curator 四种锁类型
    锁类型类名特性底层节点类型
    可重入排他锁 InterProcessMutex 同一线程可多次获取,释放时递减计数 临时顺序节点
    不可重入排他锁 InterProcessSemaphoreMutex 同一线程不可重复获取 临时顺序节点
    分布式读写锁 InterProcessReadWriteLock 读读共享、读写互斥、写写互斥 临时顺序节点
    多锁容器 InterProcessMultiLock 将多个锁作为原子整体获取/释放 临时顺序节点
    3.2 InterProcessMutex 获取锁源码解析

    // 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 核心逻辑

    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 可重入实现原理

    // 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 释放锁源码解析

    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 + CuratorRedis + 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 机制,分为四个步骤:

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

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

    高分回答:

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

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

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

    高分回答:

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

  • 每个线程维护一个 LockData 对象,包含 owningThread(持有线程)、lockPath(锁节点路径)和 lockCount(加锁次数,AtomicInteger);
  • 同一线程再次调用 acquire() 时,从 threadData(ConcurrentMap<Thread, LockData>)中查到已有 LockData,lockCount 自增后直接返回,不创建新 ZK 节点;
  • release() 时递减 lockCount,只有当次数降为 0 时才真正删除 ZK 节点;
  • 同时通过 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(隔离令牌):

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

    高分回答:

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

  • Leader 在多数派(quorum)一侧:少数派一侧的客户端无法与 Leader 通信,写操作(包括创建临时顺序节点)会被阻塞或失败。这些客户端无法获取锁,但已获取锁的客户端(在多数派一侧)不受影响;
  • Leader 在少数派一侧:Leader 无法获得多数派确认,会主动降级为 Follower,多数派一侧重新选举新 Leader。少数派一侧的客户端会话超时,临时节点被删除,锁自动释放;
  • 客户端在分区恢复后重连:如果会话未过期,客户端可以继续持有锁;如果会话已过期,需要重新竞争锁。 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 分布式锁必须抓住三个关键点:

  • 演进动机:从普通临时节点到临时顺序节点的演进,不是为了"有编号",而是为了避免羊群效应和实现公平锁;
  • 可重入边界:Curator 通过 ThreadLocal 实现的可重入仅限同一 JVM 内同一线程,跨 JVM 不可重入;
  • 选型权衡:ZooKeeper 是 CP 系统,强一致性但性能受限;Redis 是 AP 系统,高性能但存在主从延迟风险。正确性优先选 ZK,性能优先选 Redis。
  • 生产环境中,绝不手写 ZK 分布式锁,应使用 Curator 的 InterProcessMutex。同时要注意 GC 停顿导致的锁误释放 问题,高正确性场景必须引入 Fencing Token 作为兜底。最后,ZK 锁适合短事务,长事务应考虑拆分或改用其他方案。


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

    赞(0)
    未经允许不得转载:171主机测评 » 【大白话说Java面试题 第205题】【09_Zookeeper篇】第6题:ZooKeeper 实现分布式锁的原理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址