繁

实例已经写进 ZooKeeper,Admin 为什么仍然找不到?从注册与监听路径不一致,追到事件格式、应用隔离、数据库更新和完整快照,记录 ShenYu PR #7446 的修复与验证。

注册成功,Admin 却在看另一棵树:Apache ShenYu ZooKeeper 发现链路修复
12 分钟
2487 字

客户端已经把实例写进 ZooKeeper,Admin 却判断服务不存在,或者一直收不到实例变化。问题究竟出在“没有注册上”,还是“根本没看同一个地方”?

Issue #6525 指向了后一种情况:同一个逻辑上的服务,在注册、查询、存在性检查和监听入口中,被当成了不同格式的路径。

我在 PR #7446 中处理了这条链路。但最后的修改并没有停在统一路径上。原生实例事件被接进 Admin 后,还需要补齐它能理解的地址、正确更新数据库,再把当前完整的实例列表同步出去。

这次让我花心思的,是把“注册成功”继续往后追:数据写进去了,后面的每一层是不是也在读同一份数据,并且真的用上了它?

服务名和完整路径,不能在不同入口各解释一遍h2

ShenYu 客户端注册原生 ZooKeeper 实例时,会把节点写到应用对应的父目录下。例如,应用 http_example 的实例位于 /shenyu/register/instance/http_example 之下。

但原来的几个读取入口并没有使用同一套规则:

  • selectInstances() 会拼接配置里的 watchPath,或者拼接默认实例根路径。
  • serviceExists() 直接读取传入的 key。
  • watchInstances() 也直接监听传入的 key。

这样一来,同样传入一个服务名,查询可能去实例目录读取,存在性检查和监听却去了另一处。Admin 在创建发现绑定时又会先检查服务是否存在,路径一错,后面的监听甚至还没开始就可能被拒绝。

我把三个入口收敛到同一个路径解析方法:短服务名补上实例根路径,以 / 开头的完整路径直接使用。

传入的 key实际读取或监听的位置
http_example/shenyu/register/instance/http_example
/shenyu/register/instance/http_example保持原路径,不重复拼接
/custom/service按完整路径使用,不自动改写到原生实例目录

这里也保留了一个容易遗漏的后续行为:子节点 watcher 需要重新注册时,如果事件没有提供路径,应该继续使用已经解析过的完整路径,不能又退回最初的短服务名。

注册方告诉 Admin 的,也得是实例真正所在的位置h2

只统一仓库方法还不够。客户端原先会把配置中的 registerPath 上报为 Admin 的 listener node,例如 /shenyu/discovery/http_example。但原生实例实际写入的是 /shenyu/register/instance/http_example,前者并不能代表实例所在的目录。

因此,这次在 ZooKeeper 实例注册完成后,将配置中的注册路径更新为真实的实例父路径,再交给已有的发现配置监听器上报 Admin。

这里沿用了现有的启动顺序:实例注册监听器先执行,发现配置上报监听器后执行。这样,Admin 收到的 listener node 与已经写入的节点对齐,不需要另造一套上报流程。

同时,ZooKeeper 客户端不再注入 watchPath,ZooKeeper 仓库也不再读取这个属性。其他注册中心原有的处理保持不变。

这个选择有明确的兼容性边界:旧的 watchPath 配置会被忽略;已有数据库绑定如果仍指向 /shenyu/discovery/...,需要改成实际实例目录,不会被这个 PR 自动迁移。 不能一边改变路径含义,一边假定旧配置会自己变正确。

收到了节点事件,还不代表 Admin 能读懂它h2

路径对齐后,还要看节点中的内容。

客户端写入 ZooKeeper 的原生 InstanceEntity 带有 host、port、权重和状态,但没有 Admin 上游数据使用的 url。如果把这份 JSON 原样转发过去,Admin 就缺少后续需要的 upstream_url。

我没有修改 ZooKeeper 中保存的实例格式,而是在转发原生实例事件时,根据 host:port 补齐 url,并保留已有的 address 回退。这个补齐只应用于 /shenyu/register/instance/ 下面的节点,其他子树的事件内容仍沿用原格式。

这是两层不同的数据契约:注册端能序列化自己的对象,不等于消费端就能把它当作上游对象使用。需要在它们衔接的地方,把差异明确处理掉。

还有一处隔离问题。同一份 discovery 配置可能被多个应用共享,但它们监听的服务目录并不相同。如果 Admin 只用 discovery ID 查找 listener,后来加入的应用就可能挂到另一个应用的监听器上。

