实例已经写进 ZooKeeper,Admin 为什么仍然找不到?从注册与监听路径不一致,追到事件格式、应用隔离、数据库更新和完整快照,记录 ShenYu PR #7446 的修复与验证。
客户端已经把实例写进 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 两个实例:
| 节点变化 | 处理后的实例列表 | 应同步的快照 |
|---|---|---|
| 新增 B | A、B | A、B,而不只是 B |
| 删除 B | A | A,而不是刚删除的 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 以什么身份接收它,最后又把怎样的列表发出去——这些问题需要能接成一条线。
测试也是这样。单独证明每个入口可以工作,并不自动证明它们使用的是同一套约定。把两个应用放在同一份配置里,再让实例新增、更新和离开,才更容易看见那些只有组合起来才会暴露的问题。
回头看,这次从一个路径不一致的问题出发,最后补的是几个模块之间没有说清楚的约定。我想记住的是这个排查方式:先找到一个具体的数据对象,再跟着它跨过每个边界,直到确认它真的变成了下一层需要的东西。