简

恢復舊 Admin 緩存的代碼,為什麼會先拋出空指針?從重複添加 namespace 的緩存 key,到與生產格式不一致的測試數據,記錄 ShenYu PR #7447 的修復、驗證與 Review。

緩存 key 多拼了一次:Apache ShenYu HTTP 同步恢復修復
11 分鐘
2123 字

Gateway 手裏的配置比某個 Admin 節點更新,按理説,Admin 應該先刷新自己的緩存,再判斷要不要通知 Gateway 更新。但這次,代碼還沒執行到刷新,就先拋出了空指針。

Issue #7436 指出了一個很具體的原因:緩存 key 已經帶了 namespace,恢復邏輯卻又給它加了一次前綴。ns1_PLUGIN 變成了 ns1_ns1_PLUGIN,自然找不到原來的緩存。

我在 PR #7447 中修復了這個問題。生產代碼的改動很小,但我還需要弄清楚兩件事:為什麼原來的測試沒有發現它,以及這次空指針到了 Gateway 那邊,到底會變成什麼行為。

名字叫 group,裏面存的卻已經是完整 keyh2

ShenYu 的 HTTP 同步會比較 Gateway 和 Admin 的配置 MD5。兩邊相同,不需要更新;兩邊不同,再比較修改時間。如果 Gateway 的時間更新,就進入 Admin 本地緩存的恢復分支。

問題出在這個分支如何重新讀取緩存。

AbstractDataChangedListener.updateCache() 在寫入時,先把 namespace 和配置組拼成完整 key,例如 ns1_PLUGIN。這個值既是 CACHE 的索引,也被存進了 ConfigDataCache.group。

但恢復邏輯看到 getGroup(),又把它當成裸的配置組名,與 namespace 拼了一次:

位置實際使用的值
配置組名PLUGIN
生產寫入的緩存 keyns1_PLUGIN
緩存對象的 group 字段ns1_PLUGIN
原恢復邏輯重新構造的 keyns1_ns1_PLUGIN

也就是説,問題不是緩存沒有寫進去,而是讀和寫對同一個字段的理解不同。

錯誤 key 查不到對象後,代碼會進入“緩存已經被其他線程替換”的判斷,再訪問 latest.getMd5()。此時 latest 是 null,刷新本地緩存的代碼還沒有機會執行。

這裏還有一種比空指針更容易被忽略的情況:如果恰好存在另一個 namespace,名字就叫 ns1_ns1,那麼 ns1_ns1_PLUGIN 可能真的對應一份緩存。程序不一定報錯,卻會用另一個 namespace 的 MD5 做判斷。

因此,修復不能只讓它“不拋異常”,還得確認它讀的是誰的數據。

複用完整 key,不改變已有的緩存格式h2

這次我沒有把所有緩存對象的 group 改回裸組名。現有生產寫入已經使用完整 key,修改整個表示方式會擴大範圍,也需要重新核對其他讀取入口。

恢復分支直接使用已有的完整 key 就夠了:

String configDataCacheKey = serverCache.getGroup();

這個變量隨後用於三次讀取:加鎖前、進入鎖後,以及刷新緩存後。改正它的來源,三處就都能讀到同一個 namespace 下的配置組,而不是隻修掉第一次空指針。

原來的併發處理沒有刪除。加鎖前後仍然檢查緩存對象是否已經被替換;如果其他線程已經更新了它,就用最新 MD5 判斷,不重複刷新。確實需要刷新時,仍然按 namespace 獲取鎖,並只刷新對應 namespace。

我在這裏保留的是已有的緩存表示和鎖範圍,只糾正恢復入口對 key 的理解。代碼旁也補了説明:group 已經是帶 namespace 的完整 key,不能再拼一次。

測試數據和生產格式不同,會把錯誤邏輯變成“正確答案”h2

原來的恢復測試並不是沒有覆蓋這個分支。它們手工創建了 ConfigDataCache,但往 group 裏放的是 PLUGIN,而不是生產實際保存的 ns1_PLUGIN。

在這樣的測試數據下,再調用一次 buildCacheKey() 反而正好得到正確索引。於是,錯誤的生產邏輯通過了測試。

