📌 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。
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上注册NodeChildrenChangedWatch; - 当锁释放(节点删除)时,所有等待的客户端同时被唤醒;
- 只有一个客户端创建成功,其余客户端再次失败并重新注册 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();
}
}
});
}
释放锁流程:
- 业务执行完毕,客户端主动删除自己的临时顺序节点;
- 客户端崩溃,会话超时后服务端自动删除节点;
- 节点删除触发 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 机制,分为四个步骤:
- 创建临时顺序节点 :每个客户端在
/locks下创建临时顺序节点(如lock-0000000001);- 判断最小序号:获取所有子节点并排序,若自己是最小编号则获取锁成功;
- 监听前一个节点 :若不是最小序号,找到前一个节点并注册
existsWatch,等待其删除;- 锁释放与通知 :持有锁的客户端删除节点(或会话超时自动删除),触发下一个客户端的 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 锁适合短事务,长事务应考虑拆分或改用其他方案。
觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