本章定位:分布式锁是 ZooKeeper 最经典的应用场景。掌握 Ephemeral Sequential 节点的锁竞争、Watch 前驱和释放流程。
定义与作用
分布式锁是在分布式系统中协调多进程/多线程对共享资源的互斥访问机制。ZooKeeper 通过 Ephemeral Sequential 节点天然支持分布式锁。
核心需求:
| 需求 | ZooKeeper 如何满足 |
|---|---|
| 互斥性 | 同一时刻只有一个客户端持有锁(序号最小者) |
| 无死锁 | Ephemeral 节点自动释放,Session 超时后锁自动归还 |
| 可重入 | 通过客户端本地 ThreadLocal 计数实现(应用层) |
| 公平性 | Sequential 节点天然保证 FIFO 顺序 |
| 阻塞/非阻塞 | Watch 前驱节点实现阻塞等待 |
核心原理
公平锁竞争流程
三个客户端竞争:各创建自己的临时顺序节点 → 最小序号者获锁 → 其他 Watch 前驱 → 前驱释放后自动唤醒。避免了惊群效应。
非公平锁 vs 公平锁
| 特性 | 非公平锁 | 公平锁 |
|---|---|---|
| Watch 目标 | 锁根节点(所有竞争者 Watch 同一个节点) | 前驱节点(每个竞争者 Watch 自己的前驱) |
| 惊群效应 | 有(释放时所有等待者同时唤醒) | 无(只唤醒前驱的下一个) |
| 公平性 | 不保证先到先得 | 严格 FIFO |
| 实现复杂度 | 低 | 中 |
完整示例
示例一:基于 zkCli.sh 的分布式锁演示
场景说明:电商秒杀服务,两个实例竞争同一把锁。
# Instance A —— 竞争锁
zkCli.sh -server 127.0.0.1:2181
[zkshell: 0] create -e -s /seckill-lock/req- ""
Created /seckill-lock/req-0000000001
[zkshell: 1] ls /seckill-lock
[req-0000000001] # 只有我一个 → 获取锁
# Instance B —— 竞争锁
zkCli.sh -server 127.0.0.1:2181
[zkshell: 0] create -e -s /seckill-lock/req- ""
Created /seckill-lock/req-0000000002
[zkshell: 1] ls /seckill-lock
[req-0000000001, req-0000000002]
# 不是最小 → 等待 req-0000000001 释放
# Instance A 释放锁
[zkshell: 2] delete /seckill-lock/req-0000000001
# Instance B 立即检查
[zkshell: 2] ls /seckill-lock
[req-0000000002] # 现在我是最小 → 获取锁!
操作前后对比:
| 时刻 | Instance A | Instance B | 锁持有者 |
|---|---|---|---|
| T1 | req-001 | — | A |
| T2 | req-001 | req-002(等待) | A |
| T3 | 释放 | req-002 | B |
| T4 | — | 释放 | 无 |
示例二:Java 原生 API 公平锁实现
场景说明:完整的公平分布式锁 Java 实现。
public class ZkDistributedLock {
private final ZooKeeper zk;
private final String lockPath;
private final String lockPrefix = "/lock-";
private String currentLockPath;
private String waitLockPath;
public ZkDistributedLock(ZooKeeper zk, String lockPath) {
this.zk = zk;
this.lockPath = lockPath;
}
public void lock() throws Exception {
// 1. 创建临时顺序节点
currentLockPath = zk.create(
lockPath + lockPrefix, null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
// 2. 获取所有子节点并排序
List<String> children =
zk.getChildren(lockPath, false);
Collections.sort(children);
String currentNode =
currentLockPath.substring(lockPath.length() + 1);
if (children.get(0).equals(currentNode)) {
System.out.println("Acquired lock: " + currentLockPath);
return; // 序号最小,获取锁
}
// 3. 找到前驱节点并 Watch
int idx = children.indexOf(currentNode);
waitLockPath = lockPath + "/" + children.get(idx - 1);
CountDownLatch latch = new CountDownLatch(1);
Stat stat = zk.exists(waitLockPath, event -> {
if (event.getType() ==
Watcher.Event.EventType.NodeDeleted) {
latch.countDown();
}
});
if (stat == null) {
// 前驱节点已释放(并发场景)
return;
}
// 4. 阻塞等待
latch.await();
System.out.println("Acquired lock: " + currentLockPath);
}
public void unlock() throws Exception {
zk.delete(currentLockPath, -1);
System.out.println("Released lock: " + currentLockPath);
}
}
使用示例:
ZooKeeper zk = new ZooKeeper("127.0.0.1:2181", 3000, e -> {});
zk.create("/my-lock", null, ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT);
ZkDistributedLock lock = new ZkDistributedLock(zk, "/my-lock");
lock.lock();
try {
// 临界区代码
System.out.println("Processing critical section...");
} finally {
lock.unlock();
}
操作前后对比:
| 阶段 | /my-lock 下的节点 | 锁状态 |
|---|---|---|
| 无竞争者 | lock-0000000001 | Client 1 持有 |
| Client 2 竞争 | lock-0000000001, lock-0000000002 | Client 1 持有,Client 2 等待 |
| Client 1 释放 | lock-0000000002 | Client 2 持有 |
示例三:tryLock 非阻塞模式
场景说明:超时获取锁,避免线程无限等待。
public boolean tryLock(long timeout, TimeUnit unit)
throws Exception {
currentLockPath = zk.create(
lockPath + lockPrefix, null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> children =
zk.getChildren(lockPath, false);
Collections.sort(children);
String currentNode =
currentLockPath.substring(lockPath.length() + 1);
if (children.get(0).equals(currentNode)) {
return true;
}
int idx = children.indexOf(currentNode);
waitLockPath = lockPath + "/" + children.get(idx - 1);
CountDownLatch latch = new CountDownLatch(1);
Stat stat = zk.exists(waitLockPath, event ->
latch.countDown());
if (stat == null) return true;
// 有限等待
return latch.await(timeout, unit);
}
操作前后对比:
| 场景 | tryLock 结果 | 说明 |
|---|---|---|
| 无竞争者 | 立即返回 true | 直接获锁 |
| 有竞争者,5s 内释放 | 等待后返回 true | 阻塞获锁 |
| 有竞争者,5s 未释放 | 返回 false | 超时放弃 |
易错场景与面试考点
易错场景
1. 未处理 Session 过期导致锁丢失
客户端持有锁期间 Session 过期 → Ephemeral 节点被服务端删除 → 锁"静默"释放。应用层需感知 Expired 事件并停止临界区操作。
// Watcher 中检测
if (event.getState() ==
Watcher.Event.KeeperState.Expired) {
// 锁已丢失,停止临界区操作
}
2. 未创建锁根节点直接使用
create -e -s /lock/req- ""
# KeeperErrorCode = NoNode for /lock
必须先创建持久根节点:
create /lock ""
3. 释放不属于自己的锁
Ephemeral Sequential 节点中,只有序号最小的持有锁。不能跨节点删除——删除不属于自己的节点会导致排队的客户端误判。
4. 锁超时与业务超时不一致
ZooKeeper 锁的释放依赖 Session 超时(通常 10s~30s),如果业务操作耗时超过 Session Timeout,锁会被服务端自动释放。设置合理的 Session Timeout 至关重要。
面试高频题
Q:ZooKeeper 分布式锁相比 Redis 分布式锁的优势?
A:
- ZooKeeper:通过 Ephemeral 节点保证死锁安全,通过 Watch 机制实现公平排队。成本是部署 ZooKeeper 集群。
- Redis:通过 SET NX + 过期时间实现,性能更高但公平性弱。RedLock 算法可提升可靠性但实现复杂。
- 选型:ZooKeeper 适合要求强一致性和公平性的场景;Redis 适合高吞吐、对可靠性容忍度更高的场景。
Q:如何避免惊群效应?
A:使用公平锁策略(Watch 前驱节点而非根节点)。当锁释放时,只有前驱节点的下一个竞争者被唤醒,而非所有等待者同时竞争。
Q:Curator 的 InterProcessMutex 与原生实现的区别?
A:Curator 封装了连接管理、重试、锁续期等细节。核心机制仍是 Ephemeral Sequential 节点 + Watch 前驱。优势在于生产级健壮性(自动处理连接断开、Session 过期等边界情况)。
小结
| 要点 | 说明 |
|---|---|
| 核心机制 | Ephemeral Sequential + Watch 前驱 |
| 死锁安全 | Ephemeral 自动释放 |
| 公平锁 | 按序号排队,Watch 前驱节点 |
| 非公平锁 | Watch 根节点,存在惊群效应 |
| 可重入 | 应用层 ThreadLocal 实现 |
| 超时控制 | tryLock + CountDownLatch.await(timeout) |
分布式锁解决了多实例互斥问题。下一章进入配置中心,理解 ZooKeeper 如何成为分布式系统的配置中枢。