
面试的时候一上来就抛“Zookeeper分布式锁怎么实现”很多同学能说出临时顺序节点、Watcher、羊群效应这几个词但真到白板写代码或者突然来个追问“你为什么监听前一个节点而不是根节点”就卡壳了。Zookeeper分布式锁既是Java后端面试里的高频题也是真实项目中做多机互斥时绕不开的方案。这篇文章我就把它从原理到代码、从手写到生产落地系统拆开讲一遍适合正在刷Java面试题的人也适合已经在用Zookeeper做分布式协调、想在锁的实现层面再深挖一下的同学。1. 先搞明白Zookeeper凭什么能做分布式锁1.1 ZNode和数据一致性是地基想要理解分布式锁先得接受一个前提Zookeeper本身是一个“分布式的、有共识机制的文件系统”。它里面的ZNode类似于文件节点可以存数据也可以只有子节点。每个节点有唯一的路径比如/locks/order这就给了我们一个天然的“命名空间”。ZNode挺好理解你可以把它想成酒店里的房卡。不同房间是不同的路径互不冲突。分布式锁的本质就是“同一时间只能有一个客户端拿到某个路径的控制权”。Zookeeper由于自身基于ZAB协议保证了数据一致性所以多个客户端同时去创建同一个ZNode时最终只有一个人会成功这个原子性问题是分布式锁最核心的抓手。如果脱离Zookeeper靠普通数据库或者自己用Redis命令可能还需要处理各种超时、续期、删除校验等边界问题而ZNode的创建天然就是原子的这让锁模型一下子简化了很多。1.2 三种ZNode类型锁靠的是后两种面试题里常考的ZNode类型就三种持久节点、临时节点、顺序节点。前两者可以再组合出“持久顺序节点”和“临时顺序节点”但锁场景真正依赖的是临时节点和顺序节点。临时节点会话结束自动删除。这个特性是分布式锁“自动释放”的基石客户端崩溃了锁也会因为会话断开而消失不用像Redis锁那样等着过期时间慢慢磨。顺序节点创建时自动在路径后面追加自增序号比如/locks/lock_0000000001。这个自增序号是全局唯一的天然可以用来排队。把两者组合成“临时顺序节点”就得到了一套完美的排队取号机制每个客户端先取一个号号最小的那个拿到锁其他人排队等待。这一下子把分布式锁从“谁抢到算谁的”变成了“按先来后到依次执行”这也更贴近业务里对公平性的期待。1.3 Watcher机制让客户端不会“空转”再补一个关键点Watcher。Zookeeper允许客户端在某个ZNode上注册监听器节点变化时服务端会主动通知。这个机制对应到锁流程里就是“你前面的那个人释放了锁Zookeeper会告诉你而不是要你自己每分钟去问一遍”。很多人在纸上画流程时忽略Watcher但实际实现里它非常重要。没有Watcher客户端就只能在阻塞期间不断轮询getChildren这会产生大量无效请求也就是后面会提到的“羊群效应”的来源之一。有了Watcher客户端可以阻塞等待事件代码简单集群的压力也小得多。2. Zookeeper分布式锁的两种经典实现2.1 实现一临时节点 Watcher简单但不推荐先说最直观的思路。所有客户端抢着去创建同一个临时节点比如/locks/order_lock。谁创建成功谁就获得锁。没创建成功的客户端在这个节点上注册Watcher等节点被删除时再尝试创建。这个方案确实简单代码量极少很多人第一次手写锁都是这个思路。但它有两个隐患。第一所有没抢到锁的客户端都监听同一个节点锁一释放所有客户端同时被唤醒大家一起冲过去创建节点但最终只有一个能成功其余人只能再次陷入等待。这种“惊群”现象在客户端数量多的时候会浪费大量连接和请求资源。第二它做不到公平。理论上谁先启动谁先抢但顺序完全不可控真正的业务场景里如果依赖“先到先得”这个方案就不合适。所以我在面试时如果被问到这个一般就会说“临时节点方案能实现基本互斥但不公平而且有惊群问题所以生产上一般不用主流是临时顺序节点方案”。2.2 实现二临时顺序节点 监听前一个节点公平且高效这才是你该花时间吃透的方案也是绝大多数Zookeeper锁实现的核心。流程分五步客户端在锁目录/locks/order下创建临时顺序节点得到路径/locks/order/lock_0000000003。客户端获取/locks/order下的所有子节点。判断自己是不是序号最小的那个节点。如果是说明获得了锁直接执行业务逻辑。如果不是就找到比自己序号小一号的那个节点只对“前一个节点”注册Watcher然后阻塞等待它被删除。前一个节点被删除后客户端被唤醒重新执行第2步再判断自己是不是最小的。这方案为什么好因为它把“所有客户端抢一把锁”变成了“所有客户端排成一队每个人只关心自己前面的那个人”。锁释放时只需要唤醒一个客户端惊群效应被彻底消解。加上临时顺序节点的序号天然有序锁的获取顺序就是请求到达顺序公平性问题也解决了。实际项目里Apache Curator的InterProcessMutex就是基于这个思路实现的所以理解了这个方案你等于连Curator的底层原理也一起拿下了。2.3 两种方案面试怎么答更出彩如果只是背出“临时顺序节点”五个字面试官不会满足。你要在答案里自然地把“为什么不用临时节点”和“为什么监听前一个节点”这两个点串起来展示你考虑过方案取舍。我一般会这么答第一临时节点方案虽然实现简单但所有竞争者都监听同一个节点产生惊群释放锁时大量客户端被无意义唤醒第二临时节点方案抢占顺序不可控无法保证公平第三临时顺序节点天然带全局递增序号可以形成排队队列每个客户端只需要监听自己前一个节点锁粒度的竞争被分摊到前后节点之间唤醒精确且高效。这样一段话既回答了“怎么实现”也回答了“为什么这么实现”信息量完全不同。3. 手写核心代码从加锁到解锁的完整链路3.1 准备环境与依赖手写代码前先准备好Zookeeper服务端和客户端依赖。本地开发的话用Docker起一个单机ZooKeeper最快docker run -d --name zk -p 2181:2181 -e ALLOW_ANONYMOUS_LOGINyes zookeeper:3.7Java工程里引入Zookeeper原生客户端依赖这里用3.7版本示例dependency groupIdorg.apache.zookeeper/groupId artifactIdzookeeper/artifactId version3.7.1/version /dependency然后初始化ZooKeeper连接。注意sessionTimeout这个参数它直接影响锁的自动释放时间我后面会专门讲。这里先给一个基础连接示例ZooKeeper zooKeeper new ZooKeeper( 127.0.0.1:2181, 30000, event - System.out.println(收到事件 event.getType()) );3.2 核心锁实现类我用原生API手写一个可用的分布式锁核心类大概长这样。先说明一下这里我刻意用最简单直观的方式实现不引入任何框架封装方便展示原理生产上我更推荐用Curator。public class ZkDistributedLock implements AutoCloseable { private static final String LOCK_ROOT /locks; private static final String LOCK_NAME lock_; private final ZooKeeper zooKeeper; private final String lockPath; private final CountDownLatch latch new CountDownLatch(1); private volatile boolean locked false; public ZkDistributedLock(ZooKeeper zooKeeper, String lockKey) { this.zooKeeper zooKeeper; try { if (zooKeeper.exists(LOCK_ROOT, false) null) { zooKeeper.create(LOCK_ROOT, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); } this.lockPath zooKeeper.create( LOCK_ROOT / lockKey / LOCK_NAME, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL ); } catch (Exception e) { throw new RuntimeException(创建锁节点失败, e); } } public boolean tryLock(long timeout, TimeUnit unit) throws Exception { long deadline System.currentTimeMillis() unit.toMillis(timeout); boolean gained attemptAcquire(); while (!gained) { long remaining deadline - System.currentTimeMillis(); if (remaining 0) { return false; } // 阻塞等待前一个节点事件带超时 latch.await(remaining, TimeUnit.MILLISECONDS); gained attemptAcquire(); } locked true; return true; } private boolean attemptAcquire() throws Exception { ListString children zooKeeper.getChildren( lockPath.substring(0, lockPath.lastIndexOf(/)), false); Collections.sort(children); String currentName lockPath.substring(lockPath.lastIndexOf(/) 1); int index children.indexOf(currentName); if (index 0) { return true; } String prevNodePath lockPath.substring(0, lockPath.lastIndexOf(/)) / children.get(index - 1); if (zooKeeper.exists(prevNodePath, true) ! null) { return false; } // 前一个节点已经不存在重新尝试获取 return attemptAcquire(); } Override public void close() throws Exception { if (locked) { zooKeeper.delete(lockPath, -1); locked false; } } }等等这里有一个关键点必须说明上面的attemptAcquire里用了zooKeeper.exists(prevNodePath, true)注册Watcher但这个true注册的是默认Watcher也就是每次只能注册一个Watcher而且有并发问题。原生API的Watcher是一次性的如果你在tryLock循环里反复调用需要小心Watcher失效问题。一个更稳妥的写法是使用new Watcher()匿名类手动注册并把CountDownLatch在Watcher回调里countDown。下面我把这个细节补上让代码可以直接跑且行为是正确的private boolean attemptAcquire() throws Exception { String parentPath lockPath.substring(0, lockPath.lastIndexOf(/)); ListString children zooKeeper.getChildren(parentPath, false); Collections.sort(children); String currentName lockPath.substring(lockPath.lastIndexOf(/) 1); int index children.indexOf(currentName); if (index 0) { return true; } String prevNodePath parentPath / children.get(index - 1); Stat stat zooKeeper.exists(prevNodePath, event - { if (event.getType() Event.EventType.NodeDeleted) { latch.countDown(); } }); return stat null; }递归调用的写法在实际中要小心如果前一个节点刚被删除exists返回null会立即返回true这没问题但如果在getChildren后、exists前前一个节点被删除了exists返回null此时还需要重新做一次完整的判断而不是直接认为拿到锁。这个逻辑自己推演一遍就能发现所以我实际更推荐用循环而不是递归来写主流程。3.3 加锁流程的关键时序先说主流程的时序这个面试时最好能主动画出来。线程A创建临时顺序节点/locks/order/lock_0000000001获取子节点列表后发现自己序号最小直接拿到锁进入业务逻辑。线程B创建节点/locks/order/lock_0000000002发现前一个节点是lock_0000000001于是对/locks/order/lock_0000000001注册Watcher然后阻塞等待。线程A执行完业务关闭资源或主动删除lock_0000000001Zookeeper触发NodeDeleted事件线程B的Watcher被调用latch.countDown()唤醒线程B线程B重新获取子节点列表确认自己是序号最小的节点获得锁。这里有个魔鬼细节如果线程A是会话超时导致临时节点被删除而非主动释放锁线程B同样会被唤醒。但此时线程A可能还在执行业务逻辑并没有真正结束。这是Zookeeper分布式锁的一个先天限制你只能通过合理设置sessionTimeout去缩小这个窗口做不到完全消除。面试里被问到“ZK锁有没有可能两个客户端同时拿到锁”时答案就是“极端情况下可能比如会话超时但业务仍在执行”。3.4 解锁与异常处理解锁动作就是删除自己创建的临时顺序节点。zooKeeper.delete(lockPath, -1);-1表示不校验版本号直接删除。为什么可以这样因为临时顺序节点路径是唯一的每个客户端只删除自己创建的那个节点不存在误删别人锁的可能。Redis分布式锁里要拿一个随机value来防误删在Zookeeper锁里天然就不需要因为路径本身就是身份的凭证。还有一点很重要。使用原生API时拿到锁后执行业务代码一定要把解锁放在finally块里否则业务抛异常时锁不会被释放后续所有排队线程都会卡死。即使你用Curator的InterProcessMutex它在内部封装了释放逻辑你在业务代码里也要保证release()一定会执行。我再补充一个实际工程里的小经验不要在持有锁的期间做太耗时的操作比如远程接口调用或者大批量数据计算。Zookeeper锁没有自动续期机制锁的有效期受sessionTimeout约束。如果业务执行时间超过了sessionTimeout服务端会认为客户端已死直接删除临时节点锁就提前释放了后续排队的线程会拿到锁进来造成并发安全问题。这个窗口很难完全消除所以实践中要么调大sessionTimeout要么把持锁时间压缩到足够短。4. 面试官喜欢追问的5个问题4.1 Zookeeper锁和Redis锁怎么选各自优缺点这是分布式锁问题里最经典的对决。我整理了一张对比表面试时可以直接拿来当答题框架对比维度Zookeeper分布式锁Redis分布式锁底层一致性ZAB协议强一致AP模型主从切换时可能丢锁自动释放临时节点随会话消失靠设置过期时间公平性临时顺序节点天然公平通常不公平羊群效应监听前一个节点无惊群自旋重试视实现而定性能创建/删除节点zk写入性能中等Redis内存操作性能高依赖组件需要额外维护Zookeeper集群已有Redis时零成本复用业务里怎么选我一般会看两个指标一是对一致性要求有多高二是当前基础设施里有没有现成的Redis。如果是金融、交易类场景锁的误判可能造成资损我会更倾向Zookeeper如果是高并发缓存更新、定时任务防重复执行这类允许小概率问题的场景Redis锁的成本和性能优势更明显。4.2 羊群效应到底是什么怎么避免羊群效应说直白一点就是“同时惊动一大批人”。在临时节点方案里所有等待锁的客户端都监听同一个节点持锁方一释放锁Zookeeper给所有等待者都发通知但最终只有一个客户端能抢到锁其余人白忙一场。客户端一多瞬间请求量暴涨Zookeeper集群压力剧增。临时顺序节点方案之所以没有羊群效应是因为每个客户端只监听一个前置节点。锁释放时只有排在该节点后面那一个客户端会被唤醒通知是点对点的请求量被平摊到整个等待队列每次的释放动作上。这个设计很优雅面试时能把这个点讲清楚基本就能证明你是真懂而非背题。4.3 锁的有效时间怎么保证ZK怎么自动续期Redis锁的经典痛点是“业务没跑完锁先过期了”所以Redisson有看门狗自动续期。Zookeeper锁没有显式的续期接口但它的锁生命周期与会话绑定只要客户端与Zookeeper之间的会话保持活跃临时节点就不会被删除。实际效果是Zookeeper客户端会通过心跳维持会话sessionTimeout时间内收到心跳就不会判定会话失效锁就一直在。所以Zookeeper锁不是不需要续期而是“会话保活”本身就是续期机制。代价是会话依赖于网络状况发生长时间GC、网络分区时会话可能超时导致锁被释放。所以不能完全指望会话短事务才是王道。4.4 客户端宕机ZK分布式锁会立刻释放吗临时节点的删除由服务端感知会话超时后触发并不是客户端一断就立刻删。默认的sessionTimeout通常是几秒到几十秒客户端异常退出后Zookeeper需要等待超时时间过去才会清理该会话下的所有临时节点。从宕机到锁被释放中间存在一个时间窗口。窗口内如果客户端B一直在等待它只能阻塞等待。这比Redis锁“过期时间到了就叫醒下一个”慢一些但换来的是“只要持有者没真正挂掉锁就不会被误释放”。所以Zookeeper锁安全性更高但可用性响应上略慢。面试时能答出这个“安全性和响应速度之间的权衡”是加分项。4.5 可重入锁和读写锁怎么实现可重入的意思是同一个客户端已经持有锁了再次进入加锁逻辑时能直接获得锁而不是被自己的锁挡住。Curator的InterProcessMutex实现了可重入它内部维护了当前持有锁的线程和重入计数每次acquire时如果本线程已持有就直接把计数加一release时减到0才真正删除节点。读写锁的实现在Curator里也有对应的InterProcessReadWriteLock。思路是为同一把锁建两个前缀读锁和写锁各自维护一套顺序节点。读锁之间可共享写锁独占实现时要额外判断当前等待队列里是否有写节点排在前面。这一块如果面试挖得深通常不是要你真写出来而是考察你有没有在生产环境里用过更丰富的锁类型。5. 生产实践从原生API到Curator5.1 原生API的痛点看完上面的手写代码你应该有感觉了原生API能用但不好用。痛点集中在几个地方Watcher是一次性的每次事件触发后要重新注册容易漏掉。多个线程同时用一个ZooKeeper连接时Watcher回调顺序和连接状态管理很繁琐。连接断开、会话重连、session过期等异常场景都要自己处理一旦处理不到位就会出现锁一直等不到的bug。没有可重入、读写锁等高级语义全部得从零造轮子。所以我平时在项目里从不用原生API做锁而是用Curator。Curator是对Zookeeper的高级封装它的InterProcessMutex把上面所有麻烦都处理好了包括Watcher重注册、会话过期恢复、可重入逻辑。5.2 用Curator实现分布式锁的模板先引入依赖dependency groupIdorg.apache.curator/groupId artifactIdcurator-recipes/artifactId version5.2.0/version /dependency然后是核心用法这个模板我在实际项目中反复用CuratorFramework client CuratorFrameworkFactory.builder() .connectString(127.0.0.1:2181) .sessionTimeoutMs(15000) .connectionTimeoutMs(5000) .retryPolicy(new ExponentialBackoffRetry(1000, 3)) .build(); client.start(); InterProcessMutex lock new InterProcessMutex(client, /locks/order); try { if (lock.acquire(5, TimeUnit.SECONDS)) { try { // 真正的业务逻辑 System.out.println(获取锁成功执行下单逻辑); } finally { lock.release(); } } else { throw new RuntimeException(获取锁超时请稍后重试); } } finally { // 注意Curator的锁在连接关闭后也会释放但不建议依赖 }acquire可以带超时时间拿不到锁就直接返回而不是无限阻塞这在接口场景里是必须的不然大量请求会全部堆积在线程池里。另外ExponentialBackoffRetry是重连策略第一次等1秒第二次等2秒第三次等4秒最多3次Curator会自动处理连接恢复。5.3 实战避坑清单把我在生产环境里踩过的坑集中列一下每一条都不陌生Zookeeper集群不要只用一台。锁依赖Zookeeper的可用性单个节点挂了整个锁服务就瘫了。生产上至少3台奇数台保证选举能正常进行。锁的粒度要细。锁路径按业务维度拆分比如/locks/order和/locks/pay分开而不是所有业务共用一把全局锁。锁粒度过粗并发能力直接跌成单机水平。防止锁内做RPC调用。我一直强调这点因为会话超时导致的锁提前释放绝大多数都是因为持锁时间太长。只要业务里能拆出去的操作尽量不要放在锁内。注意sessionTimeout与业务耗时的匹配。如果业务确实有慢逻辑宁可把sessionTimeout调大也不要出现“业务没跑完锁没了”的情况。但调大也意味着宕机后锁自动释放的时间更长这是一个trade-off。监听器的回调里不要做耗时操作。Watcher回调是Zookeeper客户端派发器单线程执行的回调里阻塞会影响同一个连接上的其他事件处理。代码必须用try/finally释放锁。不管原生API还是Curator都要确保释放逻辑最终执行。Curator虽然内部有状态管理但release()抛异常时锁可能未释放业务逻辑不该依赖底层兜底。我在实际开发中还有一个自己的习惯能不用分布式锁就不用分布式锁。很多时候通过唯一索引、乐观锁版本号、消息队列的幂等设计都能绕开锁不但性能更好还少维护一个中间件。但Zookeeper锁在“多进程严格互斥 需要公平排队”的场景里依然是最可靠的选择比如分布式定时任务调度、分布式事务中的资源锁定、跨机器的库存扣减。最后再分享一个实操中的小技巧。如果你在学习阶段想直观地观察锁的获取和释放过程可以配合Zookeeper的可视化工具ZooInspector查看/locks目录下节点的变化。启动一个多线程示例刷新界面你会看到临时顺序节点一个接一个地创建、排队、最终依次消失。这个画面比任何文档都直观看完之后对临时顺序节点为什么适合做分布式锁这件事会比背十遍面试题都牢靠。