简

記錄我第一次在 Apache ShenYu 中完整處理一個 Issue 的過程,以及讀代碼、修復問題時的一些收穫。

從 refresh() 追到 TCP Server:第一次參與 Apache ShenYu 的 Bug 修復
22 分鐘
4317 字

這是我第一次比較完整地在 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。同步入口知道配置已經為空,卻沒有把清理動作傳遞給真正保存運行時狀態的組件。

圖 1 · 空快照處理:修復前後對比
空快照處理:修復前後對比 HTTP 空快照進入 ProxySelectorRefresh;修復前直接返回,舊服務殘留;修復後沿 Subscriber 和 Handler 轉發,清理緩存並嘗試關閉服務。 HTTP 同步收到空快照ProxySelector 列表為空 ProxySelectorRefresh 修復前 修復後 直接 return 舊 TCP Server 殘留清理事件沒有傳到資源管理層 通知 Subscriberrefresh() 分發到 Handlerrefresh() 清理 TCP 服務緩存TcpBootstrapFactory.clearCache() 移除緩存 · 嘗試 shutdown()
小屏可在圖內左右滑動查看。

看到這裏,我意識到,空列表也有它的含義。如果這次全量同步是成功的,那麼返回空列表就表示當前已經沒有任何代理選擇器,而不是“不需要處理”。

如果程序把空列表理解成“不需要處理”,運行狀態就可能與 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 A
tcp-proxy-b → BootstrapServer B

最簡單的做法似乎是直接調用 cache.clear()。但這裏有一個問題:從 Map 中刪除引用,並不等於真正停止 TCP 服務。

BootstrapServer 是運行中的服務實例,可能涉及監聽端口以及其他網絡資源。僅清空容器,並不會自動調用它的 shutdown()。

如果想確保 TCP 代理選擇器被清理,就需要完成兩個動作:

  1. 將實例從緩存中移除。
  2. 對移除的實例執行關閉操作。

因此,我在 TcpBootstrapFactory 中增加了 clearCache() 方法,統一處理這兩件事。

為什麼使用條件刪除?h3

這裏沒有采用單純的遍歷再按 key 刪除,而是使用類似下面的邏輯:

if (cache.remove(selectorName, bootstrapServer)) {
// 关闭当前移除成功的实例
}

ConcurrentHashMap.remove(key, value) 的意義是:只有當前 key 對應的值仍然是這個實例時,才執行刪除。

考慮一個併發場景:線程 A 正在執行全量清理;與此同時,線程 B 更新了某個代理選擇器,把舊服務替換成新服務。

如果線程 A 直接按照遍歷時拿到的 key 刪除,就可能誤刪線程 B 剛剛更新的實例。條件刪除可以避免這種特定情況:發現當前值已經改變時,線程 A 不再刪除新值。

同時,只有成功移除緩存項的線程才繼續關閉對應實例,也減少了併發清理時重複關閉同一對象的風險。

不過,條件刪除保護的是當前這條緩存項,並沒有把整個全量刷新變成原子操作。

如果一個 TCP Server 關閉失敗呢?h3

假設當前存在三個服務:

Server A
Server B
Server C

如果在關閉 Server B 時拋出異常,直接讓異常中斷整個遍歷,就可能導致 Server C 沒有機會執行清理。

因此,clearCache() 會在每個實例的 shutdown() 外捕獲 RuntimeException,記錄錯誤並繼續處理其他實例。這樣可以避免單個實例的關閉異常阻斷其他實例的清理。

關閉失敗的實例仍可能有資源沒有釋放。這次先記錄異常,讓其他服務繼續清理,沒有再把重試和資源恢復一起加進來。

圖 2 · TCP 緩存清理及異常處理
TCP 緩存清理與異常處理 遍歷緩存;條件刪除失敗則跳過,成功則調用 shutdown;RuntimeException 記錄後繼續遍歷,未拋出異常也繼續遍歷。關閉失敗不代表資源已釋放,全量清理也不具備嚴格原子性。 遍歷 TCP Server 緩存selectorName / BootstrapServer 條件刪除成功?remove(key, value) 否 是 調用 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() 調用、緩存移除和關閉異常隔離
HttpSyncDataServiceTestHTTP 同步路徑能觸發 ProxySelector 刷新

其中,我覺得比較有意義的是關閉異常測試。

測試中放入兩個模擬的 BootstrapServer,讓其中一個在 shutdown() 時主動拋出異常,然後檢查另一個是否仍然收到關閉調用,以及兩個緩存項是否都已移除。

這樣驗證的不只是正常路徑,還包括清理失敗時的處理行為。

第一次參與之後,我學到了什麼?h2

這次修復的代碼改動不算多,但讀代碼和確定修改範圍的過程,比我一開始想的複雜。最初覺得補一個 refresh() 就夠了,後來才發現,要讓這次調用真的起作用,還得跨過幾個模塊,一直追到緩存和 TCP Server。

對我來説,最大的收穫不是多認識了幾個類,而是開始找到閲讀大型代碼庫的方法。剛接觸 ShenYu 時,很容易覺得要先把整個項目弄懂,才有把握動手。但這次做下來,我發現可以先從一個具體問題開始:找到入口,順着調用往下看,遇到模塊邊界時,再確認數據和操作有沒有繼續傳下去。這樣一點點縮小範圍,比一開始就試圖讀完整個倉庫更容易入手。

另一個感受是,修改已有代碼和自己從頭寫項目不太一樣。自己寫的時候,很多接口和結構都能按自己的想法來;但在 ShenYu 裏,改一個方法之前,還需要看看誰在調用它、哪些類實現了它。比如這次給 Handler 增加刷新入口,我不僅要考慮 TCP 插件怎麼用,還要儘量不影響其他已有實現。這讓我更具體地理解了“兼容性”為什麼重要。

併發問題也是類似的。以前看到 ConcurrentHashMap,我更多關注的是這個容器本身是否線程安全。這次把兩個線程的執行順序拆開看,才發現判斷和刪除之間也會出問題。這個場景讓我對“線程安全”有了更具體的認識,而不只是記住一個類的特點。

回頭看,我還沒有因此就熟悉整個 ShenYu,但至少更清楚該怎麼面對一段不熟悉的代碼了:不急着改,也不用等到理解所有模塊才開始。先把問題相關的那條路徑弄清楚,再確認自己的修改會影響哪裏。對還在學習的我來説,這就是這次參與開源最實在的收穫。


相關鏈接h2