ZooKeeper 容灾切换免重启实践:域名化配置与客户端自动迁移

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 当作信号总线,内部有三类角色:

  • 订阅方 :对多路业务节点分别挂 NodeCache watch,节点数据变化即触发本地缓存重载(真正的数据在 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 ConnectStringParserInetSocketAddress.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 NodeCacheCONNECTED/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);
                }
            }
        }
    });
}

为什么同时监听 CONNECTEDRECONNECTED 两种状态?因为 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.ttlSecurityException 仅告警。用于 SecurityManager 环境的缓解,默认不碰全局 JVM 配置
zk.failover.startRetryTimes 5 重试次数;设 3 完全回到现状

两个语义边界刻意收紧过:enabled=false 只关主动监测,不关自愈重注册(后者是缺陷修复,不该被运维开关连坐);DNS TTL 开关默认不设,不动全局 JVM 状态------一个被多方共享的进程里,私自改全局 DNS 缓存可能影响其他组件。

7. 测试与验证

单元测试:双集群 + 假解析器

用 curator-test 的 TestingServer 起两个真实 ZK 实例模拟新老集群,通过包级可见的 setResolverForTest(DnsResolver) 注入假域名映射(生产路径固定用 InetAddressDnsResolver,测试不碰公共 API),改映射即模拟"域名指向变更"。共 9 个测试类 22 个用例,核心场景:

  1. 域名变更触发迁移:改映射 → 2 个轮询周期内出现断开→重连、最终连到新 server;
  2. 迁移后订阅恢复:切换后向新 server 写节点 → 订阅方收到更新(用连接世代数断言确实是"新连接"收到了,而非旧缓存);
  3. EPHEMERAL 重注册:迁移后注册节点在新集群重新出现;
  4. DNS 故障不误切 :解析器抛 UnknownHostException → 连接状态无扰动;
  5. IP 模式旁路:全 IP 连接串 → 无监测线程、无轮询;
  6. 节流生效:连续两次映射变更,节流窗口内只 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
相关推荐
张洛闻Eren1 小时前
云原生k8s【第七课】:集群监控与可观测性
linux·运维·docker·云原生·容器·kubernetes·k8s
kruptos9 小时前
分布式系统怎么“选主“?Raft 共识一次讲清
开发语言·分布式·php·共识算法
小智老师PMP11 小时前
2026深度解析|PMP第八版与NPDP核心侧重点本质区别(管理类证书怎么选)
开发语言·分布式·算法·职场和发展·产品经理
会周易的程序员13 小时前
5Draft(五帝)测试报告
服务器·c++·分布式·raft·共识算法·共识·算力服务器
大大大大晴天️17 小时前
用 Helm、CRD 与 Operator 管大数据:声明式运维如何替代脚本化部署
大数据·云原生
LRL_19 小时前
深入浅出 Kubernetes 控制器:Deployment 与 StatefulSet 核心区别全景图
云原生·容器·kubernetes
想要打 Acm 的小周同学呀19 小时前
Kafka消息队列出现挤压如何解决?
分布式·kafka
MrSYJ20 小时前
Network Namespace 到底是个啥,别人再问你告诉他
docker·云原生·kubernetes
彬冷的心1 天前
K8s 部署若依项目 + Harbor 私有仓库(双节点版)
云原生·容器·kubernetes