ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ZooKeeper分布式锁:Curator两把锁的原理与选型指南

ZooKeeper分布式锁:Curator两把锁的原理与选型指南 先给个结论在真正需要强一致性的分布式场景里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 比对后再删这已经是基本操作了。第二是锁过期但业务没跑完。业务执行时间超过锁的 TTLRedis 锁自动消失另一个线程进来重复执行。常见解法是看门狗续期但续期代码写起来也不省心。第三是主从切换丢锁。Redis 主节点写入了锁但还没同步到从节点主节点挂了从节点顶上锁就丢了。Redlock 想解决这个问题又因为时钟跳跃、GC 停顿被很多工程团队质疑。不是说 Redis 锁不能用而是它属于性能优先、容忍极小概率错误的方案。一旦你的业务对同一时刻只能有一个执行者要求很严格ZooKeeper 模型会更安心。1.2 ZK锁原生避免误删与丢失的机制ZooKeeper 实现分布式锁的核心是三样东西临时节点、顺序节点、Watch 监听。临时节点表示这个节点和客户端会话绑定会话结束节点自动删除。这直接解决了客户端崩溃导致锁永远持有的问题——不需要算 TTLZK 自己会清理。顺序节点表示子节点会按创建顺序附加一个单调递增的序号。多个客户端抢同一把锁时各自在锁路径下创建一个临时顺序节点节点序号最小的那个就是锁的持有者。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 ConcurrentMapThread, 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 固定传 1public InterProcessSemaphoreMutex(CuratorFramework client, String path) { super(client, path, 1); }也就是说InterProcessSemaphoreMutex 是一个最多只能有一个持有者、且不可重入的互斥锁。而 InterProcessSemaphoreV2 则是最多 N 个持有者每个持有者的每次 acquire 都是一次独立租约。搞清楚这个继承关系你就明白为什么它的行为和 InterProcessMutex 有本质区别了。简单说InterProcessMutex 的锁对象会和线程绑定、支持嵌套InterProcessSemaphoreMutex 只看租约数量不区分线程同一个线程第二次 acquire 算是又申请了一个租约但在 maxLeases1 的情况下第二个租约永远等不到。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 一张表看清行为差异我在实际项目里给团队做选型评审时习惯用下面这张表快速定案维度InterProcessMutexInterProcessSemaphoreMutex锁类型可重入互斥锁不可重入互斥锁底层模型独占锁 线程计数InterProcessSemaphoreV2 信号量固定租约数1同一线程再次 acquire直接成功计数1阻塞等待第一个租约释放形成自死锁锁最大持有者数11是否公平是临时顺序节点排队是临时顺序节点排队创建方式new InterProcessMutex(client, path)new InterProcessSemaphoreMutex(client, path)典型使用场景分布式任务防重、支付幂等、优惠券扣减强制不可嵌套的互斥操作最容易踩的坑忘记 finally release递归调用时线程卡死自己这张表里最关键的差异就一行同一线程再次 acquire 时一个走内存计数一个走 ZK 排队。绝大多数选型失误都是没分清这一行。4.2 源码层级的关键差异点从源码层面看InterProcessMutex 内部有一个ConcurrentMapThread, 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设置 maxLeases3。很多人把 SemaphoreMutex 当成了 N1 的通用信号量这是对 API 的误读。再说一个容易忽略的点如果已经引入了读写锁需求也别自己拿 Mutex 做读锁兼容。Curator 提供了 InterProcessReadWriteLock内部用两个子路径分别管理读锁和写锁读锁可重入、写锁不可重入语义对标 Java 的 ReentrantReadWriteLock。选型不要停留在两把锁上Curator 的锁家族是成体系的。5. 实战避坑清单与面试官高频追问5.1 我踩过的锁坑每一个都真实发生过第一个坑用 InterProcessSemaphoreMutex 处理一个本来会用递归的树状任务。任务要对目录加锁后递归扫描子目录子目录又走同一个加锁方法。第一版测试用例只跑顶层目录一切正常加了深层目录后线程直接超时 hang 住。排查半天才发现是第二次 acquire 等第一个租约释放自己等自己。后来换成 InterProcessMutex一行代码治好了死锁。第二个坑acquire 不传超时参数。当时想着锁冲突概率很低等一会儿也没关系结果 ZooKeeper 集群做一次滚动重启大量线程绑在 acquire 里无限等待业务线程池被打满连降级接口都调不进去。从此我写锁必带超时拿不到就返回 false宁可错失一次执行机会也不能让线程池死在那。第三个坑release 的调用线程不对。有一次在异步回调里释放锁回调线程和加锁线程不是同一个InterProcessMutex 直接抛 IllegalMonitorStateException。这其实是 Curator 故意的就是为了防止你把锁释放语义搞乱。跨线程释放锁非常危险会导致锁状态和业务执行状态脱节。第四个坑锁路径随意。有人把锁路径写成/order结果所有订单共用一把锁服务吞吐直接掉到个位数。锁路径的粒度本质上就是锁的粒度必须按业务资源和唯一标识进行细分别偷懒。第五个坑会话超时和业务超时没对齐。ZooKeeper 临时节点绑定会话如果业务执行时间超过 sessionTimeoutZK 会认为客户端挂了自动删除临时节点。此时锁看似释放了但业务还在跑另一个线程进来也能拿到锁。这个问题的解法是合理设置 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 且 maxLeases1 的特例说明你读过源码层次不一样。第四问Redis 锁和 ZK 锁你怎么选。建议回答追求强一致、业务对重复执行极其敏感选 ZK追求吞吐、可以容忍极端情况下的重复执行选 Redis。不要把话说绝分布式系统没有银弹。第五问可重入分布式锁怎么设计。标准答案是每个线程一个计数器加锁时计数加一释放时计数减一减到零才真正释放锁。你甚至可以顺势说出 InterProcessMutex 的 ConcurrentMap 设计面试官会觉得你是真的写过。第六问分布式锁使用场景都有哪些。常见的是定时任务防重、订单支付防重复提交、商品库存扣减、分布式节点上的资源清理互斥。记住一个原则任何多机环境下同一业务动作只能成功一次的需求都是锁的候选场景。5.3 还能往哪个方向扩展锁用熟之后可以再往前一步。可以试试 InterProcessReadWriteLock处理读多写少的资源。比如配置中心里一个配置项频繁被读、偶尔被写读锁可以并行持有写锁独占吞吐会比全量互斥高不少。可以试试 InterProcessSemaphoreV2 做服务端限流。比如限制某个下游接口最多同时 5 个调用就把 maxLeases 设成 5每次调用前 acquire 一个租约调用完 release。这比在网关层做静态令牌桶更贴近业务语义因为是按真实调用链路占位。还可以研究一下 Curator 的 LeaderLatch 和 LeaderSelector。它们不是锁但思路和锁很像用于集群中选一个 leader 执行特殊任务比如定时清理、索引重建。理解了临时节点和会话机制这一族 API 都是相通的。我个人在实际项目里的体会是锁的选型其实没那么玄真正考验人的是边界情况。可重入、超时、释放顺序、会话生命周期任何一个环节想漏了锁就能在混乱时刻给你一把温柔的背刺。先把 InterProcessMutex 用透再去谈其他花活这是最稳妥的路径。
返回列表