从 discovery upstream 缓存残留,到第一次面对方案级质疑:记录我如何追查删除链路、对照另一个 PR,并用事件来源和测试讲清 instance 与 selector 的边界。
刚接下 Issue #6479 时,我以为自己要补的,只是一次遗漏的缓存删除。
Admin 删除了一个绑定服务发现的 selector,Gateway 侧却还留着它的 discovery upstream 状态。看上去,无非是找到少写的那个 remove(),再补一个测试。
但顺着代码往下追,我发现这个“删除”要经过 Admin、同步模块、Subscriber,再交给具体插件。每一层都能收到消息,不代表最后真的有人把状态清掉;不同插件用来找缓存的 key,也不一定是同一个。
最后,PR #7289 改了 31 个文件。可现在回头看,我最记得的反而不是改动范围,而是后来的一次 Review:维护者认为这条删除链路的设计可能不对,建议重新按快照同步来处理。
那一刻我确实有点慌。我还没有遇到过这种针对整个方案的质疑,第一反应就是:是不是我从一开始就理解错了?后来我重新读另一个 PR、追事件来源,还反复跑了很多遍测试。即使越来越觉得问题出在事件范围的理解上,我也没有马上就敢回应。这篇想记录的,是我怎么一边担心自己漏了什么,一边继续查证,最后才鼓起勇气把自己的判断讲清楚。
收到了删除事件,不代表状态真的被清掉了h2
先说这次要解决的场景。这里的 selector 可以理解为一条选择请求、关联后端服务的配置;discovery upstream 则是它通过服务发现得到的后端实例信息。Admin 侧解绑或删除相关配置后,Gateway 不应该继续保留这份运行时状态。
我一开始只盯着同步入口,后来才把整条调用链连起来看。
在最终对照的代码里,path-based sync 已经能从删除节点的 path 里拿到 pluginName 和 selectorId,并调用取消订阅。更明确的断点在后面:CommonDiscoveryUpstreamDataSubscriber#unSubscribe() 原来只有一行 //ignore。
也就是说,删除消息可以传到 Subscriber,却没有继续交给插件清理。HTTP 全量同步还有另一种遗漏:最新快照里不再出现的 selector,也需要被识别出来,而不能只处理这一次还存在的记录。
我这才意识到,排查这类问题不能只问“事件有没有到”。还要继续看:到了以后,谁负责删?删的是哪一份状态?
这次沿用了已有的 Subscriber → Handler 分发结构,把 selector 级别的 discovery upstream 删除继续传到拥有状态的插件。没有让同步层去判断“这是 Divide 就删这个,是 gRPC 就删那个”。
Divide、WebSocket 主要清理 UpstreamCacheManager;gRPC 还涉及 ApplicationConfigCache 和 GrpcClientCache;TCP 又有自己的 upstream 状态和名称映射。同步层负责把“谁消失了”传清楚,具体怎么清,还是交给各自的 Handler。
改动范围就是这样一点点变大的。补了接口,就要跟着改调用方和插件实现,再把对应测试补上。我原来以为只要找一个遗漏的删除,最后才发现,得沿着整条责任链把它接起来。
删除需要的是身份,不是一份填不全的数据h2
原来的接口是 unSubscribe(DiscoverySyncData data)。可真正删除时,需要的往往只是 pluginName、selectorId,以及部分插件要用的 selectorName。
DiscoverySyncData 表达的是一份同步数据。为了删一个资源,却要构造一个很多字段都为空的 DTO,我写着写着就觉得不太顺:到底是在传一份数据,还是只想告诉对方“删掉谁”?
所以这次引入了 DiscoveryUpstreamKey,专门表达删除身份:
public record DiscoveryUpstreamKey( String pluginName, String selectorId, String selectorName) {}这是字段结构的摘录,省略了从同步数据提取 key 的方法。selectorName 可以为空,具体 Handler 再根据自己保存的状态解析。
这也让我第一次比较具体地理解了“接口语义”。不只是给类起一个更好听的名字,而是让调用方不用拿“更新内容”来勉强表达“删除身份”。这个方向后来也得到了 Reviewer 的认可。
TCP 让我多追了一步:收到的 key 和保存的 key 一样吗?h3
TCP 的问题不在删除方法本身有多复杂,而是同步事件主要带着 selectorId,部分 upstream 状态却按 selectorName 保存。
拿到 ID,不代表就能找到按名称存的缓存。于是我补了 selectorId → selectorName 的映射;删除时先查本地映射,找不到再用 key 里带的名称。
这份映射也不能只等普通 selector event 来建立。Gateway 重启后,discovery upstream 数据可能先恢复,所以 TcpUpstreamDataHandler 处理这份数据、确认缓存存在时,也会注册映射。清理 upstream 时,再把对应映射一起移除。
如果我只看 removeDiscoveryUpstreamData() 的几行实现,很容易以为删除已经完整了。继续问“这个 key 从哪里来,重启以后还找不找得到”,才会看到另一个问题。
同样叫同步,删除信息却不一定长得一样h2
沿着各条同步路径排查时,我发现不能要求它们都带着一份完整的“删除数据”。节点都已经删了,payload 很可能也不在了。
ZooKeeper 的 NODE_DELETED 事件里,newData 可以是 null,需要从 oldData 取得原来的 path。项目里已有这层处理,这次我补了 discovery upstream 的回归测试,确认即使没有新 payload,仍能根据旧节点路径传出正确的删除身份。
path-based sync 可以从 .../discoveryUpstream/<plugin>/<selectorId> 的末尾两段恢复身份;node-based sync 则从对应的节点 key 解析。Nacos、etcd、Consul、Polaris、Apollo 也沿用这些共享处理路径,不是每个协议都要再写一套独立的插件清理逻辑。
HTTP 更不同:它拿到的是全量快照,不会为每个消失的 selector 另发一次 DELETE。因此 DiscoveryUpstreamDataRefresh 要保存上一份身份快照,再和当前快照比较。
| 快照变化 | 要处理的状态 |
|---|---|
[S1, S2] → [S2] | 取消订阅 S1,保留并更新 S2 |
[S1] → [] | 取消订阅原来的 S1 |
同一 ID,old-name → new-name | 先清旧名称对应的状态,再订阅新数据 |
HTTP 比较用的身份包含 namespace、plugin 和 selector ID,不只是一个裸 ID。名称变化也要单独检查,因为 TCP 的旧名称可能仍然对应着旧缓存。
这些情况最后都收敛到 unSubscribe(DiscoveryUpstreamKey)。我慢慢理解了:同步协议可以用不同方式告诉我“它不在了”,但到了 Subscriber,删除的对象和责任必须明确。
先弄清楚删的是谁,才能讨论该走 UPDATE 还是 DELETEh2
我原来以为,这个 PR 最费劲的部分会是跨模块修改。后来维护者拿它和 PR #7172 对照,提出了一个更根本的问题:discovery upstream 应该用完整快照同步,删除一个实例后重新发布剩余列表,为什么还要补一条 DELETE 链路?
这个担心是有道理的。实例从 [A, B] 变成 [B],应该发布剩余实例的 UPDATE 快照;最后一个实例没了,也应该是 UPDATE [],而不是把整个 selector 当成不存在。
但我当时最难受的,是这不再是“这里少一个判断”或者“补一个测试”。如果判断成立,前面连起来的整条链路都可能需要重新设计。
我第一反应没有去反驳,而是先想:会不会真的是我把删除语义理解错了?可同时又有一点说不上来的疑问:#7172 和 #7289,删的好像不是同一种东西。
于是我没有立刻照着建议改代码,而是重新去找两个事件的生产者。
#7172 讨论的是 selector 内部的实例变化:Registry 的 ADDED、UPDATED、DELETED 先在 Admin 更新数据库,再查询完整剩余列表,发布 DISCOVER_UPSTREAM UPDATE。这是我认同的实例级快照模型。
而 #7289 接住的,是项目里原本就存在的 selector 级 discovery 删除事件:SelectorServiceImpl#unbindDiscovery 调用 DiscoveryProcessor#removeSelectorUpstream,发布 DISCOVER_UPSTREAM DELETE。这次没有把 Registry 的单实例删除改成 DELETE,也没有替换已有的 SELECTOR DELETE 或 PROXY_SELECTOR DELETE。
把两个场景放在一起以后,我才有把握说:我们当时讨论的是两个不同的生命周期对象。
最容易混淆的恰好是那个 []。S1: [] 表示 S1 的 discovery 记录还在,只是没有实例;[S1] → [] 表示当前 discovery 快照连 S1 这份记录都没有了。外观看起来都是“空了”,后续行为却不能一样。
我也在回复里把边界补清楚:WebSocket 的 MYSELF / REFRESH 重连对账是另一个问题,这个 PR 处理 DELETE,并没有顺便实现一套新的重连 reconciliation 算法。
把判断查清楚,才有勇气把话说出来h2
找到两个生命周期的差别以后,我并没有立刻就有底气去回应。对方比我熟悉项目,而我还是一个学生。我很担心:会不会只是自己读到的那几段代码能对上,放回整个项目里,其实还有我没看到的路径?
所以那次准备答复时,我整理的不只是自己的 diff,还包括另一个 PR、已有的事件生产代码,以及两边的测试。我反复跑了很多遍相关测试,包括 #7172 的 DiscoveryDataChangedEventSyncListenerTest、UpstreamCacheManagerTest,和 #7289 的 DiscoveryUpstreamDataRefreshTest、DivideUpstreamDataHandlerTest。跑通以后,还要回头看断言到底在验证什么,和我准备说出的结论是不是同一回事。
我也借助了 DeepSeek、Gemini、GPT 和 GLM,让不同模型一起辅助审查我的理解和方案。我当时很想确认,自己不是因为写了这段代码,就只看到了支持自己判断的部分。多换几个角度检查,至少能让我继续问:还有没有遗漏的边界?有没有哪一步是我想当然了?
模型的分析帮我多检查了几遍,但真正让我慢慢敢回应的,还是能回到代码里找到事件来源,能把测试结果和具体场景对上。我需要的不只是一个“你的理解没问题”的回答,而是自己也能解释清楚:为什么这里是 selector 级删除,为什么它没有改变实例级 UPDATE 的路径。
我把这些重新整理成几个可以逐项核对的判断:
| 我需要说明的边界 | 对应的证据 |
|---|---|
| 单个实例删除仍然走 UPDATE | Registry 事件的生产路径,以及 #7172 的实例快照测试 |
| selector 级 DELETE 不是这次新造的事件 | unbindDiscovery → removeSelectorUpstream 的已有调用链 |
| HTTP 能识别消失的 discovery 记录 | #7289 的 [S1, S2] → [S2]、[S1] → [] 测试 |
| 取消订阅确实落到了插件状态 | 对应 Handler 的缓存清理测试 |
准备这些证据,不只是为了让别人更容易复核,也是为了让我自己敢把话说出来。如果只是凭感觉说“这两个 PR 不一样”,我会很不踏实;把调用链和测试放在一起以后,我才觉得自己可以认真解释这个区别。
即便如此,回应时我还是很小心。我不想让讨论变成一句“你理解错了”,也担心自己表达不好,让对方觉得我只是舍不得改已经写好的代码。所以我先说明自己认同实例级完整快照的模型,再解释 #7289 处理的是另一层的删除,把依据和没有覆盖的范围一起写清楚,也同步澄清了 PR 描述。
真正把回复发出去时,我还是有点紧张。只是反复核对之后,我觉得不能一直停在“可能是我错了”这里。如果我查到的事实确实支持这个判断,就应该鼓起勇气说出来,也让别人有机会继续检查它。
后来维护者在后续回复里确认,之前对事件范围有误解:instance 变化走完整 UPDATE 快照,#7289 处理的是 selector-scoped DELETE。他会按这个区分重新看 PR。
看到那条回复时,我确实松了一口气。前面一直担心自己是不是漏了什么,也担心这次回应会不会显得不够谨慎。对方愿意按澄清后的边界重新看 PR,让我觉得这番反复查证和认真组织的解释没有白费,原来卡住的讨论终于能继续了。
失败怎么被看见,也是方案的一部分h2
这次 Review 不只有事件范围的争议。有两个工程取舍,也让我记得很清楚。
批量删除可以失败,但不能让用户不知道发生了什么h3
Admin 发布删除事件前,需要解析出 pluginName。名字缺失时,不能继续构造一个地址不完整的 discovery upstream 路径。
我最开始选择直接抛 IllegalStateException。Reviewer 指出,一个 selector 的元数据异常,会让整个批次回滚;如果最后只给用户一个内部异常,他既不知道哪些数据动过,也不知道怎么重试。
讨论里有两种选择:跳过异常 selector,继续处理其他项;或者保留批次原子性,但用能映射到清晰错误响应的异常说明失败。
我最后选了第二种。先校验整个批次,再删除关联数据、发布事件;如果插件名经过补充查询仍无法解析,就抛 ShenyuAdminException,指出有问题的 selector,并说明本批次没有任何 selector 被删除,需要恢复插件名后再试。
以前我很容易觉得“加了异常,安全性就有了”。这次我开始意识到,还要站在使用者那边看一眼:他看完这个错误,知不知道数据现在是什么状态,下一步该做什么?
接口更清楚,不代表兼容性成本就消失了h3
unSubscribe(DiscoverySyncData) 改成 unSubscribe(DiscoveryUpstreamKey),语义确实更准确。但这是公共 SPI,不是只在一个类里改个私有方法。
仓库内部实现和调用点全部迁移,不能代表下游实现也能直接继续用。这次修改有源代码和二进制兼容性影响,最后明确写进了 RELEASE-NOTES.md。
我保留了这个接口选择,但也需要承认它的代价。方案不能只解释“为什么这样更好”,还要说明“别人要为这个变化做什么”。
CI 红灯,又把我带到了另一个问题h2
维护 #7289 的过程中,我还需要跟进 master、处理冲突和检查 CI。跨模块改动不能只靠自己读一遍代码就放心,Reviewer 也需要能检查的验证结果。
后来 k8s-examples-http 的安装步骤失败,我没有重跑权限,就继续往 workflow 里查。最后发现,curl -sfL ... | sh - 的下载失败可能被 pipeline 的退出状态掩盖,写好的重试并没有接住失败。
我把那个问题拆成了 Issue #7379 和 PR #7380,没有把无关的 CI 修复都塞进 discovery 删除的 diff。
那段经历已经写在《CI 红了,不一定是代码错了》里。对我来说,它不是完全独立的另一件事,而是维护这个 PR 时,从“怎么又红了”一路追出来的。
把方案交出去,也要把上下文讲清楚h2
这个 PR 之后,我对“把一个改动做好”的理解,多了一点以前没有认真想过的东西。原来我更多盯着自己的代码:问题有没有修掉,测试能不能过,Review 提到的地方有没有改完。后来才发现,把代码写出来以后,还要让别人能理解,我为什么选择这样处理。
这次争议也让我回头看了自己的表达。我顺着代码追了很久,已经习惯把“实例删除”和“selector 级 discovery 删除”分开理解,写说明时却容易默认读者也有同样的上下文。维护者从另一个 PR 的快照模型看过来,关注的就可能是另一种删除。光把调用链列出来,并不一定能让这个区别变得明显。
以后再介绍一个方案,我想先把场景说清楚:这次消失的是什么,哪些状态还在,我的修改负责到哪一步。不是一上来就解释新增了什么接口,而是先让读者知道,为什么这里需要这个接口。以前我觉得这些是写完代码以后的说明,现在觉得,它们本来就是把一个 PR 交出去的一部分。
我对 Review 的感觉也变了一点。以前有点像等人批改作业:对方指出问题,我就想着赶紧改好,别给别人添麻烦。这次讨论让我发现,我也需要把自己掌握的上下文带进去。有人帮我看到没想周全的地方,我也可以补上对方暂时没有看到的部分,最后一起判断这个改动该怎么往前走。
我还是会担心自己经验不够,也不会因为这一次解释清楚了,就觉得以后都能判断准确。但至少,参与讨论不一定要等到自己已经很懂整个项目。对自己说出的判断认真负责,把知道的讲清楚,把不确定的留出来,也是我现在能做的一件事。
回头看,这次让我多了一点信心的,不是“我也能指出维护者的误解”,而是我开始觉得,自己可以认真参与一次技术讨论。还会紧张,还会怕漏掉什么,但不再只把自己放在等着接受修改意见的位置上。这是我想从这次经历里留下来的变化。