ZooKeeper 容灾切换免重启实践:域名化配置与客户端自动迁移
一次 ZK 集群容灾切换演练,暴露出客户端"IP 固化 + 一次性注册"两个隐患。本文记录我们把
biz-cache-client改造为支持域名容灾切换的完整过程:从对 ZooKeeper 3.4.14 / Curator 4.1.0 源码的逐条事实核验,到"被动 / 主动 / 自愈"三条路径的架构设计,再到关键实现的取舍与踩坑。改造零 API 变化、零必填配置,使用方只需把zkServerAddr从裸 IP 换成域名。注:文中域名、jar 名与业务类名均已做脱敏处理。
1. 背景:信号总线遇上容灾切换
biz-cache-client 是被多个服务引用的班次缓存客户端 jar。它不直接通过 ZK 传数据,而是把 ZK 当作信号总线,内部有三类角色:
- 订阅方 :对多路业务节点分别挂
NodeCachewatch,节点数据变化即触发本地缓存重载(真正的数据在 DB/Redis); - 发布方:数据更新后把最新规则写回 ZK 节点;
- IP 注册 :每个实例启动时在注册路径下创建 EPHEMERAL 节点登记本机 IP,供
listSubscribeNodeIp()做实例发现。
这套机制运行多年一直平稳,直到容灾方案出台:ZK 集群 5 个节点全部域名化,容灾切换时变更域名指向------
zkServerAddr=zk01.example.com:2181,...,zk05.example.com:2181
老配置是裸 IP 直连。ZooKeeper 客户端一旦用 IP 建立连接,连接串里的 IP 就是"写死"的:域名指向改成新集群,老客户端永远连不到新 IP,只能重启进程。而一个被几十个服务引用的 jar,"重启所有进程"恰恰是容灾时最做不到的事。
需求最终收敛为四条硬约束:
| 项 | 结论 |
|---|---|
| 老集群切换时状态 | 两种都可能:紧急容灾直接停机;计划内蓝绿切换老集群仍存活 |
| 生效时间 | 域名切换后 1 分钟内恢复连接/订阅/注册 |
| 兼容性 | 必须兼容旧 IP 直连配置,行为不变 |
| 环境约束 | 运行环境不可控------不能依赖外部 JVM 参数,jar 必须自包含 |
2. 先核验事实,再谈设计
动手前先对照本地依赖源码核验了六个事实,它们直接决定了方案的形状:
| # | 事实 | 对设计的影响 |
|---|---|---|
| 1 | ZK 3.4.14 ConnectStringParser 用 InetSocketAddress.createUnresolved() 保留域名不固化 IP;StaticHostProvider.next() 每次重连尝试都重新解析域名(受 JVM DNS 缓存影响,默认 30s) |
停机场景的被动重连原生可用,白捡一条路径 |
| 2 | ZK 3.4.14 没有公开的带 HostProvider 的构造器(3.5+ 才有);testableRemoteSocketAddress() 为 protected |
无法注入自定义解析器、拿不到当前对端 IP → 触发条件只能改用"解析集合变化" |
| 3 | Curator 4.1.0 CuratorZookeeperClient.reset() 是 public:优雅关闭当前 ZK 句柄并用连接串重建连接 |
主动迁移的执行手段现成 |
| 4 | Curator 4.1.0 NodeCache 在 CONNECTED/RECONNECTED 时自动重读数据并重挂 watch |
reset 后订阅自愈免费,无需重建 NodeCache |
| 5 | ExponentialBackoffRetry(base, retries, maxSleepMs) 三参构造器存在 |
重试加固的最小改动入口 |
| 6 | registerIp()(订阅/发布两侧)只在启动时注册一次 EPHEMERAL 节点,会话过期后无人重建 |
既有缺陷,与容灾无关也存在,顺带修复 |
其中事实 #2 值得展开:理想中最好的触发条件是"当前连接的对端 IP 是否还在域名解析集合里",但 3.4.14 不给这个信息。退而求其次的"解析集合发生变化"语义等价------集合变了,连接要么迁移要么迟早要迁。
3. 总体架构:三条路径
┌────────────────────────────────────────────┐
│ ZkFailoverManager(新增,按 serverAddr 分组)│
│ 守护线程,默认 15s 轮询 │
│ 解析 connectString 中所有域名的 A 记录 │
│ 解析结果集合变化 → reset 组内全部 client │
└──────────┬─────────────────────────────────┘
│ reset (CuratorZookeeperClient.reset())
attach(创建 client 时)↓
┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐
│ 路径1 被动 │ │ 路径2 主动 │ │ 路径3 自愈 │
│ 老集群停机: │ │ 蓝绿切换: │ │ ConnectionStateListener │
│ 断连→原生 │ │ monitor 检测变更 │ │ CONNECTED/RECONNECTED → │
│ next()重新 │ │ → reset → 新连接 │ │ 重跑 doRegisterIp │
│ 解析域名→新IP│ │ → 解析出新 IP │ │ NodeCache 原生恢复 watch │
└─────────────┘ └──────────────────┘ └─────────────────────────┘
路径 1(被动) :老集群停机场景。TCP 断连后 ZK 原生重连循环每轮 next() 都重新解析域名,DNS 缓存过期(默认 30s)后即拿到新 IP。只要配置从 IP 换成域名,这条路径零代码白拿。
路径 2(主动) :蓝绿场景的根本难题------老集群还活着,连接一切正常,客户端毫无感知 ,永远不会触发重连。所以必须自己造一个监测器:轮询解析 connectString 中所有域名,解析结果集合变化就主动 reset() 重建连接。
路径 3(自愈) :无论哪种切换方式,连接重建都意味着新会话 ------旧会话的 EPHEMERAL 节点已被服务端删除,一次性注册的 IP 就此消失。所以对注册用的 client 挂 ConnectionStateListener,收到 CONNECTED/RECONNECTED 就重跑幂等的注册逻辑。注意这条路径与域名无关,IP 直连的老配置同样受益(会话过期天天可能发生,只是以前节点消失了也无人知晓)。
三条路径各管一段,缺一不可:路径 1 管"断了怎么重连",路径 2 管"没断怎么搬家",路径 3 管"搬完怎么恢复注册"。
4. 关键实现
4.1 零侵入的接入点:attach()
创建共享 CuratorFramework 的位置全库只有两处:ZookeeperDataSource.initZookeeperListener() 和 ZookeeperPublish.initZookeeperClient()。client 放入静态 zkClientMap 后追加一行:
java
// 域名容灾:serverAddr 含域名时启动解析监测(幂等,纯 IP 不启动)
ZkFailoverManager.attach(serverAddr, this.zkClient);
this.nodeCache = new NodeCache(this.zkClient, this.path);
attach() 内部完成两件事:把 client 登记进按 serverAddr 分组的注册表(同一连接串下读/写/注册多种鉴权组合可能对应多个 client,迁移时统一处理);然后判定是否启动监测:
java
public static void attach(String serverAddr, CuratorFramework client) {
if (StringUtils.isBlank(serverAddr) || !ZkFailoverConfig.enabled()) {
return;
}
if (client != null) {
List<CuratorFramework> group = groupOf(serverAddr);
synchronized (group) {
if (!group.contains(client)) {
group.add(client);
}
}
}
startMonitorIfNeeded(serverAddr);
}
private static void startMonitorIfNeeded(String serverAddr) {
List<String> hosts = extractHosts(serverAddr);
if (!hasDomain(hosts)) {
log.info("[ZkFailoverManager] connectString all ip-literal, dns failover monitor not started: {}", serverAddr);
return;
}
synchronized (LOCK) {
if (MONITORED.contains(serverAddr)) {
return;
}
ensureSchedulerStarted();
MONITORED.add(serverAddr);
}
}
兼容性就藏在这里 :hasDomain() 判定 connectString 里是否至少含一个非 IP 字面量(IPv4 点分十进制或含冒号的 IPv6)。纯 IP 配置 → 不启动监测线程、不轮询,行为与现状完全一致。这比加开关更可靠------旧配置的用户什么都不用做,新配置自动获得能力。
4.2 检测逻辑:集合比对 + 防误切
监测线程是单线程 ScheduledExecutorService 守护线程(命名 zk-failover-monitor,不阻止 JVM 退出),每轮对所有监测中的 serverAddr 执行:
java
private static void checkOne(String serverAddr) {
Set<InetAddress> current = resolveAll(extractHosts(serverAddr));
if (current == null) { // 任一域名解析失败 → 整轮跳过
log.warn("[ZkFailoverManager] dns resolve failed, skip this round: {}", serverAddr);
return;
}
Set<InetAddress> previous = SNAPSHOTS.get(serverAddr);
if (previous == null) { // 首轮只建基线,不动作
SNAPSHOTS.put(serverAddr, current);
return;
}
if (previous.equals(current)) {
return; // 集合无变化
}
// ------ 以下进入"变化确认 + 节流"(见 4.3)------
SNAPSHOTS.put(serverAddr, current);
resetClients(serverAddr);
}
两个防御性设计:
- DNS 故障绝不误切 :
resolveAll()里任一域名抛UnknownHostException就返回 null,整轮跳过。DNS 抖动、网络分区期间哪怕解析不出任何结果,也不会触发 reset------宁可漏切(等下一轮),不可错切(把好连接重置掉)。 - 触发条件是"集合变化"而非"对端 IP 不在集合内":如事实 #2,3.4.14 拿不到当前对端地址。集合变化语义等价且实现简单。
4.3 节流:60 秒窗口的"原子认领"
5 个域名分批改指向时(运维批量操作天然如此),可能连续两三轮都检测到变化。节流窗口保证 60s 内最多一次 reset:
java
final boolean[] claimed = new boolean[1];
LAST_RESET_AT.compute(serverAddr, (key, lastResetAt) -> {
if (lastResetAt != null && now - lastResetAt < throttleMs) {
return lastResetAt; // 窗口内:不认领,原值写回
}
claimed[0] = true;
return Long.valueOf(now); // 认领本次 reset 权
});
if (!claimed[0]) {
// 窗口内不 reset 也不更新快照:窗口过后集合仍不同会再次触发(收敛语义)
return;
}
这里用 ConcurrentHashMap.compute 把"检查窗口 + 占位时间戳"做成单原子操作,是因为朴素的"先 get 再 put"在并发下有竞态:测试线程与轮询线程同时进入时,两边都会读到"窗口已过"然后双双通过,造成双重 reset。compute 的 lambda 在同一个 key 上串行执行,天然免锁。
另外注意节流期间不更新快照:窗口过后如果解析结果与基线仍不同,会再次触发 reset。这保证了部分切换最终收敛到新集群,而不是"切了一半就永久停在中间状态"。
4.4 reset 编排:错峰 + 状态检查
java
private static void resetClients(String serverAddr) {
List<CuratorFramework> group = CLIENTS.get(serverAddr);
if (group == null || group.isEmpty()) {
return;
}
for (CuratorFramework curatorClient : group) {
try {
if (curatorClient.getState() != CuratorFrameworkState.STARTED) {
continue; // 已关闭/未启动的 client 跳过
}
curatorClient.getZookeeperClient().reset();
Thread.sleep(RESET_STAGGER_MS); // 200ms 错峰,避免同时断连风暴
} catch (Throwable t) {
log.error("[ZkFailoverManager] reset failed for one client of {}", serverAddr, t);
}
}
}
三个细节:STARTED 状态检查跳过已关闭的 client(注册表是 JVM 级静态的,不随单个 DataSource close 退出);同组 client 间隔 200ms 依次 reset,避免同一瞬间全部断连造成下游连锁反应;单个 reset 失败记日志继续下一个,不让一个坏 client 卡住整组迁移。
reset 期间读路径不受影响 :readSource() 读的是 NodeCache 本地缓存,ZK 只是信号源,读操作不走网络。
4.5 EPHEMERAL 重注册(修既有缺陷)
把原来的 registerIp() 拆成"幂等的注册动作"和"带一次性 guard 的入口":
java
static void doRegisterIp(ZookeeperPublish zookeeperPublish, String registerPath) throws Exception {
CreateMode mode = CreateMode.EPHEMERAL;
String ip = IpInstance.getInstance();
String path = registerPath.endsWith("/") ? registerPath + ip : registerPath + "/" + ip;
zookeeperPublish.setData(path, ip, mode, null); // 存在则更新、不存在则创建,天然幂等
}
static void attachReRegisterListener(final ZookeeperPublish zookeeperPublish, final String registerPath) {
zookeeperPublish.getZkClient().getConnectionStateListenable().addListener(new ConnectionStateListener() {
@Override
public void stateChanged(CuratorFramework client, ConnectionState newState) {
if (newState == ConnectionState.CONNECTED || newState == ConnectionState.RECONNECTED) {
try {
doRegisterIp(zookeeperPublish, registerPath);
} catch (Exception e) {
log.error("[CacheSubscribe] re-register ip failed", e);
}
}
}
});
}
为什么同时监听 CONNECTED 和 RECONNECTED 两种状态?因为 reset 重建连接后 Curator 发布哪种事件取决于内部状态机路径,首次连接走 CONNECTED、断线恢复走 RECONNECTED------NodeCache 源码也是按这两个状态自愈的,照抄这个约定最稳。
原有的 AtomicBoolean 一次性 guard 保留在外层入口:listener 只挂一次,避免每次 setData/getData 都重复注册。
4.6 重试加固:被"随机退避"教育的一次
原代码 new ExponentialBackoffRetry(1000, 3):约 7 秒就放弃。容灾切换窗口期(约 50s)内 Pod 恰好重启的话,7 秒重试根本熬不到 DNS 切换完成,直接 crash-loop。
java
static ExponentialBackoffRetry retryPolicy() {
// ExponentialBackoffRetry(1000, 5, 5000):5 次重试、单次休眠 [1s,5s] 随机封顶 5s
return new ExponentialBackoffRetry(RETRY_BASE_SLEEP_MS, startRetryTimes(), RETRY_MAX_SLEEP_MS);
}
改成 (1000, 5, 5000) 后最长重试窗口约 25s,覆盖大部分切换窗口。这里有个值得单独讲的坑,见第 8 节。
5. 时间预算:1 分钟 SLA 怎么凑出来的
| 阶段 | 耗时 | 说明 |
|---|---|---|
| DNS 记录变更生效 | 0~30s | JVM DNS 缓存默认 30s(无 SecurityManager 环境) |
| monitor 检测到集合变化 | ≤15s | 轮询周期 |
| reset + 重连新集群 | ~2-5s | 新 StaticHostProvider 重新解析(缓存已过期,命中新 IP) |
| EPHEMERAL 重注册 / watch 恢复 | <1s | RECONNECTED 事件同步触发 |
| 最坏合计 | ~50s | 满足 <1min |
老集群停机场景更短:断连即重试,next() 每次重新解析,通常 30s(缓存过期)+ 秒级重连恢复。
注意第一行是整个预算里最大的不可控变量 :JVM DNS 缓存默认 30s,但装了 SecurityManager 且策略锁死的环境里,成功解析会被永久缓存 (networkaddress.cache.ttl 在有 SecurityManager 时默认变为 forever)。为此提供了 zk.failover.dnsCacheTtlSeconds 缓解开关,详见下节。
6. 配置项:全部可选,零配置可用
| 属性 | 默认 | 说明 |
|---|---|---|
zk.failover.enabled |
true |
仅控制路径 2(主动监测);关闭后不启动轮询线程。路径 3 重注册与重试参数不受影响 |
zk.failover.pollIntervalMs |
15000 |
DNS 轮询周期 |
zk.failover.resetThrottleMs |
60000 |
reset 节流窗口 |
zk.failover.dnsCacheTtlSeconds |
-1(不动) |
>=0 时 best-effort 设置 networkaddress.cache.ttl,SecurityException 仅告警。用于 SecurityManager 环境的缓解,默认不碰全局 JVM 配置 |
zk.failover.startRetryTimes |
5 |
重试次数;设 3 完全回到现状 |
两个语义边界刻意收紧过:enabled=false 只关主动监测,不关自愈重注册(后者是缺陷修复,不该被运维开关连坐);DNS TTL 开关默认不设,不动全局 JVM 状态------一个被多方共享的进程里,私自改全局 DNS 缓存可能影响其他组件。
7. 测试与验证
单元测试:双集群 + 假解析器
用 curator-test 的 TestingServer 起两个真实 ZK 实例模拟新老集群,通过包级可见的 setResolverForTest(DnsResolver) 注入假域名映射(生产路径固定用 InetAddressDnsResolver,测试不碰公共 API),改映射即模拟"域名指向变更"。共 9 个测试类 22 个用例,核心场景:
- 域名变更触发迁移:改映射 → 2 个轮询周期内出现断开→重连、最终连到新 server;
- 迁移后订阅恢复:切换后向新 server 写节点 → 订阅方收到更新(用连接世代数断言确实是"新连接"收到了,而非旧缓存);
- EPHEMERAL 重注册:迁移后注册节点在新集群重新出现;
- DNS 故障不误切 :解析器抛
UnknownHostException→ 连接状态无扰动; - IP 模式旁路:全 IP 连接串 → 无监测线程、无轮询;
- 节流生效:连续两次映射变更,节流窗口内只 reset 一次。
SIT 演练(上线前必做)
单测里的"域名"是假解析器,真实 DNS 链路的缓存、TTL、传播行为只能实测:双集群 + 真实 DNS 指向切换,验证蓝绿(老集群存活)与停机两场景端到端时延 <1min、publish→subscribe 链路恢复、切换过程日志快照可读。
8. 踩坑记
坑 1:ExponentialBackoffRetry 的退避是随机的。
大量资料(包括我们的设计初稿)把退避序列写成固定的 1s→2s→4s→8s。翻 Curator 4.1.0 源码,getSleepTimeMs 的实现是 base * max(1, random(2^(n+1))) 再封顶 maxSleep------每次重试的休眠是区间内随机值 。这个坑是在写单测时暴露的:按固定序列写断言,跑十次挂三次。随机化的本意是避免大量客户端同时重试造成重连风暴(thundering herd),是合理设计,但文档长期语焉不详。写测试请用区间断言(sleep >= 1000 && sleep <= 5000),别赌序列。
坑 2:JVM DNS 缓存是隐形主角。
InetAddress 的成功解析默认缓存 30s,这是 1 分钟 SLA 预算表里最贵的一行。更隐蔽的是:一旦 JVM 装了 SecurityManager 且策略未授权,networkaddress.cache.ttl 的默认值就从 30s 变成 forever------域名指向永远切不过去,且没有任何报错。我们的处理是提供可选的 TTL 设置开关(best-effort + 容忍 SecurityException),把这类环境的兜底能力留给运维。
坑 3:节流判断的并发竞态。
"先 get 再 put"的节流写法在单线程调度器里没问题,但测试线程可以和调度线程并发调用 pollOnce(),两边同时读到"窗口已过"然后双双 reset。用 ConcurrentHashMap.compute 把检查和占位合并为原子操作后收口。教训:静态工具类的"内部方法可测性"会改变它的并发暴露面------为了测试拆出来的同步入口,等于多了一个并发调用者。
坑 4:EPHEMERAL + 一次性注册 = 定时炸弹。
会话一过期节点就被服务端删除,任何形式的重连(网络抖动、会话超时、容灾切换)都会引爆------只是以前从未被发现,因为节点消失时连一行日志都没有,实例就从 peers 列表里静默蒸发了。这次作为路径 3 顺带修掉。凡是"用 EPHEMERAL 做存活注册"的代码,都值得检查一遍注册逻辑是否只跑一次。
9. 结语
这次改造的产出很克制:新增 ZkFailoverManager / ZkFailoverConfig / DnsResolver 三个类约 190 行,修改既有类约 75 行(不含测试),公共 API 零变化,不新增第三方依赖。使用方的升级动作只有一个:把配置中心里的 zkServerAddr 从裸 IP 改成域名形式。
回头看,方案能够做轻,根源在第 2 节的事实核验:三条路径里有两条(被动重连、NodeCache 自愈)是 ZK/Curator 原生就有的能力,真正的增量只有"蓝绿场景的主动监测"和"EPHEMERAL 重注册"。升级依赖前先把中间件源码的关键行为核验一遍,往往能把"造轮子"的冲动收敛成"接电线"的工程量。
遗留与边界(均为有意不做):
subscribeData()失败即System.exit(-1)的启动强依赖未动(单独跟踪);重试加固把切换窗口期的 crash-loop 概率大幅压低,但不消除;ZookeeperDataSource.close()/ZookeeperPublish.close()会关闭共享 client 的既有缺陷只加了注释标注(jar 内无生产调用方,缺陷休眠);- JVM 全局 DNS 缓存为无限长的极端环境不解决,只提供缓解开关。
附:涉及的核心文件
| 文件 | 角色 |
|---|---|
datasource/zookeeper/ZkFailoverManager.java |
新增:注册表、轮询、节流、reset 编排 |
datasource/zookeeper/ZkFailoverConfig.java |
新增:System Property 配置(全可选) |
datasource/zookeeper/DnsResolver.java / InetAddressDnsResolver.java |
新增:可注入解析器接口与默认实现 |
datasource/zookeeper/ZookeeperDataSource.java |
attach 接入 + 重试策略统一 |
datasource/zookeeper/ZookeeperPublish.java |
attach 接入 + 重试策略统一 |
subscribe/CacheSubscribe.java / publish/CachePublish.java |
registerIp 拆分 + 重注册 listener |