使用 TongZK 做服务注册与服务发现

使用 TongZK 做服务注册与服务发现

服务一旦从单实例扩展为多实例,调用方就会面对一个很实际的问题:订单服务当前有哪些可用实例?某个实例扩容、重启或异常退出后,地址列表怎样及时更新?

把地址写死在配置文件里,短期很省事,实例一多就会变成频繁改配置、重启和排查不一致的问题。服务注册与服务发现要解决的,正是"服务提供者如何声明自己还活着"以及"服务消费者如何获得并更新可用地址"这两个问题。

TongZK 面向 ZooKeeper 生态提供兼容能力,适合承担这类强一致服务目录和状态感知工作。业务 Java 应用通常可以继续使用 org.apache.zookeeper.* 客户端 API,而不需要把导入改为 com.tongtech.tongzk。后者主要用于 TongZK 服务端内部代码、日志和扩展分析。

本文以 order-service 为例,介绍如何用临时顺序节点登记服务实例,用 Watcher 感知目录变化,并说明会话、下线和生产运维中容易被忽略的边界。

一、先把服务目录设计清楚

服务注册不是把所有服务信息放到一个大节点里,而是利用 znode 的层次结构组织目录。下面是一种便于理解的最小模型:

text 复制代码
/services
  /order-service                 # 持久目录节点
    /instance-0000000001         # 临时顺序节点,数据为 10.10.1.21:8080
    /instance-0000000002         # 临时顺序节点,数据为 10.10.1.22:8080

这里有三个关键设计。

  • /services/services/order-service 是持久节点,用来保存服务目录结构。
  • 每个实例在服务目录下创建临时顺序节点,节点数据保存该实例的访问地址或小体量元数据。
  • 消费者对服务目录的子节点列表注册 Watcher;目录中的实例节点新增或删除后,消费者重新读取地址列表。

临时节点把"实例是否仍和 TongZK 保持会话"与"该实例是否出现在服务目录中"关联起来。实例正常关闭客户端连接时,节点会删除;进程异常退出或网络持续中断时,节点会在对应 Session 过期后删除。

临时顺序节点并不是唯一选择。若业务已有全局唯一的实例 ID,也可以用唯一实例 ID 创建普通临时节点。本文选择临时顺序节点,是为了避免多个实例同时启动时发生节点名冲突,并方便观察每个注册动作对应的目录变化。

二、服务提供者:创建临时顺序节点完成注册

下面的示例把注册逻辑封装为一个小类。它假定外层应用已经完成与 TongZK 的连接管理,重点展示"确保目录存在"和"创建临时顺序节点"两个动作。

java 复制代码
import java.nio.charset.StandardCharsets;

import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;

public final class ServiceRegistry implements AutoCloseable {
    private static final String ROOT_PATH = "/services";

    private final ZooKeeper client;
    private final String servicePath;
    private String registeredPath;

    public ServiceRegistry(ZooKeeper client, String serviceName) {
        this.client = client;
        this.servicePath = ROOT_PATH + "/" + serviceName;
    }

    public String register(String endpoint)
            throws KeeperException, InterruptedException {
        ensurePersistent(ROOT_PATH);
        ensurePersistent(servicePath);

        registeredPath = client.create(
                servicePath + "/instance-",
                endpoint.getBytes(StandardCharsets.UTF_8),
                ZooDefs.Ids.OPEN_ACL_UNSAFE,
                CreateMode.EPHEMERAL_SEQUENTIAL);
        return registeredPath;
    }

    private void ensurePersistent(String path)
            throws KeeperException, InterruptedException {
        try {
            client.create(
                    path,
                    new byte[0],
                    ZooDefs.Ids.OPEN_ACL_UNSAFE,
                    CreateMode.PERSISTENT);
        } catch (KeeperException.NodeExistsException ignored) {
            // Multiple instances may create the same directory concurrently.
        }
    }

    public String getRegisteredPath() {
        return registeredPath;
    }

    @Override
    public void close() throws InterruptedException {
        client.close();
    }
}

