深入剖析 ZooKeeper 分布式 互斥锁:Curator 之 InterProcessMutex 源码全解(附图解)

🍃 深入剖析 ZooKeeper 分布式互斥锁:Curator 之 InterProcessMutex 源码全解(附图解)

大家好,我是一名在实习的后端实习生。今天想和大家聊聊分布式锁的工业级实现 ------ Apache Curator 的 InterProcessMutex。本文会从前置知识 讲起,一步步带你啃完获取锁 / 排队 / 释放锁的完整源码流程,并总结面试高频的设计精髓。内容偏入门向,保证让你看得懂、记得住、会复用!

TOC


一、为什么需要分布式锁?

在单体架构时代,多个线程操作共享资源时,我们习惯用 synchronizedReentrantLock 来保证线程安全:

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

📌 经验acquirerelease 必须成对出现,且 release 一定要放在 finally 里,防止业务异常导致锁泄漏。

跑起来之后,如果同时启动多个进程 / 线程竞争这把锁,你会发现它们严格按先来后到 执行------这就是公平锁!下面我们就来揭开它背后的奥秘。


四、整体设计思想:先看懂"银行叫号"模型

在啃源码前,先建立整体认知。InterProcessMutex 的原理可以类比银行柜台叫号

  1. 每位顾客到银行,先取一张号票(= 创建临时顺序节点,拿到全局递增的序号);
  2. 柜台只有一个(= maxLeases = 1,互斥);
  3. 大厅电子屏显示"当前办理 1 号";
  4. 你是 3 号,你只需盯着 2 号(= 监听前驱节点),2 号办完叫到你,你再上前;
  5. 中途你等得不耐烦走了(= 会话断开 / 超时),你的号作废,但排队顺序不乱。

对应到代码,整个获取锁的流程是:

复制代码
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;
}

这一步是整个公平锁的灵魂,务必反复体会:

  1. 拿到排好序的号票列表
  2. 找到自己的位置
  3. 不是第一名 → 只监听前一名 ,然后 wait() 睡大觉;
  4. 前一名释放锁(节点被删)→ 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 线程的锁。


七、使用注意事项 & 生产实践建议

  1. acquire / release 必须成对release 一定放 finally
  2. 释放锁的线程必须是持锁线程,跨线程释放会抛异常;
  3. 锁路径要有业务语义且全局唯一 ,如 /locks/{订单号},避免不同业务互相阻塞;
  4. 临界区要短,长时间持锁会拖慢整个队列,还容易触发会话超时;
  5. 合理配置重试策略ExponentialBackoffRetry(baseSleepTimeMs, maxRetries) 按需调节;
  6. 选型提示
    • 需要强公平、强一致,且已有 ZK 集群 → ZK + InterProcessMutex
    • 追求极致性能、可容忍轻微不一致 → Redis(Redisson)锁;
    • 中小项目不想引入新组件 → 数据库乐观锁 / 悲观锁先顶着。

八、总结与延伸

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 版 RedissonRLock 是如何实现锁续期(看门狗)的

写在最后

说实话,在动手写这篇文章之前,我对"分布式锁"的理解停留在"会用 synchronized 和 Redis 的 SETNX"层面。

真正翻开 InterProcessMutex 的源码,我才被 Curator 作者的设计功底震撼到:

  • 临时顺序节点把"公平性"交给 ZooKeeper 保证;
  • 只监听前驱把惊群从 O(n) 降到 O(1);
  • JVM 内映射表实现高性能可重入;
  • 会话过期、节点丢失、删除失败这些边界情况,都有配套的容错兜底。

读源码最大的收获,不是背下这几百行代码,而是学会"如何用最小的机制,优雅地解决最棘手的问题"。

如果这篇文章对你有帮助,欢迎点赞、收藏、评论交流~你的支持是我持续输出的最大动力!🎉


参考资料

本文基于个人学习笔记整理,源码注释与解读如有偏差,欢迎指正,感谢!

相关推荐
wifi___6 小时前
全局异常处理的原理
java·开发语言
To_OC9 小时前
从一行入口代码啃透 NestJS:工厂模式、装饰器和依赖注入是怎么串起来的
后端·设计模式·nestjs
Python私教10 小时前
多个项目怎么安全合并?先适配,再切换
后端·python·架构
2601_9638702010 小时前
【计算机毕业设计】基于Spring Boot的专科医院医疗管理系统
java·spring boot·课程设计
Bingo_BIG10 小时前
Java Spring 批量修改,实体、接口、方法的定义
java·spring
画中有画11 小时前
软件架构中质量属性(性能、安全、可扩展性)的权衡设计
java·运维·安全
Shaoxi Zhang11 小时前
JAVA学习笔记035——对象和JSON格式
java·笔记·学习
赵丙双11 小时前
CountDownLatch 源码分析
java·aqs·countdownlatch
Python私教11 小时前
从表格到管理系统:别先写页面,先补齐权限、流程和审计
数据库·后端·架构
余额瞒着我当琳11 小时前
C++--深拷贝三件套 + swap + 写时拷贝 + vector 扩容 + reserve
java·开发语言·c++