記錄我第一次在 Apache ShenYu 中完整處理一個 Issue 的過程,以及讀代碼、修復問題時的一些收穫。
這是我第一次比較完整地在 Apache ShenYu 這樣規模的開源倉庫裏處理一個 Issue。這次遇到的是 Issue #6781:HTTP 同步收到空的 ProxySelector(代理選擇器)列表後,Gateway 沒有及時清理原有狀態,可能留下已經不再需要的 TCP 服務。
剛看到空列表分支直接 return 時,我以為補一次 refresh() 調用就能解決。但順着代碼往下讀,才發現事情沒有這麼簡單:刷新請求經過 Subscriber 後,並沒有繼續傳到真正負責關閉 TCP Server 的 Handler。 要修復這個問題,我還得弄清楚這幾個模塊是怎麼配合的。
最後,修復通過 PR #6909 合併。這篇文章想記下從“一行調用”繼續往下追的過程,也聊聊第一次在不熟悉的開源項目裏改代碼時,我學到了什麼。
配置刪空,TCP Server 為什麼還在?h2
先看一下這次問題涉及的配置和運行狀態。Apache ShenYu 是一個基於 Java 的 API 網關,支持多種協議代理和動態配置。
其中,TCP 插件可以通過 ProxySelector(代理選擇器)配置監聽端口、負載均衡策略及上游服務等信息。管理員在 Admin 中修改相關配置後,這些配置可以同步到 Gateway,並反映到實際運行的 TCP 代理服務中。
正常情況下,我們希望配置和運行狀態保持一致。例如,Admin 中刪除某個代理選擇器後,Gateway 應當不再保留對應的 TCP 服務。
問題發生在配置同步返回空列表的時候。
假設最初存在兩個代理選擇器:
Admin: ProxySelector A ProxySelector B
Gateway: TCP Server A TCP Server B隨後,管理員刪除了全部代理選擇器。下一次 HTTP 全量同步返回的 ProxySelector 數據變成空列表(下面只是簡化示意,不是原始響應的完整格式):
{ "PROXY_SELECTOR": { "data": [] }}按照預期,Gateway 應當識別到這組配置已經為空,並清理原有運行狀態。
但原來的 ProxySelectorRefresh 在檢測到空列表後,只記錄了一條日誌,然後直接 return。同步入口知道配置已經為空,卻沒有把清理動作傳遞給真正保存運行時狀態的組件。
看到這裏,我意識到,空列表也有它的含義。如果這次全量同步是成功的,那麼返回空列表就表示當前已經沒有任何代理選擇器,而不是“不需要處理”。
如果程序把空列表理解成“不需要處理”,運行狀態就可能與 Admin 的配置出現偏差。
追蹤 refresh():調用鏈在哪裏斷開?h2
明確問題後,我主要沿着這條調用鏈檢查:
ProxySelectorRefresh ↓ProxySelectorDataSubscriber ↓CommonProxySelectorDataSubscriber ↓ProxySelectorDataHandler ↓TcpProxySelectorDataHandler ↓TcpBootstrapFactory ↓BootstrapServer這裏涉及兩個不同的層次。
第一個層次是數據同步。ProxySelectorRefresh 負責接收同步數據,並通知訂閲者。
第二個層次是插件的運行時狀態。具體插件的 Handler 負責管理對應的代理服務,而 TCP 插件通過 TcpBootstrapFactory 緩存實際的 BootstrapServer。
問題是,這兩層之間的清理行為並沒有完全連接起來。
第一處問題:空列表沒有觸發刷新h3
原本 ProxySelectorRefresh 的空列表分支會直接返回。修復這部分只需要在返回之前通知所有訂閲者:
proxySelectorDataSubscribers.forEach( ProxySelectorDataSubscriber::refresh);但如果修改只停留在這裏,問題仍然沒有完全解決。
第二處問題:refresh 是空實現h3
繼續查看 ProxySelectorDataSubscriber,我發現它雖然定義了 refresh(),但默認實現沒有任何操作。而 CommonProxySelectorDataSubscriber 原本也沒有把刷新請求傳給實際負責資源管理的 Handler。
換句話説,即使 HTTP 層觸發了刷新,插件內部也不會自動清除已有的 TCP 服務。
因此,真正需要解決的問題不只是“有沒有調用 refresh()”,而是:
這個清理事件能否沿着完整的調用鏈,最終到達負責管理資源的組件?
追到這裏,我才明白,不能只看入口有沒有調用這個方法,還得繼續看它最後做了什麼。
修復設計:沿原有架構傳遞清理事件h2
確認調用鏈後,我沒有選擇讓 HTTP 同步模塊直接操作 TCP Server,而是繼續使用 ShenYu 原有的 Subscriber 和 Handler 結構。
這樣,數據同步模塊只需要表達“當前配置需要清空”,至於如何清理,則交給對應的插件。
給 Handler 增加統一的刷新入口h3
首先,在 ProxySelectorDataHandler 接口中增加一個默認的 refresh() 方法。
這裏使用 default 方法,是為了不讓現有 Handler 都跟着改。需要清理資源的實現,比如 TCP Handler,再覆蓋這個方法。
寫到這裏,我開始意識到,在已有項目裏補一個接口方法,不能只看自己的代碼能不能用,還要看看其他實現會不會受影響。當然,空默認實現不會自動清理資源,有狀態的 Handler 仍然需要實現自己的清理邏輯。
由 CommonSubscriber 負責分發h3
接下來,修改 CommonProxySelectorDataSubscriber.refresh(),讓它遍歷 handlerMap,調用對應的 Handler:
handlerMap.values().forEach( ProxySelectorDataHandler::refresh);這樣,清理邏輯就不再固定於 HTTP 同步模塊,而是被放到了已有的插件處理結構中。
這樣,HTTP 層不需要知道 TCP 服務該怎麼關閉,只需要把刷新請求傳下去,具體清理由插件負責。這次我主要處理的,還是 Issue 中的 HTTP 空快照場景。
緩存清理:移除引用不等於釋放資源h2
接下來就是 TCP 插件的資源釋放。
在 TcpBootstrapFactory 中,項目使用 ConcurrentHashMap 保存代理選擇器與 TCP 服務實例之間的對應關係:
selectorName → BootstrapServer
tcp-proxy-a → BootstrapServer Atcp-proxy-b → BootstrapServer B最簡單的做法似乎是直接調用 cache.clear()。但這裏有一個問題:從 Map 中刪除引用,並不等於真正停止 TCP 服務。
BootstrapServer 是運行中的服務實例,可能涉及監聽端口以及其他網絡資源。僅清空容器,並不會自動調用它的 shutdown()。
如果想確保 TCP 代理選擇器被清理,就需要完成兩個動作:
- 將實例從緩存中移除。
- 對移除的實例執行關閉操作。
因此,我在 TcpBootstrapFactory 中增加了 clearCache() 方法,統一處理這兩件事。
為什麼使用條件刪除?h3
這裏沒有采用單純的遍歷再按 key 刪除,而是使用類似下面的邏輯:
if (cache.remove(selectorName, bootstrapServer)) { // 关闭当前移除成功的实例}ConcurrentHashMap.remove(key, value) 的意義是:只有當前 key 對應的值仍然是這個實例時,才執行刪除。
考慮一個併發場景:線程 A 正在執行全量清理;與此同時,線程 B 更新了某個代理選擇器,把舊服務替換成新服務。
如果線程 A 直接按照遍歷時拿到的 key 刪除,就可能誤刪線程 B 剛剛更新的實例。條件刪除可以避免這種特定情況:發現當前值已經改變時,線程 A 不再刪除新值。
同時,只有成功移除緩存項的線程才繼續關閉對應實例,也減少了併發清理時重複關閉同一對象的風險。
不過,條件刪除保護的是當前這條緩存項,並沒有把整個全量刷新變成原子操作。
如果一個 TCP Server 關閉失敗呢?h3
假設當前存在三個服務:
Server AServer BServer C如果在關閉 Server B 時拋出異常,直接讓異常中斷整個遍歷,就可能導致 Server C 沒有機會執行清理。
因此,clearCache() 會在每個實例的 shutdown() 外捕獲 RuntimeException,記錄錯誤並繼續處理其他實例。這樣可以避免單個實例的關閉異常阻斷其他實例的清理。
關閉失敗的實例仍可能有資源沒有釋放。這次先記錄異常,讓其他服務繼續清理,沒有再把重試和資源恢復一起加進來。
單條刪除:check-then-act 的競態h2
除了全量清理,這次還修改了 TcpProxySelectorDataHandler.removeProxySelector()。
原來的實現會先調用 inCache() 判斷某個選擇器是否存在,再調用 removeCache() 獲取實例並關閉。乍一看沒有問題,但併發情況下,這兩個操作不是原子的。
例如:
线程 A:检查 selector 是否存在 → true线程 B:清理并移除 selector线程 A:再次 removeCache() → null线程 A:调用 shutdown() → NullPointerException這裏就是典型的 check-then-act 問題。
ConcurrentHashMap 能保證單次操作的線程安全,但無法自動保證兩個獨立操作之間的狀態不變。
修復思路也比較直接:不再依賴之前的存在性判斷,而是直接嘗試移除,隨後判斷返回值是否為空。只有確實取得 BootstrapServer 實例時才調用 shutdown()。
這樣既處理了併發移除導致的空指針風險,也讓重複刪除不存在的選擇器成為安全的空操作。
這是修復過程中順帶發現的問題。改動不大,卻讓我意識到,不能因為用了 ConcurrentHashMap 就覺得這段邏輯一定安全。單次操作是線程安全的,但把“先判斷、再刪除”放在一起,中間還是可能被其他線程插進來。
迴歸測試:驗證事件傳遞與關閉調用h2
這次 PR 增加了多層測試,而不是隻檢查 ProxySelectorRefresh 有沒有調用訂閲者。
| 測試位置 | 驗證內容 |
|---|---|
ProxySelectorRefreshTest | 空列表觸發刷新,非空列表仍執行訂閲 |
CommonProxySelectorDataSubscriberTest | 刷新事件能傳遞到各 Handler |
TcpProxySelectorDataHandlerTest | 驗證 shutdown() 調用、緩存移除和關閉異常隔離 |
HttpSyncDataServiceTest | HTTP 同步路徑能觸發 ProxySelector 刷新 |
其中,我覺得比較有意義的是關閉異常測試。
測試中放入兩個模擬的 BootstrapServer,讓其中一個在 shutdown() 時主動拋出異常,然後檢查另一個是否仍然收到關閉調用,以及兩個緩存項是否都已移除。
這樣驗證的不只是正常路徑,還包括清理失敗時的處理行為。
第一次參與之後,我學到了什麼?h2
這次修復的代碼改動不算多,但讀代碼和確定修改範圍的過程,比我一開始想的複雜。最初覺得補一個 refresh() 就夠了,後來才發現,要讓這次調用真的起作用,還得跨過幾個模塊,一直追到緩存和 TCP Server。
對我來説,最大的收穫不是多認識了幾個類,而是開始找到閲讀大型代碼庫的方法。剛接觸 ShenYu 時,很容易覺得要先把整個項目弄懂,才有把握動手。但這次做下來,我發現可以先從一個具體問題開始:找到入口,順着調用往下看,遇到模塊邊界時,再確認數據和操作有沒有繼續傳下去。這樣一點點縮小範圍,比一開始就試圖讀完整個倉庫更容易入手。
另一個感受是,修改已有代碼和自己從頭寫項目不太一樣。自己寫的時候,很多接口和結構都能按自己的想法來;但在 ShenYu 裏,改一個方法之前,還需要看看誰在調用它、哪些類實現了它。比如這次給 Handler 增加刷新入口,我不僅要考慮 TCP 插件怎麼用,還要儘量不影響其他已有實現。這讓我更具體地理解了“兼容性”為什麼重要。
併發問題也是類似的。以前看到 ConcurrentHashMap,我更多關注的是這個容器本身是否線程安全。這次把兩個線程的執行順序拆開看,才發現判斷和刪除之間也會出問題。這個場景讓我對“線程安全”有了更具體的認識,而不只是記住一個類的特點。
回頭看,我還沒有因此就熟悉整個 ShenYu,但至少更清楚該怎麼面對一段不熟悉的代碼了:不急着改,也不用等到理解所有模塊才開始。先把問題相關的那條路徑弄清楚,再確認自己的修改會影響哪裏。對還在學習的我來説,這就是這次參與開源最實在的收穫。