繁

一次实例查询为什么会留下持久监听?沿着 watcher 的创建、缓存读取与释放路径查下去,我选择让查询回到前缀读取,并保留显式订阅。记录 ShenYu PR #7435 的判断与验证。

查询返回了,监听却还在:Apache ShenYu etcd watcher 泄漏修复
10 分钟
1966 字

调用一次 selectInstances(),拿到实例列表之后,这次查询应该就结束了。但在 ShenYu 原来的 etcd 注册实现里,它还可能悄悄留下一个持续运行的 watcher。

Issue #6527 指出了这个问题:第一次选择一个服务 key 时,方法会创建 etcd 监听,却没有保存返回的 Watch.Watcher。同一个类里的取消订阅逻辑,只会处理另一份容器中登记过的 watcher,因此找不到这次查询留下的监听。

这里有两个修复方向:把它登记起来,让后续清理能找到;或者让查询不再创建这个监听。

我最后采用了后者,合并在 PR #7435。原因不是“删掉比管理起来简单”,而是继续看代码后,我发现这个 watcher 维护的数据,并没有被后续查询拿来返回。

watcher 没有登记,取消订阅就找不到它h2

这次涉及的类是 EtcdInstanceRegisterRepository,它既提供实例查询,也提供实例变化订阅。

显式调用 watchInstances() 时,代码会保留 watcher 句柄,放进 watchCache。随后 unWatchInstances() 可以按 key 找到它们,逐个关闭,再移除记录。

原来的 selectInstances() 则走了另一条路:读当前实例,创建一个前缀监听,让回调维护局部的节点集合和 watcherInstanceRegisterMap,最后返回实例列表。但 watchKeyChanges(...) 返回的 watcher 句柄被直接丢掉了。

两条路径放在一起,区别就比较清楚:

入口是否创建持续监听句柄由谁管理
原来的 selectInstances()服务 key 尚未记录时创建没有保存,无法通过 watchCache 按 key 释放
watchInstances()显式订阅时创建保存在 watchCache,供取消订阅和关闭流程处理

这里并不是串行查询同一个 key,就一定每次多建一个 watcher。旧实现会用 map 中是否存在这个 key 来跳过创建。但不同服务 key 的首次查询仍可能留下长期监听,而调用方只是想读一次当前列表。

仓库关闭时也会关闭整个 etcd client,所以不能把问题写成“进程关闭以后监听还永远存在”。真正缺失的是:查询创建了持久资源,却没有把它接进仓库按 key 管理和释放的路径,只能把生命周期拖到 client 关闭。

缓存一直在更新,查询却没有返回它h2

如果这个 watcher 确实在给查询提供最新缓存,那保存句柄、补齐释放路径,会是一个需要认真考虑的方向。

所以我没有只停在“这里少接了一个返回值”,而是继续看:监听维护的 map,究竟在哪里被读了?

旧代码在发现服务 key 已经存在时,会进入这段分支:

if (Objects.nonNull(watcherInstanceRegisterMap.get(selectKey))) {
return getInstanceRegisterFun.apply(client.getKeysMapByPrefix(watchKey));
}

它判断了缓存是否存在,却没有返回缓存里的实例列表,而是再次按前缀读取 etcd,再做 JSON 转换。

第一次查询也会读 etcd,之后 watcher 持续处理 PUT、DELETE,更新内部数据。可下一次选择实例时,返回值依然来自新的一次前缀读取。这个 map 在查询路径上承担的,只剩“这个 key 是否已经初始化过”的标记作用。

读到这里,我才有依据选择另一种修复:既然实例选择本来就直接读 etcd,就让它只做这件事,不再为一份没有用于返回结果的缓存维持监听。

删掉隐式监听,不等于取消实例订阅h2

最终修改集中在 selectInstances():构造实例父路径,按前缀读取当前节点,将 JSON 转成 InstanceEntity,补齐 URI 后返回。

原来的 watcherInstanceRegisterMap、局部节点集合和监听回调一起移除,不再产生需要额外释放的 watcher。

但类里的显式订阅没有一起删掉。watchInstances() 仍然读取初始实例、注册监听并保存句柄;unWatchInstances() 和 close() 仍然处理这些已登记的监听。

我需要分开的,是两种不同的调用意图:查询回答“现在有哪些实例”,订阅负责“之后发生变化时通知我”。这次移除的是查询附带的隐式订阅,不是服务发现本身的变化通知能力。

数据转换也保持原样。比如没有 HTTPS 信息时补出的 HTTP URI,以及已有 HTTPS 信息时的 URI 处理,都继续沿用原逻辑。这不是一次顺手调整注册协议或 URI 推断方式的修改。

只测“列表能返回”,还抓不住这次回归h2

这个 Bug 有个容易漏掉的地方:即使多创建了 watcher,查询仍然可能返回正确的实例列表。如果测试只断言列表内容,旧实现也能看起来正常。

因此,我补的回归测试直接检查副作用:连续查询同一个服务两次,确认发生了两次前缀读取,但一次 watchKeyChanges() 都没有调用。

这条断言放回旧实现会失败,因为第一次查询仍然会创建监听。它检查的不是方法“能跑完”,而是这次要删除的行为确实不再发生。

另外两组验证关注删除之后留下来的能力:

  • 修改模拟的 etcd 节点集合,依次检查新增、属性更新、删除和清空后的查询结果,也检查 HTTP / HTTPS URI。这样可以确认新实现每次都读取当前数据,而不是误留了一份旧快照。
  • 先建立一个显式订阅,再调用实例查询。确认没有额外创建 watcher,也没有关闭原有 watcher;随后向捕获的监听器传入 PUT 事件,检查业务 listener 仍能收到通知。

这几组用例使用 Mockito 控制读取结果和事件。对这次修改而言,我想分别守住的是:查询不再偷偷订阅,查询结果仍然新鲜,而主动订阅的人仍能继续收到变化。

维护者在 Review 中确认了这个方向:把实例选择变成普通的前缀读取,移除泄漏,同时保留显式 watcher 的行为。对方还在 Java 17 下针对这次提交运行了目标 Maven 测试,并批准了 PR。

管好资源之前,也值得先问它是否应该被创建h2

我意识到,排查资源泄漏时,除了找释放的位置,还得先问一句:这个资源真的需要创建吗?

看到句柄没有保存,很自然会想到补一个容器,把它登记起来。但那样解决的是“创建之后怎么管理”,还没有回答“这个入口为什么需要创建它”。

继续追缓存的读取位置后,我意识到,保留一套机制也有成本:它需要接收事件、维护状态,还需要明确的释放时机。如果它没有参与调用方真正使用的结果,补齐管理逻辑之前,应该先确认它还有没有存在的必要。

这次的测试也让我更具体地理解了“行为”不只有返回值。一个方法返回了正确列表,却顺带创建了一个不该有的长期监听,仍然是有问题的。以后遇到这类代码,我会一起看它留下了什么,而不只是拿回了什么。

这次没有再为 watcher 补一套管理逻辑,而是把查询和订阅分开:需要当前列表时就读取,需要持续接收变化时再订阅。不必要的监听不再创建,也就不用考虑怎样释放它。