4 ステップ・パターン
欠陥は 4 つの連続した非アトミックなステップに潜んでいる:
// 欠陥
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
ステップ1: キャッシュをチェック。ステップ2: ミス。ステップ3: 計算。ステップ4: 保存。4つのステップすべてが非アトミック。ステップ1とステップ4の間で、任意の数のスレッドがステップ1を実行し、すべてがnullを確認する可能性がある。
べき等性の罠
このパターンを保護する推論: 「2つのスレッドが同じ値を計算して保存しても問題ない。結果は冪等である。データ破損は発生しない。」
この推論: 正しさについては正しい。コストについては致命的である。
1,000スレッドがキャッシュミスした場合: 1,000スレッドがそれぞれ expensiveCompute(key) を実行する。expensiveCompute がデータベースをクエリする場合、1,000件のデータベースクエリが同時に発行される。外部サービスを呼び出す場合は、1,000件のHTTPリクエストが同時に発行される。正しい結果を生成するシステムが、その生成コストによって崩壊する。
3つのトリガー
サンダリング・ハードは、キャッシュキーが多くのスレッドで同時に warm から cold に遷移するときに発生する:
コールドスタート: サービスが空のキャッシュで再起動する。最初のリクエスト波: すべてのキーがミスする。すべての計算が同時に実行される。
サービス再起動: ローリング再起動により、インスタンス間でキャッシュがリセットされる。トラフィックがコールドインスタンスに再分散される。
TTL期限切れ: 高トラフィックのキーが期限切れになる。Nスレッドがすべてチェックし、すべてミスし、最初のスレッドが結果を保存する前にすべてが計算を実行する。
3つのトリガーはすべてトラフィックスパイクと相関している。キャッシュがコールド状態でトラフィックがピークに達したときに群れが発火する。最悪のタイミング。
Elasticsearch EnrichCache の例
Elasticsearch EnrichCache: ドキュメントコメントには「シンプルにするため意図的にロックしない…競合状態で同じキーと値を再putしても問題ない」とある。1秒間に10,000ドキュメントでエンリッチキャッシュがコールドの場合、10,000件のリクエストが同時にエンリッチインデックスに到達する。エンリッチインデックスは occasional lookups 用に設計されているため、10,000件の同時クエリに晒される。クラスタが不安定化する。
べき等性の理屈: コードコメントでは正しい。1秒間に10,000ドキュメントでは破壊的。
MOAD-0001 との結合
MOAD-0001(堆積的欠陥)は高スループットシステムで O(N²) のボトルネックを生む。MOAD-0001 を修正(O(N²) → O(N))するとワークステーションのボトルネックが解消される。スループット向上により下流へより多くのリクエストが送られる。MOAD-0001 のボトルネックで保護されていた下流キャッシュが、相関したトラフィックスパイクを受け取るようになる。MOAD-0005 がこれまで発火しなかったキャッシュで発火する。1つの MOAD を修正すると、もう1つの MOAD が待機する。
べき等性の罠が間違っている点
Elasticsearch のコメントは、誤った問いに対して慎重なエンジニアリング推論を適用した例である。べき等性は、推論する価値のある実在の特性である。罠は、正しさで分析を止めてコストまで踏み込まないことにある。
computeIfAbsent と singleflight
修正策:チェックと計算をアトミックにする。1 つのスレッドだけが計算し、他のすべてのスレッドはその結果を待つ。
Java: computeIfAbsent
// 欠陥: 4つの非アトミックなステップ
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: キーが存在しない場合、正確に1回だけ計算を行い、保存して返します。同じキーで 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: キーの計算がすでに実行中の場合、同じキーのすべての呼び出し元は待機し、単一の結果を共有します。1回の計算、N回の待機、1つの結果が共有されます。「flight」抽象化:in-flightリクエストの重複排除。
ロック vs singleflight
素朴なキーペアロックは直列化します:スレッド1が計算、スレッド2が待機、スレッド3が待機。スレッド1が完了すると、スレッド2がロックを取得してキャッシュを確認(ヒット)。スレッド3がロックを取得してキャッシュを確認(ヒット)。N-1回のロック取得とキャッシュ読み取りが発生します。
singleflightは重複排除します:スレッド1が計算、スレッド2からNまでのスレッドはすべてスレッド1の結果を待機。別々のロック取得は不要。別々のキャッシュ読み取りは不要。1回の計算、1つの結果、N人の待機者に配布。キーペアロックより少ない操作で済みます。
どちらもスタンピードを防ぎます。singleflightは冗長な作業をより完全に防ぎます。
パターンの書き換え
具体的なシナリオに修正を適用します。
// 高トラフィックなJavaサービスにおけるユーザープロフィールキャッシュ
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // コスト高: 50msのDBクエリ
profileCache.put(userId, profile);
}
return profile;
}
サービスは毎朝午前2時に再起動します。午前8時に10,000人のユーザーが同時にプロフィールをリクエストします。