ZooDefs.Ids.OPEN_ACL_UNSAFE 只适合本地验证,让示例聚焦在注册流程。生产环境必须按照应用身份、服务路径和最小权限原则设置 ACL,并与认证、TLS、网络隔离和密钥管理策略一起验证。

应用启动后可以使用多个 TongZK 节点构成连接串,例如:

java 复制代码
String connectString = "tongzk-1.example.com:2181,"
        + "tongzk-2.example.com:2181,"
        + "tongzk-3.example.com:2181";

ZooKeeper client = new ZooKeeper(
        connectString,
        30_000,
        event -> System.out.println("TongZK event: " + event));

ServiceRegistry registry = new ServiceRegistry(client, "order-service");
String path = registry.register("10.10.1.21:8080");
System.out.println("registered at " + path);

这里使用的仍是 ZooKeeper 原始客户端 API。TongZK 服务端兼容范围内的 ZooKeeper 客户端可作为业务接入选择,但连接串、客户端版本、认证、TLS、ACL、会话超时、网络策略和实际运行行为仍应在目标环境验证。

示例为了突出节点操作,省略了应用框架中的连接就绪等待、统一异常处理、重连和优雅停机钩子。真实服务不应在 new ZooKeeper(...) 后立刻假定连接已经完成;应在收到连接成功状态后再执行注册,并在应用关闭时调用 close()

三、服务消费者:先读列表,再注册 Watcher

消费者的目标不是保存一个永远不变的地址列表,而是维护一份可以随目录变化刷新出来的本地快照。一个可靠的基本流程是:

  1. /services/order-service 调用 getChildren 并注册 Watcher。
  2. 读取每个实例节点的数据,得到当前可用 endpoint 列表。
  3. 收到子节点变化事件后,在业务线程池中再次执行"读取列表并注册 Watcher"。
  4. 将新快照交给本地负载均衡、连接池或调用组件。

下面的代码展示这个模式。它省略了特定 RPC 框架的负载均衡实现,只负责维护 endpoint 快照。

java 复制代码
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.function.Consumer;

import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;

public final class ServiceDiscovery {
    private final ZooKeeper client;
    private final String servicePath;
    private final Executor refreshExecutor;
    private final Consumer<List<String>> onChanged;
    private final Consumer<Exception> onError;

    private volatile List<String> endpoints = Collections.emptyList();

    public ServiceDiscovery(
            ZooKeeper client,
            String servicePath,
            Executor refreshExecutor,
            Consumer<List<String>> onChanged,
            Consumer<Exception> onError) {
        this.client = client;
        this.servicePath = servicePath;
        this.refreshExecutor = refreshExecutor;
        this.onChanged = onChanged;
        this.onError = onError;
    }

    public void start() throws KeeperException, InterruptedException {
        refreshAndWatch();
    }

    public List<String> getEndpoints() {
        return endpoints;
    }

    private void refreshAndWatch() throws KeeperException, InterruptedException {
        List<String> children = client.getChildren(servicePath, this::handleChildEvent);
        Collections.sort(children);

        List<String> latest = new ArrayList<>();
        for (String child : children) {
            try {
                byte[] data = client.getData(servicePath + "/" + child, false, null);
                latest.add(new String(data, StandardCharsets.UTF_8));
            } catch (KeeperException.NoNodeException ignored) {
                // The instance disappeared after getChildren; the next refresh reconciles it.
            }
        }

        endpoints = Collections.unmodifiableList(new ArrayList<>(latest));
        onChanged.accept(endpoints);
    }

    private void handleChildEvent(WatchedEvent event) {
        if (event.getType() != Watcher.Event.EventType.NodeChildrenChanged) {
            return;
        }

        refreshExecutor.execute(() -> {
            try {
                refreshAndWatch();
            } catch (KeeperException | InterruptedException e) {
                if (e instanceof InterruptedException) {
                    Thread.currentThread().interrupt();
                }
                onError.accept(e);
            }
        });
    }
}

这段代码有两个容易被忽略的点。

