多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Zookeeper客户端开发实战:从Java API入门到分布式锁与Watcher机制

Zookeeper客户端开发实战:从Java API入门到分布式锁与Watcher机制 我最早接触Zookeeper是在一次Kafka集群扩容事故里那会儿消费端莫名其妙全部断开排查到最后发现是Zookeeper会话超时参数没配好。从那以后我就意识到搞大数据的人可以不会写Zookeeper源码但绝对不能不会写Zookeeper客户端。这篇博文就围绕“Zookeeper客户端开发”这个主题把Java API在真实大数据项目里的用法从入门到实战完整过一遍适合刚接触ZK的Java开发也适合那些已经在Hadoop、Kafka项目里用了ZK但一直没搞懂客户端细节的同学。文章里所有代码都是我在实际项目里跑过的结合Hadoop和Zookeeper整合、分布式环境搭建这些真实场景来讲。我尽量把每个API背后的设计逻辑讲明白而不只是贴一段能跑的通代码。毕竟只在本地连一下ZK节点和在生产环境处理千万级临时节点的会话管理、监听恢复、锁竞争完全是两码事。1. 项目定位与整体设计思路1.1 为什么大数据项目离不开Zookeeper先聊一个很多人会问的问题文件系统有HDFS协调任务有Yarn数据库有HBase为什么还需要一个Zookeeper我的理解是上面这些组件解决的是“数据存哪里、计算怎么跑”的问题但分布式系统里还有一个更基础的问题——多个进程之间如何达成共识。举个最简单的例子HDFS的NameNode是主备部署的两个节点都要抢Active状态到底谁说了算如果靠数据库做选主数据库本身又是单点。ZK在这里就是那个裁判用临时节点加Watcher机制让备节点能实时感知主节点是否存活。同理Kafka的Controller选举、HBase的HMaster选举都是ZK在背后支撑。所以Zookeeper的定位从来不是存储系统而是一个分布式协调服务。它提供的是命名服务、配置管理、分布式锁、集群成员管理等能力。数据是存在内存里的读写快但容量小这也决定了ZK的数据模型必须设计成类似文件系统的树形结构节点不能存大块数据默认上限是1M。在大数据项目里ZK的典型应用场景大概是这几个集群选主Hadoop HA、Kafka Controller、HBase HMaster都会用到配置集中管理把公共配置写到ZK节点客户端动态监听配置变更实时生效分布式锁用临时顺序节点实现公平锁解决多进程并发写同一资源的冲突服务注册与发现服务启动时注册临时节点下线自动删除这几个场景里客户端开发的核心就两件事操作节点和监听变化。理解了这两点ZK的Java API基本就掌握了一大半。1.2 原生Java API还是Curator到底怎么选Zookeeper官方提供了Java客户端库也就是zookeeper这个artifact下的一系列API。这个原生API功能完整但用起来比较“裸”很多细节需要自己处理比如Watcher只触发一次触发后要重新注册连接断开时抛ConnectionLossException需要重试没有提供锁、Leader选举这些高层封装后来Apache Curator出现了它把上面这些脏活累活都封装好了提供了CuratorFramework、Recipe分布式锁、Leader选举、缓存等高级特性。网上很多教程也推荐直接用Curator。但我的建议是如果你是初学者先老老实实把原生API学明白。原因很简单Curator内部也是调用原生API如果你不懂Watcher底层是怎么实现的用Curator的NodeCache或LeaderSelector时遇到问题会很懵因为你不清楚它到底在哪些节点注册了监听监听失效后发生了什么。我自己带项目组时要求新人必须能手写原生API的节点CRUD再说Curator。还有一个现实因素是很多公司线上ZK的版本是3.4.x或者3.5.x而Curator对不同ZK版本有兼容性要求。用原生API就没有这个烦恼只要依赖的版本和集群版本做好区分就行。这篇文章后续的代码都以原生Java API为主在最后一章适当提一下生产环境如何迁移到Curator。这样既能理解底层原理又知道实际工程该用什么。2. 环境准备与客户端依赖引入2.1 JDK、Maven依赖和版本怎么选先说环境。我本地用的JDK是1.8这和项目里大部分大数据组件的版本是匹配的。Zookeeper客户端本身对Java版本要求不高3.5.x版本的客户端在JDK8上跑得很稳但如果你用的是ZK 3.6及以上建议至少用JDK8u231以后的版本避免某些TLS相关的兼容问题。Maven项目里引入依赖很简单dependency groupIdorg.apache.zookeeper/groupId artifactIdzookeeper/artifactId version3.5.9/version /dependency有个非常容易踩的坑是zookeeper这个依赖会传递引入log4j、slf4j等日志组件如果你的项目里已经用了logback或者其他日志实现可能会出现冲突。建议在引入时做一下exclusiondependency groupIdorg.apache.zookeeper/groupId artifactIdzookeeper/artifactId version3.5.9/version exclusions exclusion groupIdorg.slf4j/groupId artifactIdslf4j-log4j12/artifactId /exclusion exclusion groupIdlog4j/groupId artifactIdlog4j/artifactId /exclusion /exclusions /dependency另外如果你用了Curator还需要单独引Curator的依赖这里先不展开。关于ZK服务端的版本选择我建议测试环境用3.5.x生产环境看社区生态。3.4.x已经逐渐退出历史舞台了3.5.x引入了SSL支持和容器节点3.6.x增加了更多监控指标。新项目直接用3.5.9或3.6.x都行但要注意客户端版本和服务端版本不要差以太远否则可能出现协议兼容问题。2.2 本地快速跑一个单机ZK服务端写客户端之前本地至少要有一个ZK服务端。生产环境是分布式集群部署初学者先在本地跑单机就够了。下载ZK安装包后修改conf/zoo.cfgtickTime2000 dataDir/tmp/zookeeper/data clientPort2181 initLimit10 syncLimit5然后启动bin/zkServer.sh start启动后用bin/zkCli.sh -server 127.0.0.1:2181连一下能进到ZK命令行界面就算成功。我习惯先用zkCli确认服务端OK再开始写Java客户端这样可以避免两边同时出问题都不知道该排查谁。你可能会问tickTime、initLimit、syncLimit这些参数什么意思简单说tickTimeZK的最小时间单元默认2000毫秒其他时间参数都基于它计算initLimitFollower启动时能容忍的同步最长等待时间单位是ticksyncLimitFollower和Leader之间请求响应的最长超时时间dataDir快照日志存储路径ZNode数据会定期落盘这些参数在单机模式下影响不大但部署集群时必须认真调。比如网络状况差的机房initLimit如果设得太小Follower启动时可能一直初始化失败表现就是节点起不来。我帮别人排查过一次这个问题最后就是把这个参数从5调到了10。2.3 最小可运行实例从连接ZK到创建节点环境准备好以后写一个最小可运行的Java类。这个类做的事情很简单连接ZK、创建根路径下的一个节点、读取数据、关闭连接。但麻雀虽小五脏俱全它涵盖了客户端开发的完整链路。import org.apache.zookeeper.*; import org.apache.zookeeper.data.Stat; import java.util.concurrent.CountDownLatch; public class ZkQuickStart { // 连接字符串格式host:port多个实例用逗号分隔 private static final String ZK_ADDRESS 127.0.0.1:2181; private static final int SESSION_TIMEOUT 5000; public static void main(String[] args) throws Exception { CountDownLatch connectedLatch new CountDownLatch(1); // 创建ZooKeeper客户端建立会话 ZooKeeper zooKeeper new ZooKeeper(ZK_ADDRESS, SESSION_TIMEOUT, watchedEvent - { if (watchedEvent.getState() Watcher.Event.KeeperState.SyncConnected) { // 会话建立成功 connectedLatch.countDown(); } }); // 等待连接建立 connectedLatch.await(); // 创建节点路径、数据、ACL、创建模式 String path zooKeeper.create(/myApp, hello zk.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); System.out.println(create node: path); // 读取节点数据 Stat stat new Stat(); byte[] data zooKeeper.getData(/myApp, false, stat); System.out.println(read data: new String(data)); System.out.println(version: stat.getVersion()); // 关闭连接 zooKeeper.close(); } }这段代码看着简单但有几个细节必须说明。ZooKeeper构造函数里的Watcher参数传的是默认Watcher它主要负责会话状态变更事件比如SyncConnected、Expired等。而后面我们讲getData、exists这些API里传入的Watcher是节点事件监听器两者不是一回事新手很容易搞混。OPEN_ACL_UNSAFE表示这个节点不做权限控制所有人都能读写。生产环境一般不这么干但这个ACL在开发阶段很方便。CreateMode.PERSISTENT表示持久节点断开连接后节点还在。还有EPHEMERAL临时节点、PERSISTENT_SEQUENTIAL持久顺序节点、EPHEMERAL_SEQUENTIAL临时顺序节点后面展开讲。这段代码跑通以后恭喜你Zookeeper客户端开发的入门关卡已经过了。下面进入真正的核心API实战。3. 核心API实战节点操作、监听机制与异步回调3.1 节点增删改查每个参数都要知道为什么ZooKeeper的节点操作和文件系统很像但细节上有不少差异。我逐个说。创建节点create方法有多个重载核心参数是路径、数据、ACL和创建模式。// 创建一个临时节点 String path zooKeeper.create(/myApp/tmp, temp.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);注意一点ZK的节点路径不支持递归创建也就是说/myApp不存在时直接用create(/myApp/tmp)会抛NoNodeException。所以要么从根路径一级一级创建要么在3.5版本用create的withParents特性Curator的creatingParentContainersIfNeeded。还有一个常见需求是同时创建多个层级节点比如同时创建/app/config/db。原生API不能一次性完成需要写循环。Curator里通过creatingParentsIfNeeded()一行搞定。这也是为什么生产环境用Curator会舒服很多。读取数据getData方法返回节点的数据字节数组同时通过Stat对象带回各种元信息包括版本号、事务ID、子节点数等。Stat stat new Stat(); byte[] data zooKeeper.getData(/myApp/appName, false, stat);这里的boolean参数是watch表示是否注册Watcher。我一般习惯传false然后用显式的Watcher因为直接传true等于注册了一个很难精确管理的默认Watcher在复杂监听场景下容易乱。更新数据Stat updateStat zooKeeper.setData(/myApp/appName, newVersion.getBytes(), stat.getVersion());第三个参数是期望的版本号用于乐观锁控制。如果服务端版本号和本地版本不一致会抛BadVersionException。这里的设计逻辑很值得琢磨ZK为什么用版本号而不是时间戳来做并发控制因为在分布式环境下服务器时间本身不可靠而版本号是单调递增且在集群内一致同步的用它判断冲突才是最准确的。如果你不关心并发控制这里传-1表示强制更新但生产环境不推荐。删除节点zooKeeper.delete(/myApp/tmp, -1);和setData一样第二个参数是版本号-1表示不校验版本。删除还有个大坑节点有子节点时不能直接删除。你需要先递归删除所有子节点再删父节点。原生API没有提供递归删除方法得自己写。这也是Curator里deletingChildrenIfNeeded()存在的原因。检查节点是否存在Stat existsStat zooKeeper.exists(/myApp, true);如果节点存在返回Stat对象不存在返回null。注意这个API和getData的区别getData在节点不存在时会抛KeeperException.NoNodeException而exists不抛异常。所以判断节点是否存在用exists更合适不要用getData去try catch。我把节点操作的核心要点整理成一张表方便对照记忆操作异常情况版本控制是否需要递归处理create节点已存在抛NodeExistsException不涉及父目录需先存在getData节点不存在抛NoNodeException不涉及不涉及setData节点不存在抛NoNodeException支持版本校验不涉及delete节点不存在抛NoNodeException支持版本校验有子节点则删除失败exists不会抛异常不涉及不涉及3.2 Watcher监听机制一次性触发这件事必须搞明白Watcher是ZK客户端开发里最容易出问题的点没有之一。先看一个正确的监听注册。假设我们要监听/myApp/appName这个节点的数据变化Stat stat zooKeeper.exists(/myApp/appName, event - { if (event.getType() Watcher.Event.EventType.NodeDataChanged) { System.out.println(data changed!); // 重新读取数据 try { byte[] data zooKeeper.getData(/myApp/appName, false, null); System.out.println(new data: new String(data)); } catch (Exception e) { e.printStackTrace(); } } });这里有个很隐蔽的坑Watcher只触发一次。也就是说上面注册的Watcher在第一次NodeDataChanged事件触发以后就自动失效了。如果你想持续监听节点变化必须在回调里重新注册Watcher也就是再次调用exists或getData并传入同样的Watcher。看下面这个改进版代码public void watchNode(String path, ZooKeeper zk) throws Exception { Stat stat zk.exists(path, event - { if (event.getType() Watcher.Event.EventType.NodeDataChanged) { System.out.println(data changed); try { byte[] data zk.getData(path, false, null); System.out.println(new data: new String(data)); // 重新注册监听否则只触发一次 watchNode(path, zk); } catch (Exception e) { e.printStackTrace(); } } }); }为什么ZK要这样设计我想了很久最后在官方文档里找到答案ZK的Watcher是基于客户端与服务端之间的会话通道实现的。如果Watcher持续有效客户端需要不断维护Watcher注册表服务端也要在每个节点上维护Watcher列表这会消耗大量内存。一次性触发机制让服务端能够及时清理无用的Watcher同时也迫使业务方思考“监听后下一步做什么”。但在工程实践里自己写重新注册逻辑很容易漏比如回调线程抛了异常Watcher就没法重新注册业务会静默失效。所以生产环境一般推荐使用Curator的NodeCache或PathChildrenCache它们内部帮你处理了重注册的问题。不过理解原生机制仍然是基本功——只有你知道Watcher是一次性的才能理解为什么Curator要设计cache这种模式。除了数据变化Watcher还能监听子节点变化和节点删除// 监听子节点变化 ListString children zk.getChildren(/myApp, event - { if (event.getType() Watcher.Event.EventType.NodeChildrenChanged) { System.out.println(children changed!); // 重新获取子节点列表 } }); // 监听节点删除 Stat stat zk.exists(/myApp/appName, event - { if (event.getType() Watcher.Event.EventType.NodeDeleted) { System.out.println(node deleted!); } });还有一类Watcher关注的是会话状态变化比如连接断开、会话过期。这类Watcher在创建ZooKeeper对象时传入适合做重连逻辑。会话过期时ZK会清除该会话创建的所有临时节点这个特性是分布式锁能自动释放的基础。3.3 异步回调不要让网络延迟卡住你的主线程前面所有示例用的都是同步API比如create调用后会阻塞直到服务端返回结果。这在数据量小的场景下问题不大但如果你的应用需要高频操作ZK比如每秒创建几百个临时节点同步调用会让主线程大量阻塞在等待网络上。ZK提供了完整的异步API核心思路是传入一个AsyncCallback回调对象操作完成后异步通知zooKeeper.create(/async/node, data.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT, (rc, path, ctx, name) - { if (rc KeeperException.Code.OK.intValue()) { System.out.println(异步创建成功: name); } else { System.out.println(异步创建失败: rc); } }, context data);回调参数里的rc是返回码path是操作路径ctx是你传入的上下文对象name是实际创建的节点路径顺序节点时会带上序号。异步API用了KeeperException.Code里的状态码OK是0NONODE是-101NODEEXISTS是-110。排查问题时看到这些负数不要慌对照KeeperException.Code枚举就能知道原因。异步回调的执行线程是ZooKeeper内部的一个线程池并不是你的业务线程。所以回调里如果有耗时的业务操作建议用你自己的线程池去处理避免阻塞ZK的IO线程ExecutorService executor Executors.newFixedThreadPool(4); zooKeeper.getData(/async/node, false, (rc, path, ctx, data, stat) - { executor.submit(() - { System.out.println(回调里处理业务 new String(data)); }); }, null);异步API在客户端注册Callback时如果回调实现里又要调用ZK API注意不能再用同步版本的getData否则会抢占同一个IO线程池可能互相等待造成死锁。这个坑我踩过一次当时整个ZK客户端线程全部卡住线上应用看起来像挂了一样。建议回调内再操作ZK时要么也用异步API要么把操作丢到一个独立的线程池里。3.4 一键搞定客户端连接对象初始化写了好几次new ZooKeeper(...)之后我建议你封装一个客户端工具类把连接管理、重连状态监听统一处理。这里给一个基本的封装思路public class ZkClientManager { private ZooKeeper zk; private final CountDownLatch connectedSignal new CountDownLatch(1); public ZkClientManager(String address, int sessionTimeout) throws Exception { this.zk new ZooKeeper(address, sessionTimeout, event - { if (event.getState() Watcher.Event.KeeperState.SyncConnected) { connectedSignal.countDown(); } else if (event.getState() Watcher.Event.KeeperState.Expired) { // 会话过期临时节点全部被清理需要重新建立会话 try { this.zk.close(); } catch (Exception e) { // ignore } this.zk new ZooKeeper(address, sessionTimeout, event2 - { System.out.println(重新建立会话); }); } }); connectedSignal.await(); } public ZooKeeper getZk() { return zk; } public void close() throws InterruptedException { zk.close(); } }注意会话过期和连接断开的区别。连接断开Disconnected只是网络暂时不可通会话还在服务端保留重连后可以继续用。但会话过期Expired意味着服务端已经销毁了会话必须重新创建ZooKeeper实例。很多人在写重连逻辑时没区分这两者导致会话过期后所有临时节点的监听全部失效业务静默出现故障。4. 分布式环境下的高级实战锁、会话与大数据整合4.1 用临时顺序节点实现分布式锁先讲一个日常开发中最常遇到的场景多个应用实例同时处理同一批订单需要保证只有一个实例能拿到处理权。这时候用ZK实现分布式锁比用数据库锁更合适因为它天然支持多进程互斥且客户端可以自动释放。ZK实现分布式锁的经典方案是临时顺序节点 Watcher监听前一个节点。核心逻辑是在/lock路径下创建临时顺序节点比如/lock/lock_000000001获取/lock下所有子节点如果自己创建的节点是所有子节点中最小的就认为自己拿到了锁如果没有拿到锁监听比自己的序号小一位的那个节点等待它删除前一个节点删除后重新检查自己是否是最小节点为什么用临时顺序节点临时节点保证客户端会话断开后自动删除避免锁永远不会释放顺序节点让竞争节点按顺序排队每个节点只需要监听前一个节点不会出现“惊群效应”。代码核心部分public class ZkDistributedLock { private final ZooKeeper zk; private final String lockPath; private String currentNodePath; private String waitPath; public ZkDistributedLock(ZooKeeper zk, String lockPath) { this.zk zk; this.lockPath lockPath; } public void lock() throws Exception { // 创建临时顺序节点 currentNodePath zk.create(lockPath /lock_, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); // 获取所有子节点并排序 ListString children zk.getChildren(lockPath, false); Collections.sort(children); int currentIndex children.indexOf(currentNodePath.substring(lockPath.length() 1)); if (currentIndex 0) { // 自己是最小节点拿到锁 System.out.println(get lock: currentNodePath); return; } // 监听前一个节点 waitPath lockPath / children.get(currentIndex - 1); CountDownLatch latch new CountDownLatch(1); Stat stat zk.exists(waitPath, event - { if (event.getType() Watcher.Event.EventType.NodeDeleted) { latch.countDown(); } }); if (stat ! null) { // 前一个节点存在等待它删除 latch.await(); } // 前一个节点已删除重新获取锁 System.out.println(get lock after wait: currentNodePath); } public void unlock() throws Exception { zk.delete(currentNodePath, -1); zk.close(); } }这个实现有两个细节需要强调。第一exists判断前一个节点时如果返回null说明前一个节点刚好被删除这时候不需要等待直接自认为拿到锁。第二创建顺序节点后子节点列表里既有自己也有其他客户端创建的节点排序后通过indexOf就能确定自己的位置。标准库的Collections.sort对字符串排序时lock_10会排在lock_9前面但因为创建顺序节点时ZK保证序号是递增补零的如lock_0000000010所以排序结果没问题。分布式锁如果只做互斥用Redis也可以。但ZK锁的优势在于不用设置过期时间来防死锁临时节点天然处理了客户端宕机的情况。代价是性能不如Redis快每加一次锁至少要写节点、读节点、监听节点三次网络操作。所以在并发量非常高的场景下我会优先考虑Redis在需要绝对可靠互斥的场景比如选主ZK更稳妥。4.2 会话超时、临时节点和心跳三者的关系必须理顺ZK客户端与服务端的连接是通过Session来维护的。Session有一个超时时间由客户端在构造ZooKeeper对象时传入例如new ZooKeeper(zkAddress, 5000, watcher);这里的5000毫秒就是sessionTimeout。客户端和服务端会协商一个最终的有效超时时间范围在服务端配置的minSessionTimeout和maxSessionTimeout之间。如果你的服务端没配这两个参数默认是tickTime的2倍和20倍也就是4秒到40秒默认tickTime2000时。为什么会话超时时间这么重要因为它决定了ZK容忍客户端暂时失联的最大时间会话超时间内客户端和服务端断了重连回来后会话仍然有效临时节点保留超过超时时间没心跳服务端判定会话过期销毁该会话下所有临时节点会话过期后客户端再次接入收到的是SessionExpiredException这个机制用在分布式锁里非常好用如果拿锁的进程突然宕机了它的临时节点会在会话超时后被自动清理锁自动释放。但你要注意“自动释放”并不是立刻的中间有最多一个sessionTimeout的延迟。如果你的业务对锁释放延迟很敏感就要把sessionTimeout配小一点比如3000毫秒。反过来sessionTimeout也不能太小。如果网络经常抖动会话还没恢复就超时过频会导致临时节点频繁被创建和删除对ZK集群压力很大。我见过一个极端案例有人把sessionTimeout配成1000毫秒然后网络间歇性故障ZK集群每秒都在处理大量会话创建和临时节点清理CPU直接打满。生产环境的经验值是sessionTimeout设置在3000到10000毫秒之间根据网络状况和业务对锁延迟的容忍度来选。网络好、对锁并发敏感的项目用5000左右网络一般、业务容忍度高的用10000也行。ZK客户端还有一个心跳机制值得了解。客户端以每tickTime/3的间隔发送心跳实际是Ping请求服务端在tickTime内没有收到心跳就认为连接可能断了。心跳是自动发的不需要业务代码干预但如果你在Java里看到很多Ping请求不要以为是异常那是正常的保活行为。4.3 Hadoop和Zookeeper整合实战到底整合了个啥很多人学ZK的时候听到“Hadoop和Zookeeper整合”就觉得是不是要把ZK接到HDFS存储上其实完全不是。ZK在Hadoop生态里主要做HA和元数据协调。以HDFS为例NameNode有Active和Standby两个节点。两个节点都往ZK注册一个临时节点表示自己是Active但ZK的机制决定了同一路径下只能有一个节点创建成功。抢到创建的节点就是Active另一个自动成为Standby。Active节点异常宕机后临时节点过期消失Standby通过Watcher感知到变化立刻尝试创建同一个节点上位。这个流程里涉及的东西不多客户端要做的事也很集中HDFS启动时NameNode调用ZK API创建临时节点监听该节点的存在性Active节点的临时节点消失后Standby竞争创建Kafka也是类似在ZK里注册broker信息Controller选举同样靠临时节点实现。你在Kafka日志里看到的/brokers/ids/0其实就是ZK上的节点路径。实际操作层面以Hadoop 3.x为例core-site.xml里有几个关键配置property nameha.zookeeper.quorum/name valuezk1:2181,zk2:2181,zk3:2181/value /property这个配置指定了ZK集群地址。然后hdfs-site.xml里配置名称服务的ID和NameNode的ID。配置完以后HDFS内部自己就会用ZK客户端库去抢Active节点完全不用业务代码介入。有人问既然Hadoop内部封装好了我们还需要学ZK客户端吗当然需要。你在排查HDFS的ActiveNameNode切换异常时如果不懂ZK的临时节点和会话机制根本不知道从哪下手。我在生产环境遇到过一个问题NameNode节点本身没挂但ZK会话超时了临时节点被清掉导致HA切换。最后检查发现是ZK集群有节点GC导致整个集群抖动会话集体过期。如果看不懂客户端日志里的Session expired这个问题会排查得非常痛苦。5. 常见问题与排查技巧实录5.1 异常速查表看到报错知道该怎么办ZK客户端开发中最常见的问题我整理成一张表按频率排序如果你在生产里碰到类似报错直接对号入座。异常出现场景根本原因解决方向ConnectionLossException任意操作客户端与服务端连接断开了检查网络、查看ZK集群状态、重试操作SessionExpiredException任意操作会话已过期临时节点已被清理重新创建ZooKeeper实例重新注册NoNodeExceptioncreate、getData、delete操作的节点不存在检查路径父节点是否已创建NodeExistsExceptioncreate节点已存在先exists判断再创建或容忍该异常BadVersionExceptionsetData、delete传入的版本号和服务端不一致重新读取最新Stat用最新版本号重试KeeperException.ConnectionClosedException任意操作ZooKeeper对象已经close检查资源生命周期避免用已关闭的客户端其中ConnectionLossException是最常见的因为它可能在网络瞬断、ZK节点宕机、GC停顿等五花八门的情况下出现。处理它的思路是重试但要注意不能无限重试。我一般建议最多重试3次每次间隔指数退避比如100ms、200ms、400ms。如果重试3次还失败就抛错让上游感知而不是闷头重试把线程占死。SessionExpiredException则不能简单重试。它代表会话已经没了临时节点已经全部被清理如果你用原来的ZooKeeper实例继续操作会一直失败。正确的做法是重新new一个ZooKeeper再用新的实例去做业务操作。5.2 生产环境客户端连接的三个配置细节除了异常处理启动生产环境的ZK客户端时有几个配置值得专门说一下。连接字符串的顺序连接字符串可以写多个ZK节点比如String address 192.168.1.10:2181,192.168.1.11:2181,192.168.1.12:2181;客户端会按顺序尝试连接这些地址。所以建议把离客户端最近的节点放在前面可以减少连接耗时。如果第一个节点挂了会自动尝试下一个。最大连接重试次数如果你用原生API重连逻辑得自己写。ZooKeeper对象内部对连接中断的处理是自动尝试重连直到会话超时。所以你的业务代码需要注意一次操作失败后给客户端一点时间恢复连接再重试不要死循环调用。如果你用CuratorRetryPolicy可以配置得更精细。比如RetryPolicy retryPolicy new ExponentialBackoffRetry(1000, 3, 5000);表示初始等待1秒最多重试3次最大等待5秒。这个策略的好处是避免了同步重试带来的请求风暴。watch数量过多时的性能问题如果你在业务代码里对大量路径注册了Watcher要特别注意ZK的watch机制是有内存开销的。每注册一个Watcher客户端和服务端都要维护对应的引用。当Watcher数量超过几万个时客户端的内存会明显上涨事件处理也会变慢。我的建议是不要让每个业务请求都动态注册Watcher尽量把监听注册和业务逻辑解耦使用公共路径的监听或者用Curator的cache机制批量管理。比如在配置中心场景所有配置项可以聚合到一个大路径下监听父路径的NodeChildrenChanged事件而不是对每个配置节点都注册Watcher。5.3 原生API和Curator的生产选型经验到这儿原生API基本讲完了。生产环境我实际上大部分项目用的是Curator原因不是原生API不好而是Curator把很多生产必备的功能都封装好了。对比一下特性原生APICuratorWatcher重注册自己管理容易漏NodeCache/PathChildrenCache自动处理递归创建/删除节点需要自己写循环creatingParentsIfNeeded / deletingChildrenIfNeeded分布式锁手写逻辑InterProcessMutexLeader选举手写逻辑LeaderSelector重试策略无内置多种RetryPolicy依赖复杂度低引入较多依赖如果是简单的配置读取原生API就够用。如果是分布式锁、Leader选举、服务发现这类高级场景建议直接上Curator。不过有一点要提醒Curator 4.x版本对应ZK 3.5.xCurator 5.x对应ZK 3.6.x。选版本时一定要先确认你连接的ZK集群版本再做对应选择。我吃过一次亏用的Curator 5.1和线上的ZK 3.4.6通信老版本ZK不支持新协议的某些特性客户端日志里一堆警告。5.4 我在实际项目中的踩坑总结最后分享几个只有实操才会遇到的细节问题希望能帮你避开。第一个坑是误用已关闭的ZooKeeper实例。我们的应用里ZK客户端是单例的但有一次重构时一段代码在应用关闭时调用了zk.close()但另外一条线程还在用同一个实例创建节点结果疯狂抛ConnectionClosedException。这个问题排查了很久最后是加了一个zk.getState()判断if (zk.getState() ZooKeeper.States.CLOSED) { // 重新初始化客户端 }第二个坑是分布式锁忘记释放临时节点。我见过有同事写分布式锁锁的业务逻辑抛异常后没有走finally块调用unlock()导致节点一直存在其他线程永远拿不到锁。后来我们在代码里强制要求lock和unlock必须配对出现并且unlock放到finally块中。使用临时节点的好处是即使忘记删除会话断开以后节点也会消失但问题在于会话可能还连着节点会一直占着。第三个坑是getChildren的Watcher注册在回调里丢失。这里特别提醒一下getChildren(path, watcher)虽然也能注册Watcher监听子节点变化但这个Watcher同样是一次性的。你在回调里再次调用getChildren时需要重新传入Watcher否则后续变化感知不到。这个逻辑我一开始没意识到导致上线的配置动态更新功能过了一天就完全不生效了。第四个坑是数据序列化问题。ZK存的是字节数组如果直接用String.getBytes()存中文取出来也要用相同字符集解码。项目里最好统一用UTF-8并且把序列化和反序列化的逻辑封装在同一个工具类里避免散落在各业务代码中出现Charset混乱。我个人在实际操作中最深的体会是ZK客户端代码本身不难难的是对分布式协调模型的理解。你写的每一行节点操作背后都对应着ZK集群内部的一次原子写入你注册的每一个Watcher都代表着客户端和服务端之间的一次约定。生产里的各种诡异故障十有八九是没处理好会话、临时节点和Watcher这三者的边界。把这几个概念吃透ZK的实战路基本上就通了。
返回列表