🍃 深入剖析 ZooKeeper 分布式互斥锁:Curator 之 InterProcessMutex 源码全解(附图解)
大家好,我是一名在实习的后端实习生。今天想和大家聊聊分布式锁的工业级实现 ------ Apache Curator 的
InterProcessMutex。本文会从前置知识 讲起,一步步带你啃完获取锁 / 排队 / 释放锁的完整源码流程,并总结面试高频的设计精髓。内容偏入门向,保证让你看得懂、记得住、会复用!
TOC
一、为什么需要分布式锁?
在单体架构时代,多个线程操作共享资源时,我们习惯用 synchronized 或 ReentrantLock 来保证线程安全:
java
synchronized (this) {
// 临界区代码
}
但当我们把应用升级为分布式架构 (多实例部署、微服务拆分)后,synchronized 只能锁住当前 JVM 内的线程。设想一个电商的"扣库存"场景:
用户A -> 实例1 -> synchronized(库存对象) → 锁有效,但只对实例1有效
用户B -> 实例2 -> synchronized(库存对象) → 实例2 的锁跟实例1 的锁毫无关系!
两个实例同时读到库存 = 1,都执行了扣减,最终库存变成 0 甚至负数------这就是经典的超卖问题。
于是我们急需一把跨进程、跨机器 的锁:分布式锁。
主流分布式锁实现方案对比
| 实现方案 | 依赖组件 | 优点 | 缺点 |
|---|---|---|---|
| 基于数据库(唯一索引 / 悲观锁) | MySQL | 实现简单、无需额外组件 | 性能差、依赖数据库可用性 |
| 基于 Redis(SETNX + 过期时间) | Redis | 性能极高、实现简单 | 锁过期 / 主从切换易出问题,需处理续期 |
| 基于 ZooKeeper(临时顺序节点) | ZooKeeper | 强一致性、天然公平、无锁过期风险 | 性能中等、依赖 ZK 集群 |
本文要剖析的,正是 ZooKeeper 方案的工业级实现 ------ InterProcessMutex。
二、前置知识:10 分钟搞懂 ZooKeeper 关键概念
这几个概念是理解后面源码的地基,请一定耐心看完,磨刀不误砍柴工。
2.1 节点(ZNode)
ZooKeeper 以树形结构 存储数据,每个节点叫 ZNode:
/
├── locks/
│ ├── order-lock/
│ └── pay-lock/
└── config/
2.2 三种关键节点类型
| 节点类型 | 特点 | 何时消失 |
|---|---|---|
| 持久节点(PERSISTENT) | 手动删除才消失 | 手动删除 |
| 临时节点(EPHEMERAL) | 创建它的客户端会话断开后自动删除 | 会话结束 |
| 顺序节点(SEQUENTIAL) | 创建时自动在名字末尾追加全局自增序号 | --- |
重点来了:
- 临时节点:客户端挂了,节点自动删除 → 天然避免"锁永远无法释放"的死锁问题!
- 顺序节点:序号全局唯一且递增 → 天然具备"先来后到"的公平性!
而 EPHEMERAL_SEQUENTIAL(临时顺序节点) 把两者结合,正是 InterProcessMutex 的核心武器。
2.3 Watcher(监听机制)
客户端可以对某个节点注册 Watcher 。节点被创建 / 删除 / 修改时,ZooKeeper 服务端会主动通知客户端。
这避免了客户端"傻傻地轮询",是高效实现"等待唤醒"的关键机制。
2.4 Curator 是什么?
Curator 是 Apache 旗下的 ZooKeeper 客户端框架,提供了一堆开箱即用的高级功能(官方称为 Recipe):
- 分布式锁 :
InterProcessMutex(互斥锁)、InterProcessSemaphoreMutex(信号量)等 - Leader 选举(
LeaderSelector) - 分布式队列、分布式屏障
- 分布式计数(
SharedCount)
一句话:Curator 让我们不用自己操作繁琐的 ZooKeeper 原生 API,用优雅的 API 就能拿到分布式锁。
三、快速上手:5 分钟跑通 InterProcessMutex
3.1 引入依赖
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.5.0</version>
</dependency>
3.2 最小可运行示例
java
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import java.util.concurrent.TimeUnit;
public class DistributedLockDemo {
private static final String ZK_ADDR = "127.0.0.1:2181";
private static final String LOCK_PATH = "/locks/order";
public static void main(String[] args) throws Exception {
// 1. 创建并启动 Curator 客户端(配置重试策略)
CuratorFramework client = CuratorFrameworkFactory.newClient(
ZK_ADDR, new ExponentialBackoffRetry(1000, 3));
client.start();
// 2. 创建分布式锁
InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
try {
// 3. 限时 5 秒获取锁
if (lock.acquire(5, TimeUnit.SECONDS)) {
try {
// 4. 临界区:只有拿到锁的线程能进来
System.out.println("获取锁成功:" + Thread.currentThread().getName());
Thread.sleep(3000); // 模拟业务处理
} finally {
// 5. 一定记得释放锁!
lock.release();
}
} else {
System.out.println("5 秒内未获取到锁");
}
} finally {
client.close();
}
}
}
📌 经验 :
acquire与release必须成对出现,且release一定要放在finally里,防止业务异常导致锁泄漏。
跑起来之后,如果同时启动多个进程 / 线程竞争这把锁,你会发现它们严格按先来后到 执行------这就是公平锁!下面我们就来揭开它背后的奥秘。
四、整体设计思想:先看懂"银行叫号"模型
在啃源码前,先建立整体认知。InterProcessMutex 的原理可以类比银行柜台叫号:
- 每位顾客到银行,先取一张号票(= 创建临时顺序节点,拿到全局递增的序号);
- 柜台只有一个(=
maxLeases = 1,互斥); - 大厅电子屏显示"当前办理 1 号";
- 你是 3 号,你只需盯着 2 号(= 监听前驱节点),2 号办完叫到你,你再上前;
- 中途你等得不耐烦走了(= 会话断开 / 超时),你的号作废,但排队顺序不乱。
对应到代码,整个获取锁的流程是:
acquire()
│
├─ ① 检查本线程是否已持有锁?(threadData 映射)→ 是则重入计数 +1,直接返回
│
├─ ② attemptLock():在 ZK 上创建一个临时顺序节点(号票)
│
├─ ③ internalLockLoop():
│ ├─ 拿到当前所有"号票",按序号排序
│ ├─ 判断自己的序号是不是最小(ourIndex == 0)
│ │ ├─ 是 → 拿到锁 ✅
│ │ └─ 否 → 监听自己前一个节点,然后 wait()
│ │ (前驱释放时 Watcher 通知 → notifyAll() → 重新判断)
│ └─ 循环直到拿到锁 / 超时
│
└─ ④ 拿到锁 → 记录 threadData,支持重入
整体类图:
┌───────────────────────────────────────────────┐
│ InterProcessMutex (门面类) │
│ - 对外提供 acquire() / release() │
│ - 内部委托给 LockInternals │
│ - 用 threadData 记录 线程→锁信息,实现可重入 │
└───────────────────┬───────────────────────────┘
│ 委托
▼
┌───────────────────────────────────────────────┐
│ LockInternals (核心引擎) │
│ - 创建 / 删除临时顺序节点 │
│ - 排队等待、监听前驱、wait / notifyAll │
│ - 持有 driver(策略),驱动获取 / 判断逻辑 │
└───────────────────┬───────────────────────────┘
│ 策略
▼
┌───────────────────────────────────────────────┐
│ StandardLockInternalsDriver (策略类) │
│ - createsTheLock():创建临时顺序节点 │
│ - getsTheLock():判断是否轮到我 + 该监听谁 │
└───────────────────────────────────────────────┘
与 ReentrantLock(fair=true) 的对比
| 维度 | ReentrantLock(true) | InterProcessMutex |
|---|---|---|
| 作用范围 | 单个 JVM 内 | 跨 JVM / 多进程 |
| 公平性 | AQS 等待队列,先到先得 | ZK 顺序节点 + 只监听前驱 |
| 可重入 | 支持(AQS state 计数) | 支持(threadData 计数) |
| 释放方式 | unlock() |
删除临时顺序节点 |
| 底层机制 | AQS + CAS + LockSupport | ZooKeeper + Watcher + wait/notifyAll |
OK,现在可以正式开啃源码了。
五、源码深度剖析(核心章节)
说明:本文源码基于 Curator 2.12 系列经典实现(也是网上流传最广的版本),新版整体结构一致。为了不淹没主逻辑,部分与主流程无关的容错细节会标注为「📎 补充」,初学者可先跳过。
5.1 构造函数:如何初始化一把锁
java
// 最常用
public InterProcessMutex(CuratorFramework client, String path) {
// ZooKeeper 利用 path 创建临时顺序节点,这是实现公平锁的核心
this(client, path, new StandardLockInternalsDriver());
}
public InterProcessMutex(CuratorFramework client, String path, LockInternalsDriver driver) {
// maxLeases = 1:同一时刻只有 1 个线程能拿到锁(跨 JVM),即互斥锁
this(client, path, LOCK_NAME, 1, driver);
}
// protected 构造函数
InterProcessMutex(CuratorFramework client, String path, String lockName,
int maxLeases, LockInternalsDriver driver) {
basePath = PathUtils.validatePath(path);
// internals 类型为 LockInternals,
// InterProcessMutex 将分布式锁的申请和释放操作全部委托给 internals 执行
internals = new LockInternals(client, driver, path, lockName, maxLeases);
}
关键点:
path:锁在 ZK 中的根路径 ,如/locks/order,所有竞争这把锁的进程都在这条路径下创建子节点;LOCK_NAME:子节点名的前缀 ,默认是"lock-";maxLeases = 1:表示"同时最多 1 个租约",这就是互斥;如果改成 N,就变成了"最多 N 个线程同时持锁"的共享锁(读锁)。
💡 思考:
InterProcessReadWriteLock实现"读读共享、读写互斥",本质就是对同一 path 设置不同的命名规则与租约数。理解了 maxLeases,你就理解了整个锁家族的鼻祖。
5.2 获取锁(一):acquire 与可重入判断
java
// 无限等待
public void acquire() throws Exception {
if (!internalLock(-1, null)) {
throw new IOException("Lost connection while trying to acquire lock: " + basePath);
}
}
// 限时等待
public boolean acquire(long time, TimeUnit unit) throws Exception {
return internalLock(time, unit);
}
java
private boolean internalLock(long time, TimeUnit unit) throws Exception {
Thread currentThread = Thread.currentThread();
LockData lockData = threadData.get(currentThread);
if (lockData != null) {
// ① 同一线程再次 acquire:
// 线程的锁信息已存在 → 可重入,原子 +1 后直接返回
lockData.lockCount.incrementAndGet();
return true;
}
// ② 第一次获取:委托 LockInternals 真正去抢锁
String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
if (lockPath != null) {
// ③ 抢锁成功,记录 线程 → 锁信息
LockData newLockData = new LockData(currentThread, lockPath);
threadData.put(currentThread, newLockData);
return true;
}
return false;
}
其中映射表与锁信息:
java
// 记录线程与锁信息的映射关系
private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();
// 锁信息:一个临时顺序节点对应一张"号票",但要让锁生效,还需要排队等待激活(公平锁)
private static class LockData {
final Thread owningThread; // 持有锁的线程
final String lockPath; // 对应的 ZK 节点路径
final AtomicInteger lockCount = new AtomicInteger(1); // 重入次数
private LockData(Thread owningThread, String lockPath) {
this.owningThread = owningThread;
this.lockPath = lockPath;
}
}
可重入(Reentrant)的实现奥秘 :完全靠 JVM 内的一个 ConcurrentMap<Thread, LockData>。同一个线程重复 acquire,只是本地计数 +1,根本不会再访问 ZooKeeper,性能极好。
📎 补充:
getLockNodeBytes()返回的节点数据内容与锁的公平性 / 互斥性无关,主要用于记录持有者等元信息,默认可为空,理解主逻辑时可忽略。
5.3 获取锁(二):attemptLock ------ 创建"号票"
java
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception {
final long startMillis = System.currentTimeMillis();
// 无限等待时 millisToWait 为 null
final Long millisToWait = (unit != null) ? unit.toMillis(time) : null;
// 节点数据内容:可撤销锁场景用空字节,一般场景沿用传入值,与主逻辑无关
final byte[] localLockNodeBytes = (revocable.get() != null) ? new byte[0] : lockNodeBytes;
int retryCount = 0;
String ourPath = null; // 我们创建的临时顺序节点路径(号票)
boolean hasTheLock = false; // 是否已持有锁
boolean isDone = false;
while (!isDone) {
isDone = true;
try {
// ① 在 ZK 中创建临时顺序节点
ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
// ② 进入排队等待循环,激活锁(公平性核心,见 5.5)
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
} catch (KeeperException.NoNodeException e) {
// 📎 容错:会话过期等导致节点丢失,按重试策略决定是否重新获取
if (client.getZookeeperClient().getRetryPolicy().allowRetry(retryCount++,
System.currentTimeMillis() - startMillis, RetryLoop.getDefaultRetrySleeper())) {
isDone = false; // 满足重试策略,重新尝试
} else {
throw e; // 不满足则继续抛出
}
}
}
if (hasTheLock) {
return ourPath; // 拿到锁,把节点路径交给上层封装成 LockData
}
return null; // 没拿到
}
5.4 创建节点:createsTheLock
java
// From StandardLockInternalsDriver
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception {
String ourPath;
if (lockNodeBytes != null) {
ourPath = client.create()
.creatingParentContainersIfNeeded() // 父节点不存在则自动创建
.withProtection() // 子节点名加 GUID 前缀,防止重连误删他人节点
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL) // ⭐ 临时顺序节点!
.forPath(path, lockNodeBytes);
} else {
ourPath = client.create()
.creatingParentContainersIfNeeded()
.withProtection()
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath(path);
}
return ourPath;
}
📎 三个细节解读:
creatingParentContainersIfNeeded():父节点不存在时自动创建(老版本用 PERSISTENT,新版本优先用 CONTAINER);withProtection():给节点名加一个随机 GUID 前缀,作用是防止会话重连期间节点顺序混乱导致的误删他人节点问题;EPHEMERAL_SEQUENTIAL:临时 + 顺序,一颗子弹两个效果------客户端挂了自动删节点(不死锁),序号全局递增(保公平)。
💡 你可能会问:加了 GUID 前缀,排序还准吗?
答案是准的 。Curator 内部对子节点排序时,会先定位到
lockName前缀,提取末尾的数字序号进行数值比较,而不是直接对完整字符串做字典序比较。所以无论 GUID 是什么,都能保证按创建顺序排队。
5.5 获取锁(三):internalLockLoop ------ 排队 + 监听 + 等待(重头戏)
java
// 循环等待来激活分布式锁,实现锁的公平性
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception {
boolean haveTheLock = false;
boolean doDelete = false;
try {
while ((client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock) {
// ① 获取 basePath 下所有子节点,并按序号排序
List<String> children = getSortedChildren();
// ② 取出我们自己创建的节点名
String sequenceNodeName = ourPath.substring(basePath.length() + 1);
// ③ 判断能否拿锁 + 该监听谁(公平性核心)
PredicateResults predicateResults = driver.getsTheLock(client,
children, sequenceNodeName, maxLeases);
if (predicateResults.getsTheLock()) {
// ④ 轮到我拿锁了!
haveTheLock = true;
} else {
// ⑤ 还没轮到我,去监听"前一个节点"
String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
synchronized (this) {
try {
// 用 getData() 而非 exists() 注册监听(规避监听注册的竞态问题)
client.getData().usingWatcher(watcher).forPath(previousSequencePath);
if (millisToWait != null) {
// 限时等待:计算剩余等待时间,超时则放弃
millisToWait -= (System.currentTimeMillis() - startMillis);
startMillis = System.currentTimeMillis();
if (millisToWait <= 0) {
doDelete = true; // 超时,标记删除号票
break;
}
wait(millisToWait);
} else {
wait(); // 无限等待,直到被 Watcher 唤醒
}
} catch (KeeperException.NoNodeException e) {
// 📎 前驱节点可能在 getData 前刚被删(锁刚释放)。
// 外层 while 会重新走一遍 getsTheLock 判断,这里无需处理。
}
}
}
}
} catch (Exception e) {
ThreadUtils.checkInterrupted(e);
doDelete = true; // 异常兜底,删除号票
throw e;
} finally {
if (doDelete) {
deleteOurPath(ourPath); // 删除我们创建的临时顺序节点
}
}
return haveTheLock;
}
这一步是整个公平锁的灵魂,务必反复体会:
- 拿到排好序的号票列表;
- 找到自己的位置;
- 不是第一名 → 只监听前一名 ,然后
wait()睡大觉; - 前一名释放锁(节点被删)→ ZooKeeper 通过 Watcher 触发回调 →
notifyAll()叫醒 → 回到第 1 步重新判断。
5.6 拿锁判断:getsTheLock(惊群效应的克星)
java
// From StandardLockInternalsDriver
public PredicateResults getsTheLock(CuratorFramework client, List<String> children,
String sequenceNodeName, int maxLeases) throws Exception {
// ① 自己在排序列表中的位置
int ourIndex = children.indexOf(sequenceNodeName);
// ② 校验节点是否有效(会话过期被删时抛出 NoNodeException)
validateOurIndex(sequenceNodeName, ourIndex);
// ③ 核心判断:自己是前 maxLeases 名吗?
// maxLeases = 1 时,只有 ourIndex == 0 才能持锁
boolean getsTheLock = ourIndex < maxLeases;
// ④ 拿到锁就不需要监听任何人;否则监听 ourIndex - maxLeases 这个节点
// 即:只监听排在自己前面的那一位!(减少惊群的关键设计)
String pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases);
return new PredicateResults(pathToWatch, getsTheLock);
}
为什么只监听前一个节点,而不是监听根路径?
想象一个朴素的实现:所有等待线程都去监听根路径,一旦有人释放锁,notifyAll() 唤醒所有等待线程,大家一起重新去抢------这就是惊群效应(羊群效应)。
假设有 100 个线程排队,每次释放锁都会唤醒 99 个线程做无谓的竞争,这是灾难级的性能浪费。
InterProcessMutex 的做法极其巧妙:
- 线程只监听排在自己前面那个节点;
- 释放锁时,只有后一位被唤醒;
- 被唤醒者基本能直接拿到锁(公平,先到先得)。
于是释放一次锁,只唤醒 1 个线程。惊群复杂度从 O(n) 降到了 O(1),非常优雅。
5.7 监听器与唤醒机制
java
// From LockInternals
private final Watcher watcher = new Watcher() {
@Override
public void process(WatchedEvent event) {
notifyFromWatcher();
}
};
private synchronized void notifyFromWatcher() {
notifyAll(); // 唤醒所有等待该 LockInternals 实例的线程
}
📎
wait()/notifyAll()配合synchronized(this),是 Java 经典的生产者-消费者同步模式,Curator 把它用在了分布式场景的本地等待上。ZooKeeper 的 Watcher 在这里扮演"分布式信号"的角色:远端节点变化 → 本地线程被唤醒。
5.8 释放锁:release
搞懂了获取,释放就清晰了:
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; // 还有重入次数,先不释放(重入计数减到 0 才真正释放)
}
if (newLockCount < 0) {
// 理论不可达
throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath);
}
try {
internals.releaseLock(lockData.lockPath); // 真正释放:删除临时顺序节点
} finally {
threadData.remove(currentThread); // 移除线程的锁信息
}
}
java
// From LockInternals
void releaseLock(String lockPath) throws Exception {
revocable.set(null);
// 删除临时顺序节点:
// 只会触发"后一位"去拿锁,理论上不存在竞争,只排队、非抢占、先到先得(公平)
deleteOurPath(lockPath);
}
java
private void deleteOurPath(String ourPath) throws Exception {
try {
// guaranteed():后台不断重试直到删除成功,防止网络抖动导致删除失败
client.delete().guaranteed().forPath(ourPath);
} catch (KeeperException.NoNodeException e) {
// 可能已被删除(如会话过期导致),无需处理
}
}
释放过程一句话总结:把号票(临时顺序节点)撕掉,通知后一位顾客上前。
六、核心设计精粹(面试高频点)
| 特性 | 实现原理 |
|---|---|
| 互斥 | maxLeases = 1,同一时刻只有序号最小的节点能持锁 |
| 公平 | 临时顺序节点天然保证"先创建先拿锁";排队者只监听前驱 |
| 可重入 | JVM 内 threadData 映射 + AtomicInteger 计数 |
| 不死锁 | 临时节点随会话消失自动删除,guaranteed() 后台补删 |
| 防惊群 | 每个线程只监听自己的前驱节点,释放一次只唤醒一人 |
| 容错 | 节点丢失时按重试策略重建号票重新排队 |
6.1 公平锁是怎么做到的?
- 创建有序 :
EPHEMERAL_SEQUENTIAL由 ZooKeeper 服务端保证序号全局递增; - 领取有序 :只有
ourIndex == 0(序号最小)者持锁; - 唤醒有序:只监听前驱,前驱释放才轮到下一位。
三者环环相扣,构成了严格的"先来后到"公平队列。
6.2 如何避免死锁?
场景:线程拿到锁后进程宕机,锁永远不会释放怎么办?
- ZooKeeper 的临时节点:客户端会话断开,服务端自动删除节点 → 锁自动释放;
- 网络分区时,会话超时(sessionTimeout)后同样会触发清理。
这就是 ZK 分布式锁比 Redis 锁在"锁永久丢失"这一点上更让人安心的原因(前提是 ZK 集群本身可用)。
6.3 会话过期(脑裂)怎么办?
最坏情况:A 持有锁,但 A 与 ZK 的会话过期,节点被删;此刻 A 认为自己还持着锁继续执行,而 B 也拿到了锁 → 出现"两把锁"。
这是 ZK 分布式锁已知的局限(也是所有分布式锁的共性难题)。Curator 的做法是:
- 用
acquire()(无限等待)时若连接丢失,会直接抛异常,让业务感知; - 提供可撤销锁(
makeRevocable)等扩展机制辅助处理。
真实生产环境通常要求:拿到锁后的临界区尽量短 ,并配合业务侧的幂等设计,把极端脑裂的影响降到最低。
6.4 重入的坑:释放锁的线程必须是自己
release() 里先查 threadData.get(currentThread),如果不是本线程持有,直接抛 IllegalMonitorStateException。这跟 ReentrantLock 的行为一致,避免 A 线程误释放 B 线程的锁。
七、使用注意事项 & 生产实践建议
- acquire / release 必须成对 ,
release一定放finally; - 释放锁的线程必须是持锁线程,跨线程释放会抛异常;
- 锁路径要有业务语义且全局唯一 ,如
/locks/{订单号},避免不同业务互相阻塞; - 临界区要短,长时间持锁会拖慢整个队列,还容易触发会话超时;
- 合理配置重试策略 :
ExponentialBackoffRetry(baseSleepTimeMs, maxRetries)按需调节; - 选型提示 :
- 需要强公平、强一致,且已有 ZK 集群 → ZK +
InterProcessMutex; - 追求极致性能、可容忍轻微不一致 → Redis(Redisson)锁;
- 中小项目不想引入新组件 → 数据库乐观锁 / 悲观锁先顶着。
- 需要强公平、强一致,且已有 ZK 集群 → ZK +
八、总结与延伸
8.1 一句话记住 InterProcessMutex
利用 ZooKeeper 的临时顺序节点 + 最小序号优先 + 只监听前驱节点,实现跨 JVM 的公平可重入互斥锁。
8.2 全流程时序图
线程A ZooKeeper 线程B
│ │ │
│ create(EPHEMERAL_SEQ) │ │
├──────────────────────────>│ │
│ 返回 /locks/lock-0000000000 │
│ │ create(EPHEMERAL_SEQ) │
│ 序号最小 → 直接拿锁 ✅ │<─────────────────────────┤
│ │ 返回 /locks/lock-0000000001
│ │ │
│ 执行业务... │ 不是最小 → 监听前驱 │
│ │ getData(lock-0) │
│ │<─────────────────────────┤
│ release() 删除节点 │ │
├──────────────────────────>│ Watcher 通知 nodeDeleted │
│ ├─────────────────────────>│
│ │ notifyAll() 唤醒 │
│ │ lock-1 序号最小 → 拿锁 ✅
8.3 延伸阅读(建议下一篇学习路线)
InterProcessReadWriteLock:读写锁(读读共享、读写互斥)InterProcessSemaphoreMutex/InterProcessSemaphoreV2:信号量LeaderSelector:Leader 选举- 对比学习:Redis 版
Redisson的RLock是如何实现锁续期(看门狗)的
写在最后
说实话,在动手写这篇文章之前,我对"分布式锁"的理解停留在"会用 synchronized 和 Redis 的 SETNX"层面。
真正翻开 InterProcessMutex 的源码,我才被 Curator 作者的设计功底震撼到:
- 用临时顺序节点把"公平性"交给 ZooKeeper 保证;
- 用只监听前驱把惊群从 O(n) 降到 O(1);
- 用 JVM 内映射表实现高性能可重入;
- 连会话过期、节点丢失、删除失败这些边界情况,都有配套的容错兜底。
读源码最大的收获,不是背下这几百行代码,而是学会"如何用最小的机制,优雅地解决最棘手的问题"。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论交流~你的支持是我持续输出的最大动力!🎉
参考资料
- Apache Curator 官方文档:https://curator.apache.org/
- ZooKeeper 官方文档:https://zookeeper.apache.org/
- Curator
InterProcessMutex源码(curator-recipes 模块)
本文基于个人学习笔记整理,源码注释与解读如有偏差,欢迎指正,感谢!