
在实际的微服务改造中把一个一直运行良好的单机模块改成集群模式往往不是代码量的问题而是从思路到验证都要换一套逻辑。手头这个项目代号叫 [One Piece: Heart Operation]07 版本内部代号 Set Sail名字很容易让人联想到某部动漫但这里只是项目代号。它想表达的其实是两件事Heart Operation 指“心跳运维”Set Sail 指“让这套机制真正启航”。集群里的主控节点被团队称作 BIG MOM其他节点则是这个“海贼团”里的普通成员——这个称呼帮助大家在沟通时快速理解谁是船长谁是船员。在后续内容中我们会把一个实际工程问题讲透如何用 ZooKeeper 完成多实例自动故障转移。读者如果正打算把单机监控模块改成集群模式或者想弄清 Leader 选举在日常项目中怎么落地可以按后面的步骤操作。文章会从概念、环境、代码、验证、排错和最佳实践六个层面展开最后给出可以直接拿去评审的发布检查清单。1. 为什么把单机心跳监控改成集群心跳治理1.1 初代版本只解决了“机器活着吗”最原始的 Heart Operation 逻辑非常简单一个进程定时向被监控的机器发送探测包连续几次没有响应就标记为失联。这个机制在机器数量少、网络稳定的场景下没有问题但它有一个致命点监控进程本身是单点。一旦这个单点宕机不仅被监控对象的状态无人知晓连已经产生的失联告警都无法继续处理。换句话说监控者自己先失去了心跳。单点故障带来的体验很直接业务方反馈“为什么系统几分钟没反应”排查后才发现监控程序自己挂了。所以第一阶段改造的目标不是把探测算法做得更聪明而是先把“监控者”变成一个不会因为单机故障就整体停摆的集群。1.2 集群化之后问题从“检测”变成“决策”当监控模块从 1 个实例扩展成 3 个实例后新的问题出现了如果三个实例同时向同一批被监控对象发送探测包会产生重复告警如果同时去执行清理任务会产生重复写入。于是系统必须从这么多实例里确定一个“主节点”只有主节点才能执行写操作和任务调度其他节点作为热备等待接替。这套机制放在 Heart Operation 里就是 BIG MOM 的由来。BIG MOM 是集群里的主控节点负责汇总健康状态、下发探测任务、触发告警“海贼团”的其他成员则负责在 BIG MOM 失联时重新选举出新的主节点。到了这一阶段核心问题已经不再是“探测包怎么写”而是多个节点之间如何达成一致谁可以操作数据谁应该等待。1.3 Set Sail 版本的交付目标07 版本被命名为 Set Sail意思是集群可以从开发环境真正“启航”到测试甚至生产环境。为了方便验证这个版本定义了几个可量化的目标3 个应用实例共享同一个 ZooKeeper 集群。同一时刻只有 1 个实例成为 BIG MOMLeader。Leader 通过 ZooKeeper 临时节点上报心跳默认 15 秒不续约则视为失联。Leader 被 kill 掉之后剩余节点在几秒到十几秒内自动完成切换。所有实例都提供/status接口可以直接从外部判断当前节点是否是 Leader。这些目标不需要引入重量级框架就能实现用 ZooKeeper 的临时顺序节点加 Curator 的 LeaderSelector 就能跑通。2. 核心概念心跳、租约、Leader 与脑裂2.1 心跳Heartbeat与租约Lease心跳是节点告诉别人“我还活着”的信号。单机场景下心跳通常是一个定时任务分布式场景下心跳通常和“租约”绑定在一起。租约是一个带过期时间的权限凭证。以 ZooKeeper 为例客户端连接成功后服务端会创建一个 Session并分配一个 Session Timeout。客户端需要在这个时间内向服务端发送请求或心跳否则 Session 会被判定为过期。Session 过期后客户端在这个 Session 下创建的临时节点会被自动删除。代码里的sessionTimeoutMs(15000)就是租约时长。这个参数的含义是如果 ZooKeeper 与客户端之间超过 15 秒没有有效通信服务端会认为客户端已经死亡并删除它创建的临时节点。临时节点一旦消失其他等待中的节点就会被唤醒开始重新选举。注意不要因为临时节点会自动删除就忘记在应用层监听连接状态。ZooKeeper 的 Session 可能因为网络抖动而重建但应用进程本身并没有宕机此时如果继续执行主节点任务会造成逻辑上的“双主”。2.2 Leader 选举BIG MOM 如何产生ZooKeeper 做 Leader 选举最常见的一种方式是“临时顺序节点”多个节点在同一路径下创建临时顺序节点。序号最小的节点成为 Leader。非 Leader 节点监听前一个节点的删除事件。如果 Leader 或前一个节点过期后一个节点收到通知重新检查序号。Curator 的LeaderSelector已经封装了这个流程。使用LeaderSelector后应用不需要自己实现监听逻辑只需要在takeLeadership回调里定义“成为 Leader 之后做什么”。需要注意takeLeadership是一个阻塞方法。当它返回时Curator 会认为当前节点主动放弃 Leader 身份。因此合理的写法是在方法内阻塞直到节点失去连接或进程收到停止信号。2.3 脑裂Split-Brain为什么必须避免脑裂是指集群因为网络分区被分成多个部分而且多个部分同时认为自己的节点是 Leader。在分布式系统里脑裂会导致重复调度、重复写入、甚至数据损坏。ZooKeeper 的 ZAB 协议通过“多数派”机制避免脑裂一次选举必须得到集群中超过半数节点的同意新 Leader 才能生效。如果只有 2 个 ZooKeeper 节点网络分区后两边各 1 个谁都无法形成多数派如果只有 1 个节点整个 ZooKeeper 服务就没有任何容错能力。因此生产环境里 ZooKeeper 节点数至少要为 3 个并且最好是奇数个。在实际实验环境中我们只用一个 ZooKeeper 节点本身无法阻止脑裂。要理解脑裂的预防机制至少需要 3 个 ZooKeeper 节点。如果只是为了验证应用层选举可以用单节点但要把这一点记在心里。3. 环境准备用 ZooKeeper Curator 搭建实验集群3.1 依赖版本与工具清单下面这份清单以常见稳定版本为例落地前请根据公司的依赖基线调整。原始项目没有指定版本时优先使用本机已验证的版本不要盲目追新。组件示例版本用途JDK11 或 17运行 Spring Boot 应用Maven3.8构建项目Docker20.10快速启动 ZooKeeperZooKeeper3.8.1提供分布式协调能力Spring Boot2.7.x提供 Web 接口和依赖管理Apache Curator5.4.0封装 ZooKeeper 的选举与协调 APICurator 5.x 对应 ZooKeeper 3.5这一搭配比较成熟。如果项目中已经使用了 Spring Cloud可以参考spring-cloud-starter-zookeeper-discovery但本文为了把选举逻辑讲清楚不引入额外封装。3.2 用 Docker 启动 ZooKeeper单机实验环境可以直接使用 Docker 启动一个 ZooKeeperdocker run -d --name heart-operation-zk \ -p 2181:2181 \ -e ZOOKEEPER_CLIENT_PORT2181 \ -e ZOOKEEPER_TICK_TIME2000 \ zookeeper:3.8启动后可以检查端口是否监听docker ps如果本机装有nc可以发送四字命令检查 ZooKeeper 状态echo ruok | nc localhost 2181正常情况会返回imok。如果返回空可能是 ZooKeeper 4lw 命令白名单没有开启。可以用-e ZOOKEEPER_4LW_COMMANDS_WHITELISTruok补全。不过这个检查不是必需的应用程序能连上 ZooKeeper 就说明网络和端口没有问题。3.3 创建 Spring Boot 项目结构实验项目命名为heart-operation-07目录结构如下heart-operation-07/ ├── pom.xml └── src/main/ ├── java/com/example/heartoperation/ │ ├── HeartOperationApplication.java │ ├── config/ZookeeperConfig.java │ ├── service/LeaderElectionService.java │ └── controller/StatusController.java └── resources/application.yml这个结构没有复杂的分层因为文章的验证重点是选举机制而不是业务 CRUD。实际项目里可以把选举服务放到infrastructure或cluster包下与业务逻辑分开。4. 核心代码实现注册、选主和故障转移4.1 Maven 依赖配置pom.xml只需要三个核心依赖Web、Curator Recipes、Lombok可选。示例配置如下?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdheart-operation-07/artifactId version0.0.1-SNAPSHOT/version properties java.version11/java.version curator.version5.4.0/curator.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.apache.curator/groupId artifactIdcurator-recipes/artifactId version${curator.version}/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /projectCurator Recipes 会传递引入 ZooKeeper 客户端依赖所以不需要单独再声明org.apache.zookeeper:zookeeper。4.2 YAML 配置application.yml中定义连接参数server: port: ${SERVER_PORT:8081} spring: application: name: heart-operation-07 zookeeper: connection-string: ${ZK_CONNECTION_STRING:localhost:2181} namespace: heart-operation election-path: /election/big-mom session-timeout-ms: 15000 connection-timeout-ms: 5000这里的namespace用来隔离不同环境下的选举路径。例如开发环境使用heart-operation-dev生产环境使用heart-operation-prod。election-path是所有实例参与选举的子节点路径所有实例必须完全一致。4.3 注册 ZooKeeper 客户端ZookeeperConfig创建CuratorFramework实例并交给 Spring 管理package com.example.heartoperation.config; import org.apache.curator.RetryPolicy; import org.apache.curator.framework.CuratorFramework; import org.apache.curator.framework.CuratorFrameworkFactory; import org.apache.curator.retry.ExponentialBackoffRetry; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class ZookeeperConfig { Bean(destroyMethod close) public CuratorFramework curatorFramework( Value(${zookeeper.connection-string}) String connectionString, Value(${zookeeper.namespace}) String namespace, Value(${zookeeper.session-timeout-ms}) int sessionTimeoutMs, Value(${zookeeper.connection-timeout-ms}) int connectionTimeoutMs) { RetryPolicy retryPolicy new ExponentialBackoffRetry(1000, 3); CuratorFramework client CuratorFrameworkFactory.builder() .connectString(connectionString) .namespace(namespace) .sessionTimeoutMs(sessionTimeoutMs) .connectionTimeoutMs(connectionTimeoutMs) .retryPolicy(retryPolicy) .build(); client.start(); return client; } }destroyMethod close能保证 Spring 容器关闭时释放 ZooKeeper 连接资源。如果没有这行进程退出后临时节点不会立即删除其他节点要等 Session 超时才能接管切换时间会变长。4.4 Leader 选举服务LeaderElectionService是这篇文章的核心类。它把结构分为两个阶段初始化 Curator 客户端已经在上面的 Config 完成以及启动LeaderSelector。package com.example.heartoperation.service; import org.apache.curator.framework.CuratorFramework; import org.apache.curator.framework.recipes.leader.LeaderSelector; import org.apache.curator.framework.recipes.leader.LeaderSelectorListener; import org.apache.curator.framework.state.ConnectionState; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.concurrent.atomic.AtomicBoolean; Service public class LeaderElectionService { private static final Logger log LoggerFactory.getLogger(LeaderElectionService.class); private final CuratorFramework curatorFramework; private final String electionPath; private final String nodeId; private final LeaderSelector leaderSelector; private final AtomicBoolean isLeader new AtomicBoolean(false); public LeaderElectionService(CuratorFramework curatorFramework, Value(${zookeeper.election-path}) String electionPath, Value(${server.port}) String port) { this.curatorFramework curatorFramework; this.electionPath electionPath; this.nodeId node- port; this.leaderSelector new LeaderSelector(curatorFramework, electionPath, listener()); this.leaderSelector.autoRequeue(); } private LeaderSelectorListener listener() { return new LeaderSelectorListener() { Override public void takeLeadership(CuratorFramework client) throws Exception { isLeader.set(true); log.info({} 成为 BIG MOM开始执行主节点任务, nodeId); try { // 模拟 Leader 持续工作实际项目中可改为启动线程池执行任务 while (true) { Thread.sleep(5000); log.info({} 作为 Leader 续约心跳, nodeId); } } finally { isLeader.set(false); log.info({} 失去 Leader 身份, nodeId); } } Override public void stateChanged(CuratorFramework client, ConnectionState newState) { log.info({} 连接状态变化: {}, nodeId, newState); if (newState ConnectionState.SUSPENDED || newState ConnectionState.LOST) { isLeader.set(false); } } }; } PostConstruct public void start() { leaderSelector.start(); } PreDestroy public void close() { if (leaderSelector ! null) { leaderSelector.close(); } } public boolean isLeader() { return isLeader.get(); } }这段代码有四个关键细节autoRequeue()当前节点失去领导权后会自动重新排队参与下一次选举。没有这行节点一旦退出选举就不会再进入。takeLeadership里的while (true)Leader 必须一直阻塞在这里一旦这个方法返回Curator 会立刻认为该节点放弃了领导权。stateChanged当连接状态变为SUSPENDED或LOST时不能继续认为自己还是 Leader因此立即把标记置为 false。isLeader使用AtomicBoolean状态标记会被不同线程修改普通boolean在多线程下可见性无法保证。4.5 暴露状态检查接口为了让外部能直观看到哪一个是 Leader增加一个简单的接口package com.example.heartoperation.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; RestController public class StatusController { private final LeaderElectionService leaderElectionService; public StatusController(LeaderElectionService leaderElectionService) { this.leaderElectionService leaderElectionService; } GetMapping(/status) public MapString, Object status() { MapString, Object result new HashMap(); result.put(nodeId, System.getProperty(server.port, unknown)); result.put(isLeader, leaderElectionService.isLeader()); return result; } }这里使用server.port作为节点标识目的是在日志和接口中区分不同实例。生产环境通常使用 IP、主机名或 Pod 名。4.6 启动类package com.example.heartoperation; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class HeartOperationApplication { public static void main(String[] args) { SpringApplication.run(HeartOperationApplication.class, args); } }4.7 关键参数说明参数例子含义设置过大的影响设置过小的影响sessionTimeoutMs15000ZooKeeper 会话过期时间故障切换变慢节点失联很久才被接管网络抖动容易导致会话频繁过期触发不必要的重新选举connectionTimeoutMs5000与 ZooKeeper 建立连接的超时时间启动时等待时间变长短暂网络波动会导致连接建立失败retryPolicy1000ms / 3 次连接失败后的重试间隔与次数恢复耗时变长临时故障无法自动恢复electionPath/election/big-mom选举节点路径不同团队共用 namespace 时可能冲突无建议带业务名5. 运行验证启动三节点并观察主权切换5.1 构建并启动三个实例先执行 Maven 打包mvn clean package -DskipTests然后打开三个终端分别启动 8081、8082、8083 端口java -Dserver.port8081 -jar target/heart-operation-07-0.0.1-SNAPSHOT.jar java -Dserver.port8082 -jar target/heart-operation-07-0.0.1-SNAPSHOT.jar java -Dserver.port8083 -jar target/heart-operation-07-0.0.1-SNAPSHOT.jar如果系统属性-Dserver.port不生效可以在application.yml中把端口写成${SERVER_PORT:8081}然后通过环境变量覆盖。5.2 查看 Leader 日志与接口输出三个实例启动完成后观察日志。正常情况下只有一条日志包含“成为 BIG MOM”其他两个实例会等待。例如[8081] 成为 BIG MOM开始执行主节点任务 [8082] 连接状态变化: CONNECTED [8083] 连接状态变化: CONNECTED接着请求三个端口的状态curl http://localhost:8081/status curl http://localhost:8082/status curl http://localhost:8083/status期望结果中只有一处的isLeader为true。5.3 杀掉 Leader 并观察自动切换假设 8081 是当前的 Leader。找到它的进程 ID 并强制结束ps -ef | grep heart-operation kill -9 8081的进程ID然后每隔 1 秒轮询剩余实例的状态watch -n 1 curl -s http://localhost:8082/status echo curl -s http://localhost:8083/status由于默认 Session Timeout 是 15 秒最迟约 15 秒后8082 或 8083 的日志会出现“成为 BIG MOM”状态接口中的isLeader也会变为true。5.4 验证结果分析这次切换证明了三件事ZooKeeper 能自动识别 Leader 会话过期。Curator 的LeaderSelector能在 Leader 失联后唤醒剩余节点。应用在stateChanged中把isLeader置为 false 的写法有效没有出现旧 Leader 标记残留。如果切换时间远大于 15 秒优先检查sessionTimeoutMs是否被系统属性覆盖以及进程退出时临时节点是否没有及时删除。生产环境不要直接使用kill -9来测试故障转移至少要先用kill触发优雅停机再逐步升级到强制终止。这样能分别验证“正常下线”和“异常宕机”两条路径。6. 常见问题排查6.1 ZooKeeperSessionExpiredException现象应用日志周期性出现org.apache.zookeeper.KeeperException$SessionExpiredException并且 Leader 频繁变更。可能原因客户端与 ZooKeeper 之间的网络不稳定。JVM 触发长时间 Full GC导致客户端线程无法正常发送心跳。Session Timeout 设置过短例如只有 3000ms。检查方式查看 ZooKeeper 服务端日志确认是否出现大段时间空白。用jstat -gcutil观察 GC 停顿。查看是否有防火墙或负载均衡设备在空闲时断连。解决方案把sessionTimeoutMs调整到 10000 到 20000 之间。在应用启动参数中加入-XX:UseG1GC减少长时间停顿。收到LOST状态后主动放弃 Leader 身份并依赖 Curator 重连后重新参与选举。6.2 多个节点同时认为自己是 Leader现象调用多个实例的/status接口返回isLeader: true的不止一个。可能原因网络分区导致两个节点分别成为 Leader。takeLeadership返回后isLeader没有在finally中置为 false。应用自己维护了额外的“内存标记”与 Curator 的回调状态不同步。检查方式观察应用日志中“成为 BIG MOM”和“失去 Leader 身份”是否成对出现。查看 ZooKeeper 选举路径下的节点数量。如果使用单节点 ZooKeeper暂时无法模拟多数派保护检查是否只有单节点运行。解决方案把isLeader的更新放在stateChanged和takeLeadership的finally中。不要在takeLeadership之外的线程里自行将isLeader置为 true。生产环境使用至少 3 个 ZooKeeper 节点避免网络分区时出现双主。6.3 节点退出后其他节点迟迟没有接管现象Leader 进程已经被 kill但剩余节点等待了几分钟才完成切换甚至一直没有切换。可能原因Curator 客户端没有正确关闭临时节点仍然由旧 Session 持有直到 Session 超时。进程退出不是正常的 JVM 退出而是被强杀且 Session Timeout 设置过大。选举路径不同导致新节点监听的是另一条路径。检查方式使用 ZooKeeper 客户端查看选举路径下是否还有旧节点的临时子节点。确认三个实例的zookeeper.election-path完全一致。查看是否配置了PreDestroy关闭逻辑。解决方案在PreDestroy中调用leaderSelector.close()。容器环境配置 TermGracePeriod让 JVM 有足够时间优雅退出。不要设置过大的sessionTimeoutMs例如超过 60 秒。6.4 网络分区下的脑裂问题现象ZooKeeper 集群被网络分区后两边同时对外提供服务且各自选出不同的 Leader。可能原因ZooKeeper 集群节点数少于 3或只有 2 个节点。节点间的网络分区导致半数以下节点组成了“假集群”。检查方式查看每个 ZooKeeper 节点上的存活 follower 数量。确认 ZooKeeper 集群是否满足多数派。解决方案至少部署 3 个 ZooKeeper 节点并部署在不同机架或可用区。设置 ZooKeeper 的quorum配置确保 Leader 需要多数派确认。应用层遇到SUSPENDED状态时必须停止写操作不能等到LOST才让步。7. 生产环境最佳实践7.1 不要用 IP 或固定机器名区分 LeaderBIG MOM 是一个逻辑身份不是某台物理机器。不要在业务代码里写死“192.168.1.10 才是 Leader”。正确做法是通过选举库查询当前 Leader或者通过/status接口获取再由注册中心或负载均衡对外暴露。7.2 监听 Leader 变更而不是反复轮询Curator 的LeaderSelectorListener已经提供了“成为 Leader”和“连接状态变化”的回调。不要自己写一个定时器每 5 秒查询一次 ZooKeeper那样既增加压力又无法做到事件级响应。7.3 合理设置 Session TimeoutSession Timeout 是一个需要权衡的参数过大节点宕机后故障转移要等待很长时间。过小网络一抖动正常节点会频繁掉线并触发选举。推荐从 10000ms 开始压测观察 ZooKeeper 在 GC 停顿和网络闪断下的表现再决定是否调整。7.4 配置外置化和命名空间隔离zookeeper.connection-string、namespace、election-path这些配置不要硬编码进代码。使用环境变量或配置中心管理。不同环境使用不同的 namespace避免开发环境连接测试环境 ZooKeeper 造成“串租户”。7.5 发布前检查清单下面这份清单可以直接用于 Code Review 和发布检查检查项检查内容是否通过ZooKeeper 集群节点数是否为 3且状态正常连接配置连接串、namespace、选举路径在各实例间一致超时参数sessionTimeoutMs 符合故障恢复目标优雅停机PreDestroy 或 shutdown hook 已配置状态标记连接 LOST 后 isLeader 能立即置为 false日志选举变更有清晰日志包含 nodeId 和原因监控对 ZooKeeper 会话数、Leader 切换次数配置告警权限生产环境 ZooKeeper 开启 ACL避免未授权客户端写入回滚方案如果新选举机制异常能快速回退到旧版本或旧集群8. 下一步扩展方向8.1 使用 etcd 或 Consul 替换 ZooKeeper如果团队已经在使用 etcd 或 Consul可以不再引入 ZooKeeper。etcd 的Lease和Campaign接口能实现类似选举Consul 的session与lock也能完成选主。选型时优先考虑团队已有基础设施。8.2 结合 Kubernetes Lease API如果应用部署在 Kubernetes 中可以直接使用coordination.k8s.io/v1的 Lease 资源做轻量级选主。它不需要额外部署 ZooKeeperKubernetes 的 API Server 本身就是高可用的。适合不需要复杂分布式锁、只要求“单实例执行定时任务”的场景。8.3 从 Leader Election 走向分布式调度当只剩 Leader 能执行任务后可以继续扩展为分布式任务调度平台。例如在 Leader 上加载任务清单、分发到 worker 节点、记录执行状态、同时引入分布式锁防止重复执行。这些都是 Heart Operation 后续版本可能做的事情。建议读者先把这次实验做扎实亲手启动三个实例杀掉 Leader观察日志和时间戳。只有亲眼看到 Session 过期触发重新选举的全过程才能真正理解心跳、租约和故障转移这三个概念在系统中是如何协作的。把这个最小闭环跑通之后再去思考生产环境的监控、权限和回滚方案会容易得多。