第一,Watcher 是一次性通知。收到 NodeChildrenChanged 事件后,不能只更新一次内存列表就结束,必须再次调用带 Watcher 的 getChildren,为后续变化重新注册监听。

第二,不要在 Watcher 回调线程里直接做耗时的网络调用、复杂路由计算或批量重建连接。示例把刷新任务交给 refreshExecutor,避免事件处理线程被业务逻辑阻塞。

本文的 Watcher 只监听服务目录的子节点增删,不监听实例节点数据变化。生产中应尽量把实例 endpoint 视为注册后不可变的数据;如确实需要更新实例元数据,应采用删除后重新注册,或为每个实例节点补充 getData Watcher 并处理其一次性重注册。

还要把 Watcher 定位为"状态变化提示",而不是可靠消息队列。收到通知后,消费者应重新读取服务目录的最新快照;不能假设每一个实例的每一次变化都会以可业务消费的消息形式逐条传递。

四、Session 决定实例何时真正下线

临时节点与客户端 Session 绑定,这也是服务注册能自动清理异常实例的基础。理解这层关系,能避免把网络瞬断误判成实例已经下线。

场景 服务目录中的临时节点 提供者与消费者应做什么
提供者正常关闭客户端 连接关闭后节点删除 消费者收到目录变化后刷新列表。
提供者进程异常退出 Session 过期后节点删除 消费者不能期待立刻删除,应结合 Session 超时和应用健康检查判断。
短暂网络抖动 在 Session 未过期前节点可能仍存在 提供者应处理重连状态;消费者不应仅凭一次 Disconnected 事件立即清空地址。
Session 已过期 原临时节点会失效 提供者要创建新的客户端连接并重新注册;消费者应重新建立监听和刷新快照。

Session 过期不是普通的短暂断连。客户端 Session 一旦过期,旧客户端不能通过简单重连恢复原来的临时节点和 Watcher 状态。应用需要按自身生命周期策略创建新客户端、重新注册实例或重新拉取服务目录。

因此,服务发现通常还需要和应用自身的连接探测、调用超时、熔断、重试和负载均衡配合。TongZK 提供的是强一致服务目录和状态变化感知,不替代完整的流量治理体系。

五、用命令行和管控台验证目录变化

开发联调时,可以先通过 TongZK CLI 查看服务目录。以下命令以第 4 篇部署的三节点集群为例:

bash 复制代码
cd /opt/tongzk
./bin/tongzkCli.sh -server tongzk-1.example.com:2181

进入 CLI 后执行:

text 复制代码
ls /services/order-service
get /services/order-service/instance-0000000001

验证流程可以按下面四步走:

  1. 启动第一个提供者,确认目录下出现一个临时实例节点。
  2. 启动第二个提供者,确认消费者本地快照增加一个 endpoint。
  3. 正常停止其中一个提供者,确认对应节点删除,消费者刷新列表。
  4. 在测试环境模拟异常退出,观察节点在 Session 超时后消失,并验证提供者恢复后是否重新注册。

除了命令行,TongZK 可视化管理控制台也能直接浏览和管理 znode 树。对运维人员来说,它适合确认服务目录层级、实例节点数据、访问权限和异常残留;对应用团队来说,它能帮助快速区分"注册逻辑没有执行""实例 Session 尚未过期"和"消费者没有重新注册 Watcher"等不同问题。

六、上线前的设计清单

服务注册看起来只有"创建节点"和"监听变化"两步,生产落地时却需要把可用性和安全性一起设计。

主题 建议关注点
服务路径 按业务域和服务名规划层级,避免多个团队随意复用同一目录。
实例数据 保持小体量,明确使用 host:port 还是 JSON 元数据,并约定字段兼容策略。
身份与权限 不要沿用示例中的开放 ACL;按服务账号授权,并验证认证、TLS 和密钥轮换。
Session 参数 根据网络质量、故障发现时效和误剔除成本设定超时,并在压测和故障演练中验证。
Watcher 处理 每次触发后重新注册;把刷新逻辑放在可控线程池,并准备错误重试和周期性对账。
消费端容错 将目录变化与连接池、超时、重试、熔断和负载均衡协同,避免一次事件直接影响全部流量。
运维可见性 用控制台和监控观察集群健康、会话、连接、服务目录、延迟和异常节点。
发布与回退 灰度验证注册、发现、实例下线和 Session 过期路径;保留服务发现配置和应用版本的回退方案。