因此,listener 的保存与查找一起改为使用 discovery ID 和被监听的服务 key。复用注册中心连接配置,不代表可以复用同一个应用的变化通知上下文。

事件处理完以后,同步的是当前列表,不是那一个变化节点h2

继续往 Admin 的持久化与同步逻辑看,还需要接上两处判断。

第一处是更新条件。原生事件不带数据库记录 ID,但原来的 UPDATE 却用 #{id} 匹配 discovery_handler_id,导致更新匹配不到对应记录。这次改为绑定实际设置过的 discoveryHandlerId,再按上游 URL 定位记录。

同时,原生事件也没有更新时间。更新执行前需要补齐 dateUpdated,否则修正匹配条件后,又会撞上数据库中 date_updated 的非空约束。权重或状态变化从 ZooKeeper 传过来,并不意味着写库这一步已经成立。

第二处是同步内容。事件里带的是发生变化的一个实例,但发给下游的 UPDATE 需要表达当前这个发现绑定下的实例列表,不能直接把事件里的单个节点当成整份列表。

例如,同一个应用有 A、B 两个实例:

节点变化处理后的实例列表应同步的快照
新增 BA、BA、B,而不只是 B
删除 BAA,而不是刚删除的 B
再删除 A空空列表,让下游清掉旧状态

因此,这次在处理新增、更新或删除之后,重新按 discovery handler 查询数据库,将当前完整上游列表放进同步数据,再发布事件。数据库对象转换为同步对象时,也补上原先漏掉的 namespaceId。

我需要区分的是“这次发生了什么”和“处理之后现在是什么”。它们都可能用一个列表表示,但不能因此当成同一种消息来发送。

用同一个实例,把注册、落库和 WebSocket 同步连起来验证h2

这次验证不能只停在“路径解析方法返回了预期字符串”。即使路径正确,事件格式、数据库参数或 listener 归属中的任何一处没接上,Admin 最后看到的状态仍然可能不对。

我补了一组本地 ZooKeeper 测试,用真实序列化的 InstanceEntity 检查查询,以及新增、更新、删除事件中的地址和权重。

随后在集成测试中,把客户端真实的 HTTP 发送器、测试 Admin、本地 ZooKeeper 和 WebSocket 接收端连起来,围绕两个应用检查整条路径:

  • 故意给第一个客户端配置旧的 /shenyu/discovery/... 路径,确认最终上报的是实际实例目录,旧路径没有被误当成服务。
  • 让 A、B 两个应用共享 discovery 配置,各自增加实例,确认数据库中的上游列表没有串到另一个应用。
  • 修改一个实例的权重,确认数据库更新,并在 WebSocket 消息中观察到对应权重。
  • 依次停止实例,确认删除后的完整快照、最后一个实例离开后的空列表,以及同步数据中的 namespace 信息。

这里观察的是实际注册请求、ZooKeeper 节点变化、测试数据库和发出的 WebSocket 数据,不只是用 mock 给各个方法分别塞一个“正确结果”。为了复用客户端发送器,Admin 新增的 shenyu-client-core 依赖只放在 test scope,没有进入生产 classpath。

维护者在 Review 中确认了注册和发现路径已对齐,也认可了多应用注册、更新、注销和旧路径避让的集成覆盖。对方在 Java 17 下针对当前提交运行目标测试后批准了 PR。

排查不能只停在注册成功h2

这次最具体的收获,是我不能只凭某一层的成功,就替后面的层下结论。

实例写进 ZooKeeper,证明注册动作完成了;监听到了事件,证明看对了节点;数据库更新成功、同步快照也正确,才把这个变化继续交了出去。中间每一层都可能有自己的 key、对象格式和状态含义。

我意识到,排查这类问题时,围绕同一个实例一路追下去,比把几个类分开看更容易发现断点。它在注册时叫什么、在哪个路径下,Admin 以什么身份接收它,最后又把怎样的列表发出去——这些问题需要能接成一条线。

测试也是这样。单独证明每个入口可以工作,并不自动证明它们使用的是同一套约定。把两个应用放在同一份配置里,再让实例新增、更新和离开,才更容易看见那些只有组合起来才会暴露的问题。

回头看,这次从一个路径不一致的问题出发,最后补的是几个模块之间没有说清楚的约定。我想记住的是这个排查方式:先找到一个具体的数据对象,再跟着它跨过每个边界,直到确认它真的变成了下一层需要的东西。