一次實例查詢為什麼會留下持久監聽?沿着 watcher 的創建、緩存讀取與釋放路徑查下去,我選擇讓查詢回到前綴讀取,並保留顯式訂閲。記錄 ShenYu PR #7435 的判斷與驗證。
調用一次 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 補一套管理邏輯,而是把查詢和訂閲分開:需要當前列表時就讀取,需要持續接收變化時再訂閲。不必要的監聽不再創建,也就不用考慮怎樣釋放它。