維護另一個 PR 的間隙,我遇到了一個很小的緩存問題。順着空列表往下看,才發現“沒有實例”和“沒有更新”其實是兩回事。
當時我正在維護另一個 ShenYu PR #7289。維護者讓我處理 merge conflicts,在等待和重新跑測試的間隙,我又翻了一下項目裏的 Issue,看到了 #6526。
問題第一眼看起來很簡單:ZooKeeper Watcher 收到空的子節點列表時,沒有更新本地緩存。最後一個實例已經刪掉了,再查一次,返回的卻還是舊實例。
和之前的 TCP 問題相比,這次沒有複雜的併發設計,也不用沿着很多模塊找調用鏈。最後的生產代碼修改,確實只是去掉一個判斷。但看進去以後,我覺得它有一個很值得記下來的地方:空列表是在説“沒有數據需要處理”,還是在説“最新狀態就是沒有數據”?
這個區別想清楚以後,修改就不難了。修復最終通過 PR #7372 合併。下面想記錄的,是我怎麼理解這個空狀態,以及為什麼測試沒有停在“刪乾淨了”這一步。
最後一個實例被刪了,緩存為什麼還在?h2
ZookeeperInstanceRegisterRepository.selectInstances() 會讀取 ZooKeeper 中的實例節點,把結果保存在 watcherInstanceRegisterMap 裏,並註冊一個 Watcher 來監聽後續變化。
假設 service-a 下原來有兩個實例。刪除其中一個以後,Watcher 重新讀取子節點列表,用剩下的實例更新緩存,這時一切正常。真正出問題的是再把最後一個實例刪掉:ZooKeeper 返回 [],緩存卻沒有跟着變空。
原來的 Watcher 回調裏,有這樣一段邏輯:
// 原有回调中的关键片段List<String> childrenList = StringUtils.isNotBlank(path) ? client.subscribeChildrenChanges(path, this) : Collections.emptyList();
if (!childrenList.isEmpty()) { watcherInstanceRegisterMap.put( selectKey, getInstanceRegisterFun.apply(childrenList) );}只要列表為空,就跳過更新。於是 ZooKeeper 已經沒有實例,本地緩存裏卻還留着最後一個實例;後續查詢命中緩存,讀到的自然還是舊結果。
第一次看 if (!childrenList.isEmpty()) 時,“有數據才更新”其實很容易讓人覺得合理。但這裏接收的是當前子節點列表,不是一批只需要追加的數據。Watcher 已經告訴我們最新結果是空,這本身就是一次更新。
我後來意識到,問題並不是沒收到通知,而是收到了通知,卻因為結果為空,把這次狀態變化忽略了。
為什麼保存空列表,而不是刪掉緩存?h2
最直接的修復,就是去掉非空判斷,讓每一次讀取到的列表都能更新緩存:
// 修复后的缓存更新,childrenList 也可以是空列表watcherInstanceRegisterMap.put( selectKey, getInstanceRegisterFun.apply(childrenList));getInstanceRegisterFun 會把子節點轉換成實例列表。輸入為空時,得到的也是空列表,因此緩存中會保留 service-a -> []。
看到這裏,也很容易想到另一種做法:既然已經沒有實例了,直接 remove(selectKey) 不就行了嗎?我繼續看了 selectInstances() 讀取緩存的部分:
final List<InstanceEntity> cachedInstances = watcherInstanceRegisterMap.get(selectKey);
if (Objects.nonNull(cachedInstances)) { return cachedInstances;}它判斷的是有沒有緩存結果,而不是結果裏有沒有實例。[] 是一個有效的命中,下一次查詢可以直接返回;如果把整個 entry 刪掉,查詢就會走到後面的訂閲和初始化流程。
這時我才把兩件事分開:沒有實例,不代表沒有緩存結果。 我們已經知道這個服務當前沒有實例,沒必要僅僅因為數量是零,就把它當作一次緩存未命中。
| 這裏讀到的結果 | 在這段邏輯中的含義 | 後續處理 |
|---|---|---|
| 緩存為非空列表 | 已有當前實例快照 | 直接返回緩存 |
緩存為 [] | 已有快照,當前沒有實例 | 同樣直接返回緩存 |
Map 查詢返回 null | 沒有這個 key 的緩存結果 | 進入訂閲和初始化流程 |
在這段代碼裏,null 是沒查到緩存,[] 是查到了一個空結果。兩者的後續處理不同,不能因為“都沒有實例”就混在一起。
緩存變空以後,還能繼續更新嗎?h2
接着我又想到一個問題:如果 selectInstances() 以後都直接返回緩存裏的 [],那服務重新註冊實例的時候,怎麼知道它又有數據了?
關鍵是,查詢命中空緩存,不等於 Watcher 停止工作。
Watcher 回調仍然會調用 client.subscribeChildrenChanges(path, this),重新讀取子節點並續訂監聽。之後有新實例出現,回調再把新的實例列表寫入同一個緩存 entry。空列表只是這段變化中的一個正常快照,不是終點。
所以這裏不是“把緩存清空以後就不管了”,而是繼續用同一套 Watcher 更新當前快照。把讀取路徑和更新路徑放在一起看,這個選擇就更容易理解了。
測試為什麼沒有停在“已經變空”?h2
這次我覺得測試比生產代碼的修改更值得展開一點。原來的測試主要檢查有實例時能正常讀取,我把它補成了 1 → 0 → 1:先有一個實例,刪除最後一個,再讓實例重新出現。
測試用一個簡單狀態變量控制模擬的 ZooKeeper 返回值:
final boolean[] hasInstance = {true};
// 根据当前模拟状态,返回一个子节点或空列表when(mock.subscribeChildrenChanges(anyString(), any(CuratorWatcher.class))) .thenAnswer(invocation -> { watcherArr[0] = (CuratorWatcher) invocation.getArguments()[1]; return hasInstance[0] ? Collections.singletonList("shenyu-test") : Collections.emptyList(); });接着,主動觸發捕獲到的 Watcher 回調,檢查每一次狀態變化後的查詢結果:
// 测试中的关键步骤,省略 repository 和 mockEvent 的初始化assertEquals(1, repository.selectInstances(selectKey).size());
hasInstance[0] = false;watcherArr[0].process(mockEvent);assertTrue(repository.selectInstances(selectKey).isEmpty());
hasInstance[0] = true;watcherArr[0].process(mockEvent);assertEquals(1, repository.selectInstances(selectKey).size());如果測試只停在中間一步,能檢查舊實例有沒有殘留,但看不到後續變化還能不能生效。再往後走一步,就把“從有到無”和“從無到有”連起來了。
PR 裏還記錄了一個對我很有幫助的檢查:先加上空列表的斷言,它在舊代碼下失敗;去掉判斷以後,再運行通過。這樣能確認,新測試確實抓住了這次修復的問題,而不只是給測試多加了幾行代碼。
和之前的空快照問題,原來有同一個盲點h2
做這個 Issue 時,我想起了第一次處理的 HTTP 空快照問題。那次是收到空的 ProxySelector 快照以後,刷新鏈路沒有把舊的 TCP Server 清理掉;這次是 ZooKeeper 返回空子節點列表以後,舊實例緩存沒有被覆蓋。
模塊不同,後果也不同,但我在兩次排查裏碰到了一個相似的盲點:看到列表為空,很容易順手把它理解成“這次沒東西需要做”。
但對這些全量快照來説,空數據可能恰恰在説:之前存在的東西,現在已經全部沒有了。 繼續保留舊狀態,反而和這次更新的含義相反。
我以前更習慣關注“數據來了以後怎麼處理”,這兩次之後,開始會多問一句:數據從有變成沒有的時候,代碼會走哪條分支?舊狀態會不會還留在那裏?
改動很小,但我想記下的不只是那兩行代碼h2
和前兩個 PR 相比,這次修復簡單很多。最後也沒有做什麼很大的改造,就是刪掉一個非空判斷,補上狀態變化的測試。
不過我並不覺得它只能寫成一句“修復緩存殘留”。對我來説,這次有意思的地方,是沿着一個看起來很合理的判斷往下讀,發現它其實混淆了兩種狀態:沒有緩存結果,和已經知道結果為空。
也讓我對測試有了一點新的想法。除了檢查某個時刻返回什麼,還可以把前後變化連起來看:先有數據,後來沒有,再後來又有。很多問題正好藏在這些過渡裏,而不是某一個靜態結果裏。
這次記下的東西很簡單:“沒有實例”不是“沒有狀態”,它本身就是當前狀態。 以後再遇到 Watcher、全量同步或者快照式緩存,我想自己會更留意那個容易被直接跳過的空列表。