先给个结论:在真正需要强一致性的分布式场景里,Apache Curator 的 InterProcessMutex 比很多人手搓的 Redis 锁靠谱得多;而 InterProcessSemaphoreMutex 虽然名字里带 Semaphore,却是一把不可重入的互斥锁,用错了会把自己卡死。这篇文章把这两把锁的底层原理、行为差异、选型逻辑和面试高频考点一次性讲透,适合正在设计分布式任务、秒杀扣减或者准备分布式锁面试题的同学。
1. 为什么先别急着用Redis锁:ZooKeeper模型的底层优势
1.1 Redis分布式锁的硬伤,不只是误删
很多人一提到分布式锁,第一反应就是SET NX EX,然后加一个 Lua 脚本释放。这套方案能应付大部分业务,但它的硬伤也摆在明面上。
第一是误删。如果释放锁的时候没有校验 value 是不是自己的,线程 A 的锁过期了,线程 B 拿到锁,A 执行完一DEL,把 B 的锁删了。解决方式是 value 存请求唯一 ID,释放用 Lua 比对后再删,这已经是基本操作了。
第二是锁过期但业务没跑完。业务执行时间超过锁的 TTL,Redis 锁自动消失,另一个线程进来重复执行。常见解法是看门狗续期,但续期代码写起来也不省心。
第三是主从切换丢锁。Redis 主节点写入了锁,但还没同步到从节点,主节点挂了,从节点顶上,锁就丢了。Redlock 想解决这个问题,又因为时钟跳跃、GC 停顿被很多工程团队质疑。
不是说 Redis 锁不能用,而是它属于"性能优先、容忍极小概率错误"的方案。一旦你的业务对"同一时刻只能有一个执行者"要求很严格,ZooKeeper 模型会更安心。
1.2 ZK锁原生避免"误删"与"丢失"的机制
ZooKeeper 实现分布式锁的核心是三样东西:临时节点、顺序节点、Watch 监听。
临时节点表示这个节点和客户端会话绑定,会话结束节点自动删除。这直接解决了"客户端崩溃导致锁永远持有"的问题——不需要算 TTL,ZK 自己会清理。
顺序节点表示子节点会按创建顺序附加一个单调递增的序号。多个客户端抢同一把锁时,各自在锁路径下创建一个临时顺序节点,节点序号最小的那个就是锁的持有者。
Watch 监听则解决了"如何知道锁被释放"的问题。每个等待者只需要监听自己前一个节点的删除事件,不需要监听所有子节点,这样既及时又不会惊动整个集群。
这套机制天然避免了 Redis 锁的误删问题:释放锁时,客户端删除的就是自己创建的那个节点,路径唯一、带着会话信息,谁也删不掉别人的锁。也不存在锁过期业务没执行完的问题,除非会话超时,而会话超时可以通过参数调大。
1.3 Curator把ZK锁封装成了什么
ZK 原生 API 也能写锁,但代码量不少,而且容易踩到事件重复监听、节点删除异常、会话恢复等细节。Apache Curator 把这一切收口了,对外就是几个构造器加 acquire/release,内部帮你处理了连接重试、会话管理、Watch 注册和清理。
Curator 的锁一共有这么几类:
- InterProcessMutex:可重入互斥锁,最常用,约等于"默认答案"。
- InterProcessSemaphoreMutex:不可重入互斥锁,同一线程二次 acquire 会阻塞。
- InterProcessSemaphoreV2:真正的信号量,可以设置同时最多 N 个持有者。
- InterProcessReadWriteLock:读写锁,读读共享、写写互斥、读写互斥。
注意,InterProcessMutex 和 InterProcessSemaphoreMutex 虽然都叫互斥锁,但在"可重入"这一点上截然不同。接下来分别拆解。
2. InterProcessMutex:可重入互斥锁的完整工作链路
2.1 加锁过程拆解:临时顺序节点与排队
InterProcessMutex 加锁时,会在你指定的锁路径下创建一个临时顺序节点。比如锁路径是/locks/pay-1001,实际创建的节点是/locks/pay-1001/_c_xxxx000000000a这类带序号的子节点。
创建完成之后,客户端做的事情就是一句话:检查自己是不是当前所有子节点里序号最小的。如果是,说明抢锁成功,进入业务代码;如果不是,就找到序号在自己前面的那个节点,注册一个 Watch 等它删除,同时自己阻塞等待。前一个节点释放锁时删除节点,触发 Watch,当前线程被唤醒,重新检查一次序号。
这里有两个关键点值得展开。
第一,使用顺序节点的原因是保证公平性。创建时间越早,序号越小,越先获得锁。后到的人必须排队,不会出现线程饿死的情况。如果你用普通临时节点,所有客户端同时抢一个节点,只有一个能成功,其他人都要重试,又慢又容易产生惊群效应。
第二,等待者监听前一个节点,而不是监听所有节点。假设有一百个线程在排队,某个线程释放锁时只通知下一个,下一个执行完再通知下下个,通知链路是串行的。如果大家都监听锁根路径下的所有子节点,一个节点删除会引起一百次唤醒风暴,这在 ZooKeeper 场景里是很伤集群的事情。
加锁伪代码可以理解为:
创建临时顺序节点 EPHEMERAL_SEQUENTIAL 循环: 获取锁路径下所有子节点,按序号排序 如果自己是第一个:加锁成功,返回 否则:找到前一个节点,Watch 它的删除事件,阻塞等待Curator 把上述逻辑封装在 LockInternals 内部,使用者不需要关心循环和 Watch 细节。
2.2 可重入的实现:线程内部的计数器
可重入的意思,是同一个线程在已经持有锁的情况下再次 acquire,不应该阻塞,而是直接成功并返回。对应到代码里,就是同一个线程里嵌套调用锁,不会死锁。
InterProcessMutex 内部维护了一个成员变量:
private final ConcurrentMap<Thread, LockData> threadData;LockData 保存线程对应的锁信息,关键字段是一个计数器和底层的锁对象。
当线程第一次 acquire 时,lockData 不存在,走真实的 ZooKeeper 抢锁逻辑,创建临时顺序节点,抢锁成功后把线程和 LockData 放进去,计数器初始化为 1。
当同一个线程再次 acquire 时,会先查 threadData,发现当前线程已经有 LockData 了,就不再走 ZK 交互,直接把计数器加 1,然后立即返回成功。整个过程不产生新的 ZK 节点,也没有网络请求。
release 的逻辑正好相反。每次 release 先把计数器减 1,只有计数器减到 0,才真正从 threadData 移除 LockData,并删除 ZK 上的临时节点。如果计数器还是正数,说明外层还有锁没释放,只是更新数字,不碰 ZK 节点。
这个设计你可以理解成一把锁分成了多条"持有线",最外层线退出后整把锁才归还。它解决的问题非常典型:一个大方法里调用了另一个也加锁的小方法,如果锁不可重入,第二次 acquire 会永远等待自己释放,直接死锁。
2.3 acquire/release 的代码实战与规范
实际项目中 InterProcessMutex 的标准写法如下:
CuratorFramework client = CuratorFrameworkFactory.newClient( "zk1:2181,zk2:2181,zk3:2181", new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessMutex lock = new InterProcessMutex(client, "/locks/pay-" + orderId); try { if (lock.acquire(10, TimeUnit.SECONDS)) { // 拿到锁,执行支付相关业务 try { doPay(orderId); } finally { lock.release(); } } else { // 超时未获得锁,做降级处理 } } catch (Exception e) { // 处理连接异常、会话异常等 } finally { client.close(); }有几个规范必须要说清楚。
acquire 一定要传超时参数。不传的话是无限期阻塞等待,如果 ZooKeeper 集群抖动,线程可能长时间挂在等待队列里,而且排查困难。带超时参数,拿不到锁就返回 false,让业务走降级或重试,系统的韧性强很多。
release 必须放在 finally 里,而且必须在获得锁的同一个线程里调用。InterProcessMutex 会在 release 时校验当前线程是否持有锁,否则抛出 IllegalMonitorStateException。跨线程释放锁是被禁止的。
判断是否持有锁可以用lock.isAcquiredInThisProcess(),这比你自己维护布尔变量靠谱,因为 acquire 成功路径上任何异常都可能让你的布尔值失真。
锁路径要有统一命名规范。建议/locks/{业务域}/{资源ID},比如/locks/pay/order_1001、/locks/stock/deduct_sku_888。路径太浅会扩大锁粒度,路径太深难以排查。
3. InterProcessSemaphoreMutex:名字叫信号量,其实是不能重入的互斥锁
3.1 从类继承关系看它的真实身份
InterProcessSemaphoreMutex 这个类名很让人误解,第一次看到它的人十有八九觉得它是信号量。实际上它是互斥锁,本质上是 InterProcessSemaphoreV2 的一个特例。
InterProcessSemaphoreV2 的构造函数要求传入最大租约数 maxLeases,表示同一时刻最多允许多少个客户端同时持有锁,这是真正的信号量语义。InterProcessSemaphoreMutex 做的事情就是把 maxLeases 固定传 1:
public InterProcessSemaphoreMutex(CuratorFramework client, String path) { super(client, path, 1); }也就是说,InterProcessSemaphoreMutex 是一个"最多只能有一个持有者、且不可重入"的互斥锁。而 InterProcessSemaphoreV2 则是"最多 N 个持有者,每个持有者的每次 acquire 都是一次独立租约"。
搞清楚这个继承关系,你就明白为什么它的行为和 InterProcessMutex 有本质区别了。简单说:InterProcessMutex 的锁对象会和线程绑定、支持嵌套;InterProcessSemaphoreMutex 只看租约数量,不区分线程,同一个线程第二次 acquire 算是又申请了一个租约,但在 maxLeases=1 的情况下,第二个租约永远等不到。
3.2 缺少可重入机制后会怎样
直接看行为。
线程 T 第一次调用semaphoreMutex.acquire()成功了,此时 T 在锁路径下有一个租约节点,序号最小,持有锁。
接着 T 在未释放锁的情况下,又调用了第二次acquire()。此时 ZK 锁路径下会有两个租约节点,第一个节点是 T 自己的租约,占着唯一的名额;第二次创建的节点排在后面。T 发现自己不是最小序号,于是开始等待前一个节点删除。
前一个节点是它自己的第一个租约节点,永远不会被删除,除非 T 自己 release。但 T 现在正阻塞在第二次 acquire 上,根本没有机会执行 release。于是线程 T 把自己锁死了。
这就是典型的自死锁。如果用 InterProcessMutex,这种场景完全没问题,因为第二次 acquire 直接走了线程计数,不会创建新节点。用 InterProcessSemaphoreMutex 写递归方法,或者在一个加锁方法里又调另一个也加锁的方法,分分钟把自己卡死,而且这种 bug 在代码评审阶段很难发现,只有跑起来才会暴露。
有的同学会问:它确实保证互斥吗?保证。它和 InterProcessMutex 一样,最终只有一个租约节点能成为最小序号,持有者唯一。但它的互斥是靠"租约数量"强制限制,不是靠"线程持有状态"控制的。
3.3 什么时候该用它,什么时候别硬用
说实话,InterProcessSemaphoreMutex 的实际使用频率远低于 InterProcessMutex。它适合什么样的场景呢?
我认为主要是两类。
一类是你想对同一个资源加锁,同时非常明确地禁止嵌套加锁。比如一些底层基础设施代码,设计上就不允许某个阻塞方法内部再对同一个锁路径加锁,那用 InterProcessSemaphoreMutex 等于把"不可重入"这个约束写进方案里,一旦有人嵌套调用,程序会阻塞暴露问题,而不是无声地允许。
另一类是监控和治理诉求很强的场景。线程一旦尝试重复获取同一把锁,最好立刻失败,而不是静默重入。因为重入成功可能掩盖掉设计上的坏味道——比如代码结构不合理,导致一个线程在一个调用链路里多次拿同一把锁。
如果业务只是要互斥,没有特殊理由要求不可重入,不要硬用 InterProcessSemaphoreMutex。不要觉得名字里有 Semaphore 就更高级,它只是把信号量容量固定为 1 而已;你要是真需要控制并发数,用它也是错的,该用的是 InterProcessSemaphoreV2。
4. 两张锁的正面对比:参数、行为、源码与选型决策
4.1 一张表看清行为差异
我在实际项目里给团队做选型评审时,习惯用下面这张表快速定案:
| 维度 | InterProcessMutex | InterProcessSemaphoreMutex |
|---|---|---|
| 锁类型 | 可重入互斥锁 | 不可重入互斥锁 |
| 底层模型 | 独占锁 + 线程计数 | InterProcessSemaphoreV2 信号量,固定租约数=1 |
| 同一线程再次 acquire | 直接成功,计数+1 | 阻塞等待第一个租约释放,形成自死锁 |
| 锁最大持有者数 | 1 | 1 |
| 是否公平 | 是,临时顺序节点排队 | 是,临时顺序节点排队 |
| 创建方式 | new InterProcessMutex(client, path) | new InterProcessSemaphoreMutex(client, path) |
| 典型使用场景 | 分布式任务防重、支付幂等、优惠券扣减 | 强制不可嵌套的互斥操作 |
| 最容易踩的坑 | 忘记 finally release | 递归调用时线程卡死自己 |
这张表里最关键的差异就一行:同一线程再次 acquire 时,一个走内存计数,一个走 ZK 排队。绝大多数选型失误,都是没分清这一行。
4.2 源码层级的关键差异点
从源码层面看,InterProcessMutex 内部有一个ConcurrentMap<Thread, LockData>的成员,这是可重入机制的"账本"。每次 acquire 先查账本,命中就加计数,不命中才走真正的 ZK 抢锁流程。释放时也是先查账本,计数归零才删除节点。
InterProcessSemaphoreMutex 继承自 InterProcessSemaphoreV2,没有线程账本这个概念。它的 acquire 每次都会尝试在锁路径下创建租约节点,然后根据当前子节点序号判断自己是否在允许范围内。因为租约数被写死成 1,所以只有序号最小的节点能成功,其他节点一律进入等待。
换句话说,InterProcessMutex 的锁状态是"线程维度"的,计数存在客户端进程内存里;InterProcessSemaphoreMutex 的锁状态是"节点维度"的,每次 acquire 都是独立的一次 ZK 写入。前者的重入是透明的,后者不具备任何重入语义。
还有个值得注意的差异:InterProcessMutex 释放时会校验当前线程是否真的持有锁,这是内存态的强校验;InterProcessSemaphoreMutex 释放时主要按租约节点释放,线程身份约束弱,它更强调"你 acquire 到几个租约,就要 release 几个租约"。
4.3 选型决策:默认Mutex,极端场景才考虑SemaphoreMutex
给一个可以直接抄的选型逻辑。
默认选 InterProcessMutex,没有例外。理由很简单:可重入是工程上非常宝贵的特性,它保证你的代码不管嵌套多深,只要逻辑上是对同一个资源的互斥保护,就不会自己把自己拦住。现代 Java 代码里,方法之间互相调用、AOP 切面包来包去,锁很可能会在同一个线程里被多次触发,可重入锁能平滑地容忍这一切。
当你确认"绝对不允许重入"是这个锁的核心需求时,再考虑 InterProcessSemaphoreMutex。但我想提醒你,这种需求非常罕见。如果你只是觉得"互斥锁本该是排他的,不可重入更严格",那你想反了——不可重入不等于更安全,它只是把锁的适用范围变窄了。
如果并发数不是 1,比如"允许同一台机器的三个线程并行处理,但不能超过三个",那不要碰这两把锁,用 InterProcessSemaphoreV2,设置 maxLeases=3。很多人把 SemaphoreMutex 当成了 N=1 的通用信号量,这是对 API 的误读。
再说一个容易忽略的点:如果已经引入了读写锁需求,也别自己拿 Mutex 做读锁兼容。Curator 提供了 InterProcessReadWriteLock,内部用两个子路径分别管理读锁和写锁,读锁可重入、写锁不可重入,语义对标 Java 的 ReentrantReadWriteLock。选型不要停留在两把锁上,Curator 的锁家族是成体系的。
5. 实战避坑清单与面试官高频追问
5.1 我踩过的锁坑,每一个都真实发生过
第一个坑:用 InterProcessSemaphoreMutex 处理一个本来会用递归的树状任务。任务要对目录加锁后递归扫描子目录,子目录又走同一个加锁方法。第一版测试用例只跑顶层目录,一切正常;加了深层目录后线程直接超时 hang 住。排查半天才发现是第二次 acquire 等第一个租约释放,自己等自己。后来换成 InterProcessMutex,一行代码治好了死锁。
第二个坑:acquire 不传超时参数。当时想着"锁冲突概率很低,等一会儿也没关系",结果 ZooKeeper 集群做一次滚动重启,大量线程绑在 acquire 里无限等待,业务线程池被打满,连降级接口都调不进去。从此我写锁必带超时,拿不到就返回 false,宁可错失一次执行机会,也不能让线程池死在那。
第三个坑:release 的调用线程不对。有一次在异步回调里释放锁,回调线程和加锁线程不是同一个,InterProcessMutex 直接抛 IllegalMonitorStateException。这其实是 Curator 故意的,就是为了防止你把锁释放语义搞乱。跨线程释放锁非常危险,会导致锁状态和业务执行状态脱节。
第四个坑:锁路径随意。有人把锁路径写成/order,结果所有订单共用一把锁,服务吞吐直接掉到个位数。锁路径的粒度本质上就是锁的粒度,必须按业务资源和唯一标识进行细分,别偷懒。
第五个坑:会话超时和业务超时没对齐。ZooKeeper 临时节点绑定会话,如果业务执行时间超过 sessionTimeout,ZK 会认为客户端挂了,自动删除临时节点。此时锁"看似释放了",但业务还在跑,另一个线程进来也能拿到锁。这个问题的解法是合理设置 sessionTimeout 和 connectTimeout,并且业务内的数据库操作、远程调用都要设超时,保证持有锁的时间远小于会话超时。
第六个坑:Curator 版本和 ZooKeeper 版本不匹配。Curator 4.x 对应 ZooKeeper 3.5 以上,Curator 5.x 要求更高。如果你还在用一个老掉牙的 ZooKeeper 3.4,直接上 Curator 4.x 会跑出一堆兼容性异常。升级前先查版本兼容矩阵,这套搭配没有捷径。
5.2 面试官问"分布式锁"时,到底在问什么
分布式锁是面试高频题,但大多数面试官并不是真想让你写代码,而是想看你有没有完整思考过一个分布式问题。
经典的连环追问一般长这样。
第一问:分布式锁有哪些实现方案。标准回答是 Redis 的 SET NX EX、Redisson 的看门狗、ZooKeeper 临时顺序节点、数据库唯一约束。能说出每种方案的适用边界更加分。
第二问:ZooKeeper 分布式锁的原理是什么。要能把临时顺序节点、最小序号、Watch 前驱这三板斧讲清楚。尤其要说清楚为什么用顺序节点而不是临时节点,为什么只监听前一个节点而不是所有子节点。后者能引出羊群效应的话题,讲得好很加分。
第三问:InterProcessMutex 和 InterProcessSemaphoreMutex 有什么区别。这就是最直接的考点。核心就是可重入与不可重入的差异、线程计数与租约数固定的差异。如果还能补一句"SemaphoreMutex 是 SemaphoreV2 且 maxLeases=1 的特例",说明你读过源码,层次不一样。
第四问:Redis 锁和 ZK 锁你怎么选。建议回答:追求强一致、业务对重复执行极其敏感,选 ZK;追求吞吐、可以容忍极端情况下的重复执行,选 Redis。不要把话说绝,分布式系统没有银弹。
第五问:可重入分布式锁怎么设计。标准答案是每个线程一个计数器,加锁时计数加一,释放时计数减一,减到零才真正释放锁。你甚至可以顺势说出 InterProcessMutex 的 ConcurrentMap 设计,面试官会觉得你是真的写过。
第六问:分布式锁使用场景都有哪些。常见的是:定时任务防重、订单支付防重复提交、商品库存扣减、分布式节点上的资源清理互斥。记住一个原则:任何"多机环境下同一业务动作只能成功一次"的需求都是锁的候选场景。
5.3 还能往哪个方向扩展
锁用熟之后,可以再往前一步。
可以试试 InterProcessReadWriteLock,处理读多写少的资源。比如配置中心里一个配置项频繁被读、偶尔被写,读锁可以并行持有,写锁独占,吞吐会比全量互斥高不少。
可以试试 InterProcessSemaphoreV2 做服务端限流。比如限制某个下游接口最多同时 5 个调用,就把 maxLeases 设成 5,每次调用前 acquire 一个租约,调用完 release。这比在网关层做静态令牌桶更贴近业务语义,因为是按真实调用链路占位。
还可以研究一下 Curator 的 LeaderLatch 和 LeaderSelector。它们不是锁,但思路和锁很像,用于集群中选一个 leader 执行特殊任务,比如定时清理、索引重建。理解了临时节点和会话机制,这一族 API 都是相通的。
我个人在实际项目里的体会是:锁的选型其实没那么玄,真正考验人的是边界情况。可重入、超时、释放顺序、会话生命周期,任何一个环节想漏了,锁就能在混乱时刻给你一把"温柔"的背刺。先把 InterProcessMutex 用透,再去谈其他花活,这是最稳妥的路径。