简

維護另一個 PR 的間隙,我遇到了一個很小的緩存問題。順着空列表往下看,才發現“沒有實例”和“沒有更新”其實是兩回事。

當“沒有實例”也是一次更新:Apache ShenYu ZooKeeper 緩存殘留修復
18 分鐘
3653 字

當時我正在維護另一個 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 已經沒有實例,本地緩存裏卻還留着最後一個實例;後續查詢命中緩存,讀到的自然還是舊結果。

圖 1 · 同一次空列表通知,修復前後留下了不同的緩存
空列表更新的修復前後對比 最後一個實例刪除後,Watcher 讀到空列表。修復前跳過更新,緩存仍為實例 A;修復後保存空快照,緩存為空列表。 最後一個實例被刪除 Watcher 讀到 children = [] 修復前 修復後 因為為空,跳過更新 把空列表也寫入緩存 Cache: [A] 仍然返回已經不存在的實例 Cache: [] 與 ZooKeeper 當前狀態一致
A 表示之前緩存的實例。小屏可在圖內左右滑動查看。

第一次看 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。空列表只是這段變化中的一個正常快照,不是終點。

圖 2 · 查詢讀快照,Watcher 負責讓快照繼續變化
緩存讀取與 Watcher 更新的兩條路徑 監聽已經建立且緩存存在時,查詢直接返回快照,包括空列表。子節點發生變化後,Watcher 重新讀取並續訂,再把新快照寫入緩存。查詢命中空列表不會終止監聽。 查詢路徑 · 已命中緩存 更新路徑 · 監聽已建立 selectInstances(key) 子節點發生變化 讀取已有緩存快照 [] 也是有效命中 Watcher 回調 重新讀取子節點,並續訂監聽 轉換後寫入緩存 無論列表裏有沒有實例 直接返回,不重新初始化 Cache 保存最新快照 後續變化仍由 Watcher 處理
示意監聽正常建立後的讀寫路徑,省略緩存未命中與異常處理分支。

所以這裏不是“把緩存清空以後就不管了”,而是繼續用同一套 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());
圖 3 · 1 → 0 → 1:變空以後,緩存還能跟着下一次變化恢復
實例與緩存的一到零再到一狀態變化 三個階段依次為存在一個實例 A、刪除後為空、實例 A 重新出現。初始查詢與後兩次 Watcher 回調,使緩存分別保存 A、空列表、A。空列表是中間快照,並不阻斷後續更新。 有一個實例 刪除最後一個 實例重新出現 ZooKeeper [A] ZooKeeper [] ZooKeeper [A] 初始查詢 Watcher 回調 Watcher 回調 Cache [A] Cache [] Cache [A] 空列表是正常快照,不是監聽的終點
對應這次單元測試的狀態序列;A 是模擬返回的同一個實例。

如果測試只停在中間一步,能檢查舊實例有沒有殘留,但看不到後續變化還能不能生效。再往後走一步,就把“從有到無”和“從無到有”連起來了。

PR 裏還記錄了一個對我很有幫助的檢查:先加上空列表的斷言,它在舊代碼下失敗;去掉判斷以後,再運行通過。這樣能確認,新測試確實抓住了這次修復的問題,而不只是給測試多加了幾行代碼。

和之前的空快照問題,原來有同一個盲點h2

做這個 Issue 時,我想起了第一次處理的 HTTP 空快照問題。那次是收到空的 ProxySelector 快照以後,刷新鏈路沒有把舊的 TCP Server 清理掉;這次是 ZooKeeper 返回空子節點列表以後,舊實例緩存沒有被覆蓋。

模塊不同,後果也不同,但我在兩次排查裏碰到了一個相似的盲點:看到列表為空,很容易順手把它理解成“這次沒東西需要做”。

但對這些全量快照來説,空數據可能恰恰在説:之前存在的東西,現在已經全部沒有了。 繼續保留舊狀態,反而和這次更新的含義相反。

我以前更習慣關注“數據來了以後怎麼處理”,這兩次之後,開始會多問一句:數據從有變成沒有的時候,代碼會走哪條分支?舊狀態會不會還留在那裏?

改動很小,但我想記下的不只是那兩行代碼h2

和前兩個 PR 相比,這次修復簡單很多。最後也沒有做什麼很大的改造,就是刪掉一個非空判斷,補上狀態變化的測試。

不過我並不覺得它只能寫成一句“修復緩存殘留”。對我來説,這次有意思的地方,是沿着一個看起來很合理的判斷往下讀,發現它其實混淆了兩種狀態:沒有緩存結果,和已經知道結果為空。

也讓我對測試有了一點新的想法。除了檢查某個時刻返回什麼,還可以把前後變化連起來看:先有數據,後來沒有,再後來又有。很多問題正好藏在這些過渡裏,而不是某一個靜態結果裏。

這次記下的東西很簡單:“沒有實例”不是“沒有狀態”,它本身就是當前狀態。 以後再遇到 Watcher、全量同步或者快照式緩存,我想自己會更留意那個容易被直接跳過的空列表。

相關鏈接h2