English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

guest
1 / ?
back to lessons

四步驟模式

缺陷存在於四個連續、非原子的步驟中:

// 缺陷
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;

步驟 1:檢查快取。步驟 2:未命中。步驟 3:計算。步驟 4:儲存。這四個步驟並非原子操作。在步驟 1 與步驟 4 之間,任意數量的執行緒都可能執行步驟 1,且全都會看到 null。

冪等性陷阱

保護此模式的理由:「兩個執行緒同時計算並儲存相同的值是可接受的。結果具有冪等性,不會發生資料損毀。」

此理由:關於正確性是對的,關於成本則是致命的。

在快取未命中時有 1,000 個執行緒:1,000 個執行緒各自執行 expensiveCompute(key)。若 expensiveCompute 查詢資料庫,則會同時發出 1,000 次資料庫查詢。若它呼叫外部服務,則會同時發出 1,000 次 HTTP 請求。系統雖然能產生正確結果,卻會因產生結果的成本而崩潰。

三種觸發情境

當快取鍵同時從熱轉為冷,且跨越多個執行緒時,就會引發「驚群效應」:

冷啟動: 服務重新啟動時快取為空。首波請求:每個鍵皆未命中,所有計算同時進行。

服務重啟: 滾動重啟會重置各實例的快取。流量重新分配至冷實例。

TTL 到期: 高流量鍵到期。N 個執行緒同時檢查、同時未命中、同時計算,導致第一個執行緒尚未儲存結果前,其他執行緒已重複計算。

所有三個觸發條件:都與流量尖峰相關。當流量達到高峰且快取為冷狀態時,herd 就會啟動。這是最糟糕的時機。

Elasticsearch EnrichCache 範例

Elasticsearch EnrichCache:文件註解寫道「為了簡化而刻意不加鎖……在競爭條件下重複 put 相同的 key/value 也沒關係」。在每秒 10,000 筆文件且 enrich cache 為冷的情況下:所有 10,000 個請求會同時命中 enrich index。原本設計用於偶爾查詢的 enrich index,卻同時面對 10,000 個並行查詢,導致叢集不穩定。

冪等性推論:程式碼註解中的說法在正確性上是成立的,但在每秒 10,000 筆文件的規模下卻會造成災難。

與 MOAD-0001 的耦合

MOAD-0001(沉積缺陷)在高吞吐量系統中造成 O(N²) 的瓶頸。修復 MOAD-0001(從 O(N²) 降至 O(N))會解除該工作站的限制。更高的吞吐量會將更多請求傳送至下游。先前因 MOAD-0001 瓶頸而受到保護的下游快取,現在會收到相關的流量尖峰。MOAD-0005 會在之前從未觸發的快取中啟動。修復一個 MOAD,卻為另一個埋下伏筆。

冪等性陷阱的錯誤之處

Elasticsearch 的註解代表將謹慎的工程推論應用在錯誤的問題上。冪等性:確實值得分析的真實屬性。陷阱在於:分析停留在「正確性」,而沒有繼續探討「成本」。

為什麼「兩個執行緒寫入相同結果是可接受的」這個推理會導致缺陷?它抓住了什麼,又遺漏了什麼?

computeIfAbsent 與 singleflight

修復方式:讓「檢查與計算」成為原子操作。只有一個執行緒負責計算,其他執行緒等待該結果。

Java: computeIfAbsent

// 缺陷:四個非原子步驟
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;

// FIX: 原子性檢查與計算
return cache.computeIfAbsent(key, k -> expensiveCompute(k));

computeIfAbsent:若鍵不存在,則僅計算一次、儲存並回傳。其他所有執行緒在呼叫相同鍵的 computeIfAbsent 時,都會等待第一次計算完成。不會發生 N 次重複計算,也不會出現驚群效應。

Go: singleflight.Group

var g singleflight.Group

func getOrCompute(key string) (Value, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveCompute(key)
})
return v.(Value), err
}

singleflight:若某 key 的計算正在進行,所有請求同一 key 的呼叫者都會等待並共享單一結果。一次計算,N 個等待者,共用一個結果。「flight」抽象概念:對進行中的請求進行去重。

Lock vs singleflight

使用每 key 獨立鎖的做法會序列化:執行緒 1 進行計算,執行緒 2 等待,執行緒 3 等待。執行緒 1 完成後,執行緒 2 取得鎖並檢查快取(命中)。執行緒 3 取得鎖並檢查快取(命中)。共發生 N-1 次鎖取得與快取讀取。

singleflight 進行去重:執行緒 1 進行計算,執行緒 2 到 N 全部等待執行緒 1 的結果。無需額外鎖取得,也無需額外快取讀取。一次計算、一個結果,發送給 N 個等待者。比每 key 鎖的做法更少操作。

兩者都能防止驚群效應,但 singleflight 更徹底地避免了重複工作。

重寫此模式

將修正套用至具體情境。

// 高流量 Java 服務中的使用者設定檔快取
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId);  // 昂貴操作:50ms 資料庫查詢
profileCache.put(userId, profile);
}
return profile;
}

每天早上 2 AM 會重新啟動服務。早上 8 AM 時,有 10,000 位使用者同時請求他們的個人檔案。

找出缺陷,說明它何時觸發,並使用 computeIfAbsent 重寫。