實例已經寫進 ZooKeeper,Admin 為什麼仍然找不到?從註冊與監聽路徑不一致,追到事件格式、應用隔離、數據庫更新和完整快照,記錄 ShenYu PR #7446 的修復與驗證。
客户端已經把實例寫進 ZooKeeper,Admin 卻判斷服務不存在,或者一直收不到實例變化。問題究竟出在“沒有註冊上”,還是“根本沒看同一個地方”?
Issue #6525 指向了後一種情況:同一個邏輯上的服務,在註冊、查詢、存在性檢查和監聽入口中,被當成了不同格式的路徑。
我在 PR #7446 中處理了這條鏈路。但最後的修改並沒有停在統一路徑上。原生實例事件被接進 Admin 後,還需要補齊它能理解的地址、正確更新數據庫,再把當前完整的實例列表同步出去。
這次讓我花心思的,是把“註冊成功”繼續往後追:數據寫進去了,後面的每一層是不是也在讀同一份數據,並且真的用上了它?
服務名和完整路徑,不能在不同入口各解釋一遍h2
ShenYu 客户端註冊原生 ZooKeeper 實例時,會把節點寫到應用對應的父目錄下。例如,應用 http_example 的實例位於 /shenyu/register/instance/http_example 之下。
但原來的幾個讀取入口並沒有使用同一套規則:
selectInstances()會拼接配置裏的watchPath,或者拼接默認實例根路徑。serviceExists()直接讀取傳入的 key。watchInstances()也直接監聽傳入的 key。
這樣一來,同樣傳入一個服務名,查詢可能去實例目錄讀取,存在性檢查和監聽卻去了另一處。Admin 在創建發現綁定時又會先檢查服務是否存在,路徑一錯,後面的監聽甚至還沒開始就可能被拒絕。
我把三個入口收斂到同一個路徑解析方法:短服務名補上實例根路徑,以 / 開頭的完整路徑直接使用。
| 傳入的 key | 實際讀取或監聽的位置 |
|---|---|
http_example | /shenyu/register/instance/http_example |
/shenyu/register/instance/http_example | 保持原路徑,不重複拼接 |
/custom/service | 按完整路徑使用,不自動改寫到原生實例目錄 |
這裏也保留了一個容易遺漏的後續行為:子節點 watcher 需要重新註冊時,如果事件沒有提供路徑,應該繼續使用已經解析過的完整路徑,不能又退回最初的短服務名。
註冊方告訴 Admin 的,也得是實例真正所在的位置h2
只統一倉庫方法還不夠。客户端原先會把配置中的 registerPath 上報為 Admin 的 listener node,例如 /shenyu/discovery/http_example。但原生實例實際寫入的是 /shenyu/register/instance/http_example,前者並不能代表實例所在的目錄。
因此,這次在 ZooKeeper 實例註冊完成後,將配置中的註冊路徑更新為真實的實例父路徑,再交給已有的發現配置監聽器上報 Admin。
這裏沿用了現有的啓動順序:實例註冊監聽器先執行,發現配置上報監聽器後執行。這樣,Admin 收到的 listener node 與已經寫入的節點對齊,不需要另造一套上報流程。
同時,ZooKeeper 客户端不再注入 watchPath,ZooKeeper 倉庫也不再讀取這個屬性。其他註冊中心原有的處理保持不變。
這個選擇有明確的兼容性邊界:舊的 watchPath 配置會被忽略;已有數據庫綁定如果仍指向 /shenyu/discovery/...,需要改成實際實例目錄,不會被這個 PR 自動遷移。 不能一邊改變路徑含義,一邊假定舊配置會自己變正確。
收到了節點事件,還不代表 Admin 能讀懂它h2
路徑對齊後,還要看節點中的內容。
客户端寫入 ZooKeeper 的原生 InstanceEntity 帶有 host、port、權重和狀態,但沒有 Admin 上游數據使用的 url。如果把這份 JSON 原樣轉發過去,Admin 就缺少後續需要的 upstream_url。
我沒有修改 ZooKeeper 中保存的實例格式,而是在轉發原生實例事件時,根據 host:port 補齊 url,並保留已有的 address 回退。這個補齊只應用於 /shenyu/register/instance/ 下面的節點,其他子樹的事件內容仍沿用原格式。
這是兩層不同的數據契約:註冊端能序列化自己的對象,不等於消費端就能把它當作上游對象使用。需要在它們銜接的地方,把差異明確處理掉。
還有一處隔離問題。同一份 discovery 配置可能被多個應用共享,但它們監聽的服務目錄並不相同。如果 Admin 只用 discovery ID 查找 listener,後來加入的應用就可能掛到另一個應用的監聽器上。
因此,listener 的保存與查找一起改為使用 discovery ID 和被監聽的服務 key。複用註冊中心連接配置,不代表可以複用同一個應用的變化通知上下文。
事件處理完以後,同步的是當前列表,不是那一個變化節點h2
繼續往 Admin 的持久化與同步邏輯看,還需要接上兩處判斷。
第一處是更新條件。原生事件不帶數據庫記錄 ID,但原來的 UPDATE 卻用 #{id} 匹配 discovery_handler_id,導致更新匹配不到對應記錄。這次改為綁定實際設置過的 discoveryHandlerId,再按上游 URL 定位記錄。
同時,原生事件也沒有更新時間。更新執行前需要補齊 dateUpdated,否則修正匹配條件後,又會撞上數據庫中 date_updated 的非空約束。權重或狀態變化從 ZooKeeper 傳過來,並不意味着寫庫這一步已經成立。
第二處是同步內容。事件裏帶的是發生變化的一個實例,但發給下游的 UPDATE 需要表達當前這個發現綁定下的實例列表,不能直接把事件裏的單個節點當成整份列表。
例如,同一個應用有 A、B 兩個實例:
| 節點變化 | 處理後的實例列表 | 應同步的快照 |
|---|---|---|
| 新增 B | A、B | A、B,而不只是 B |
| 刪除 B | A | A,而不是剛刪除的 B |
| 再刪除 A | 空 | 空列表,讓下游清掉舊狀態 |
因此,這次在處理新增、更新或刪除之後,重新按 discovery handler 查詢數據庫,將當前完整上游列表放進同步數據,再發布事件。數據庫對象轉換為同步對象時,也補上原先漏掉的 namespaceId。
我需要區分的是“這次發生了什麼”和“處理之後現在是什麼”。它們都可能用一個列表表示,但不能因此當成同一種消息來發送。
用同一個實例,把註冊、落庫和 WebSocket 同步連起來驗證h2
這次驗證不能只停在“路徑解析方法返回了預期字符串”。即使路徑正確,事件格式、數據庫參數或 listener 歸屬中的任何一處沒接上,Admin 最後看到的狀態仍然可能不對。
我補了一組本地 ZooKeeper 測試,用真實序列化的 InstanceEntity 檢查查詢,以及新增、更新、刪除事件中的地址和權重。
隨後在集成測試中,把客户端真實的 HTTP 發送器、測試 Admin、本地 ZooKeeper 和 WebSocket 接收端連起來,圍繞兩個應用檢查整條路徑:
- 故意給第一個客户端配置舊的
/shenyu/discovery/...路徑,確認最終上報的是實際實例目錄,舊路徑沒有被誤當成服務。 - 讓 A、B 兩個應用共享 discovery 配置,各自增加實例,確認數據庫中的上游列表沒有串到另一個應用。
- 修改一個實例的權重,確認數據庫更新,並在 WebSocket 消息中觀察到對應權重。
- 依次停止實例,確認刪除後的完整快照、最後一個實例離開後的空列表,以及同步數據中的 namespace 信息。
這裏觀察的是實際註冊請求、ZooKeeper 節點變化、測試數據庫和發出的 WebSocket 數據,不只是用 mock 給各個方法分別塞一個“正確結果”。為了複用客户端發送器,Admin 新增的 shenyu-client-core 依賴只放在 test scope,沒有進入生產 classpath。
維護者在 Review 中確認了註冊和發現路徑已對齊,也認可了多應用註冊、更新、註銷和舊路徑避讓的集成覆蓋。對方在 Java 17 下針對當前提交運行目標測試後批准了 PR。
排查不能只停在註冊成功h2
這次最具體的收穫,是我不能只憑某一層的成功,就替後面的層下結論。
實例寫進 ZooKeeper,證明註冊動作完成了;監聽到了事件,證明看對了節點;數據庫更新成功、同步快照也正確,才把這個變化繼續交了出去。中間每一層都可能有自己的 key、對象格式和狀態含義。
我意識到,排查這類問題時,圍繞同一個實例一路追下去,比把幾個類分開看更容易發現斷點。它在註冊時叫什麼、在哪個路徑下,Admin 以什麼身份接收它,最後又把怎樣的列表發出去——這些問題需要能接成一條線。
測試也是這樣。單獨證明每個入口可以工作,並不自動證明它們使用的是同一套約定。把兩個應用放在同一份配置裏,再讓實例新增、更新和離開,才更容易看見那些只有組合起來才會暴露的問題。
回頭看,這次從一個路徑不一致的問題出發,最後補的是幾個模塊之間沒有説清楚的約定。我想記住的是這個排查方式:先找到一個具體的數據對象,再跟着它跨過每個邊界,直到確認它真的變成了下一層需要的東西。