如果项目正在由 ZooKeeper 迁移到 TongZK,还应验证既有服务注册客户端的版本、连接串、ACL、认证、TLS、Session 行为和 Watcher 行为。兼容能力可以降低改造面,但不代表这些运行时行为可以跳过现场验证。

七、常见误用

1. 把持久节点当作实例存活标记

实例节点若使用普通持久节点,进程异常退出后很容易留下过期地址,消费者可能继续向失效实例发起调用。服务目录节点可以是持久节点,但具体实例通常应使用临时节点表达会话存活状态。

2. 收到 Watcher 通知后不重新注册

Watcher 一次触发后就失效。只在启动时注册一次监听,会导致消费者只感知到第一次目录变化,之后的扩缩容和下线都不会再刷新本地列表。

3. 把服务目录当成完整的健康检查系统

临时节点反映的是 Session 状态,不等于每个业务接口都健康。实例虽然仍保持 Session,也可能因为线程池耗尽、依赖超时或业务降级而无法处理请求。调用侧仍要有超时、熔断和健康检查机制。

4. 在生产中使用开放 ACL

开放 ACL 便于快速演示,却会让任何满足网络访问条件的客户端拥有过多操作权限。生产环境应根据服务身份和路径权限设计最小授权,并把认证、TLS、网络隔离和审计一起纳入上线验收。

八、总结

用 TongZK 做服务注册与服务发现,核心是让服务提供者通过临时节点表达存活状态,让消费者通过 Watcher 感知服务目录变化并刷新 endpoint 快照。

临时节点解决异常实例自动清理的基础问题,Watcher 减少服务消费者的无效轮询,而 Session 机制决定了"什么时候可以认为实例真正下线"。在此基础上,TongZK 的 ZooKeeper 生态兼容、可视化 znode 管理和企业级运维能力,能够让服务目录从单纯的代码逻辑变成更可观察、可治理的基础设施能力。

下一篇文章将继续介绍如何使用 TongZK 实现配置中心,重点讲 znode 数据模型、配置变更通知和强一致配置管理的适用边界。

相关推荐
执笔论英雄2 天前
【大模型推理】sglang PD 分离 : 6473 服务发现
服务发现·sglang
AI人工智能+电脑小能手2 天前
【大白话说Java面试题 第207题】【09_Zookeeper篇】第8题:谈谈 ZooKeeper 的可靠性保障机制
java·zookeeper·高可用·可靠性·分布式系统
January丶3 天前
docker-compose安装ZooKeeper集群
zookeeper
彷徨而立3 天前
常见国产 CPU、操作系统及其组合(二)
国产化
AI人工智能+电脑小能手3 天前
【大白话说Java面试题 第205题】【09_Zookeeper篇】第6题:ZooKeeper 实现分布式锁的原理
java·zookeeper·分布式锁·并发控制·分布式协调
AI人工智能+电脑小能手4 天前
【大白话说Java面试题 第203题】【09_Zookeeper篇】第4题:ZooKeeper 的节点类型有哪些?
java·zookeeper·分布式锁·分布式协调·znode
布吉岛的石头8 天前
Java 程序员第 45 阶段11:网关统一路由大模型接口,配合 Nacos 配置治理,灰度路由:基于Nacos元数据实现大模型服务灰度发布与权重路由
java·服务发现
专业工业电源打工人8 天前
URA2415LD-20WR3 适配优选 VB20-24D15LD|20W 双路±15V输出 DC-DC 模块电源参数规格拆解
国产化·电源模块·工业电源
一名普通的电源工程师8 天前
URB4824LD-60WR3 适配优选 钡特电源 VB60-48S24LD:60W 工业 DC-DC 模块电源选型分析
国产化·电源模块·工业电源