简

從 discovery upstream 緩存殘留,到第一次面對方案級質疑:記錄我如何追查刪除鏈路、對照另一個 PR,並用事件來源和測試講清 instance 與 selector 的邊界。

一個 DELETE 為什麼改了 31 個文件:Apache ShenYu 刪除鏈路與一次方案澄清
31 分鐘
6136 字

剛接下 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 就刪那個”。

圖 1 · 刪除消息走到插件,才真正落到狀態清理
selector 級刪除責任鏈 Admin 解綁 selector 級 discovery,發佈已有的 DISCOVER_UPSTREAM DELETE。同步模塊將資源身份傳給 Subscriber,由 Common Subscriber 根據插件名分發給 Handler。Divide 和 WebSocket 清 upstream 緩存,gRPC 清緩存及客户端,TCP 清 upstream 狀態並處理 ID 與名稱的映射。 Admin 解綁 selector 級 discovery 發佈已有的 DISCOVER_UPSTREAM DELETE 同步模塊傳遞刪除身份 DiscoveryUpstreamKey Subscriber.unSubscribe(key) Common Subscriber 按 pluginName 分發 Handler.removeDiscoveryUpstreamData(key) Divide / WebSocket 清 upstream 緩存 gRPC 清緩存與客户端 TCP 清 upstream 與身份映射
圖中是 selector 級 discovery 刪除,不是單個實例下線。小屏可在圖內左右滑動。

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。

把兩個場景放在一起以後,我才有把握説:我們當時討論的是兩個不同的生命週期對象。

圖 2 · 兩邊都有“刪除”,但消失的不是同一種對象
instance 與 selector 生命週期對比 左側是 PR 7172 的實例級快照方案:selector S1 仍然存在,實例 A 消失後發佈剩餘列表 B,最後一個實例消失時發佈空列表。右側是 PR 7289:selector 級 discovery 記錄移除,已有 DELETE 事件或 HTTP 快照差異觸發取消訂閲,清理該記錄的運行時狀態。 #7172 · instance lifecycle #7289 · selector lifecycle S1 還在,實例發生變化 [A, B] → [B] / [A] → [] S1 的 discovery 記錄移除 解綁,或從 HTTP 快照中消失 Admin 發佈完整 UPDATE 剩餘實例列表可以為空 已有 DELETE / HTTP 差異 識別被移除的 selector 身份 onSubscribe(snapshot) 按新快照更新實例狀態 unSubscribe(key) 插件清理這份 discovery 狀態 有記錄,但當前沒有實例 S1: [] 不是 S1 消失 當前快照已沒有這份記錄 [S1, S2] → [S2]
左邊是 #7172 的方案模型,不代表該 PR 已合併;兩邊關注不同層級的狀態。

最容易混淆的恰好是那個 []。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 的路徑。

我把這些重新整理成幾個可以逐項核對的判斷:

我需要説明的邊界對應的證據
單個實例刪除仍然走 UPDATERegistry 事件的生產路徑,以及 #7172 的實例快照測試
selector 級 DELETE 不是這次新造的事件unbindDiscovery → removeSelectorUpstream 的已有調用鏈
HTTP 能識別消失的 discovery 記錄#7289 的 [S1, S2] → [S2]、[S1] → [] 測試
取消訂閲確實落到了插件狀態對應 Handler 的緩存清理測試
圖 3 · 不是先決定誰對,而是先把判斷查清楚
方案質疑後的查證與回應 遇到方案級質疑,先重新追事件來源、對照相關 PR 和測試。發現實現確實有問題就修改並補驗證,發現上下文理解不同就説明對象和邊界。兩種回應都要給出可複核的證據,再繼續共同評審。 Reviewer 對方案提出疑問 先重新驗證自己的判斷 事件來源 / 相關 PR / 調用鏈 / 測試 實現確實有問題 修改,並補對應驗證 討論的上下文不同 説明對象、語義和邊界 給出可複核的證據,繼續共同評審

準備這些證據,不只是為了讓別人更容易複核,也是為了讓我自己敢把話説出來。如果只是憑感覺説“這兩個 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 的感覺也變了一點。以前有點像等人批改作業:對方指出問題,我就想着趕緊改好,別給別人添麻煩。這次討論讓我發現,我也需要把自己掌握的上下文帶進去。有人幫我看到沒想周全的地方,我也可以補上對方暫時沒有看到的部分,最後一起判斷這個改動該怎麼往前走。

我還是會擔心自己經驗不夠,也不會因為這一次解釋清楚了,就覺得以後都能判斷準確。但至少,參與討論不一定要等到自己已經很懂整個項目。對自己説出的判斷認真負責,把知道的講清楚,把不確定的留出來,也是我現在能做的一件事。

回頭看,這次讓我多了一點信心的,不是“我也能指出維護者的誤解”,而是我開始覺得,自己可以認真參與一次技術討論。還會緊張,還會怕漏掉什麼,但不再只把自己放在等着接受修改意見的位置上。這是我想從這次經歷裏留下來的變化。

相關鏈接h2