我先把已有用例中的緩存對象改成生產格式,再讓新增迴歸用例通過真正的 updateCache() 創建緩存,而不是手工湊出一個看起來合理的對象。

測試中的 seedCache() 會構造帶 namespace 的 PluginData,交給 updateCache() 序列化、計算 MD5 並寫入緩存。恢復時則複用測試 listener,記錄刷新了哪個 namespace,並寫入指定內容,觀察後續比較結果。

這裏需要覆蓋兩種刷新結果:

  • 刷新後的 MD5 與 Gateway 相同,説明兩邊已經一致,不應再通知更新。
  • 刷新後的 MD5 仍與 Gateway 不同,應該返回需要更新的判斷。

只檢查“刷新被調用了”還不夠。真正需要確認的是刷新以後,判斷有沒有基於正確的緩存繼續完成。

namespace 隔離則單獨安排了一個容易撞錯 key 的場景:同時放入 ns1 和 ns1_ns1 兩份緩存,並讓後一份的 MD5 恰好與請求中的 MD5 相同。

如果恢復邏輯多拼一次前綴,就可能誤以為數據已經一致,跳過 ns1 的刷新。用例因此同時檢查:確實刷新了 ns1,返回了預期判斷,而且另一份緩存對象沒有被替換。

這比單純斷言“不出現空指針”更貼近這次 Bug。錯誤 key 不存在時會崩潰,錯誤 key 存在時則可能給出錯誤結果,兩種都需要防住。

Admin 拋了異常,不代表 Gateway 會進入重試退避h2

另一個需要核對的地方,是故障發生後的請求行為。

Issue 中提到了 Gateway 的十次重試,以及失敗後的五分鐘退避。但繼續看響應鏈路,這次空指針並不會直接觸發那套邏輯。

Admin 的全局異常處理器會記錄堆棧,並返回 HTTP 200 的錯誤結果,裏面的 data 是 null。Gateway 的監聽請求先檢查 HTTP 狀態,隨後讀取 data;遇到空數據時直接返回,沒有把響應裏的業務錯誤碼轉成異常。

對外層輪詢任務來説,這次調用正常結束,於是退出當前重試循環,馬上開始下一輪監聽。原本需要異常才能進入的退避分支,在這條路徑上沒有生效。

因此,這個問題不只是某一次恢復失敗,還可能讓受影響的 Admin 持續收到請求、反覆打印堆棧,直到其他緩存更新路徑恢復了數據。

這次 PR 修復的是錯誤 key,沒有順手改 Gateway 的錯誤處理協議。我在 PR 説明中補清了這個影響,避免把“代碼裏有重試退避”直接當成“這次故障會退避”。

維護者在 Review 中確認了完整 key 的複用,以及同 namespace 恢復和前綴碰撞的測試覆蓋。當時目標測試已在 Java 17 下通過,但頁面上還有失敗的 E2E 檢查。

後來,我在跟進回覆中補充了當前提交的成功運行鏈接:相關 CI 和 E2E 重試後通過,最後一次失敗與成功運行之間沒有修改代碼或測試。根據當時的結果,我將先前失敗描述為“看起來是間歇性的”,沒有把原因説死。維護者隨後批准了最終提交,PR 合併。

測試不只是覆蓋分支,還要還原數據的來路h2

這次我意識到,讀一個對象時,不能只憑字段名判斷它的含義。group 看起來像配置組名,但真正決定它是什麼的,是寫入代碼。先看它在哪裏被構造、帶着什麼值進入緩存,再看讀取邏輯,問題才容易對上。

測試數據也是一樣。只檢查“有沒有走到這個分支”“最後返回的是不是預期值”,還不夠。輸入對象如果和生產格式不同,分支覆蓋到了,也可能一直在驗證一個現實中不存在的前提。

通過 updateCache() 創建測試緩存以後,寫入方和讀取方才真正被放在一起檢查。重複前綴不再能靠手工數據矇混過去,namespace 隔離也能用一個具體的碰撞場景來驗證。

還有一點是對異常的理解。Admin 拋出了空指針,Gateway 卻可以把這次請求當作正常結束。以後看重試或退避邏輯,我會繼續確認錯誤經過響應包裝、解析和返回之後,調用方究竟還能不能感知到失敗,而不是看到一個 catch 就停下來。