分散キャッシュの設計
秒間数十万リクエストをDBに落とさず捌く分散キャッシュを、配置戦略から一貫性ハッシュ、スタンピード対策まで面接の解答手順で設計しきれる。
- 配置はcache-aside(読み主体・実装単純だが初回ミスと二重書き競合に注意)/write-through(読み一貫・書き遅延増)/write-back(書き高速・障害でデータ喪失)を要件で選ぶ。
- シャーディングは一貫性ハッシュ+仮想ノードでノード増減時の再配置をキー全体の約1/Nに抑え、ホットキーはキー単位レプリケーションかローカルキャッシュで分散させる。
- スタンピード対策はロック(single-flight)・確率的早期再計算・論理TTLの3系統。無効化は正確さと遅延の綱引きで、TTL失効/明示削除/write-throughを組み合わせる。
要件と規模の見積もり
まず機能要件と非機能要件を切り分けます。機能要件は「キーに対する値の取得・更新・削除」「TTLによる自動失効」の3操作に集約されます。非機能要件こそがキャッシュ設計の主戦場で、低レイテンシ(p99で1ミリ秒未満)、高可用性、水平スケール、そして「キャッシュミスや障害でDBを守れること」を満たす必要があります。
規模をフェルミ推定で桁だけ押さえます。想定はSNSのタイムラインやプロフィール取得で、ピーク読み取り500,000 QPS、読み書き比を50対1として書き込み10,000 QPS。キャッシュ対象は10億エントリ、1エントリの値が平均1KB、キー・メタデータ込みで実効1.5KBとすると、必要メモリは 10^9 × 1.5KB = 1.5TB。1台あたり実装可能なメモリを64GBと置けば、1.5TB / 64GB ≒ 24台。冗長化とヘッドルームで各シャードにレプリカ1台を足し、余裕を見て合計60〜70台規模のクラスタになります。
帯域も見積もります。読み取り500,000 QPS × 1.5KB = 750MB/s ≒ 6Gbps。これをノード数で割ると1台あたり約100Mbpsで、10GbE NICなら十分に収まります。逆に言えば、この帯域計算がキーサイズやQPSの前提に敏感なので、値が大きいユースケース(画像バイナリ等)ではキャッシュではなくオブジェクトストレージ+CDNへ寄せる判断になります。
「1.5TBを64GB機で割ると数十台」「6Gbpsを分散すれば1台100Mbps」という桁が出た瞬間に、単一ノードでは無理だから水平分割が必須、という設計の出発点が確定します。数値は正確な予言ではなく、アーキテクチャの分岐点を見つける道具です。
大枠の設計
APIは意図的に薄く保ちます。get(key)、set(key, value, ttl)、delete(key) の3つが基本で、複数キーをまとめる mget / mset を性能最適化として足します。データモデルはキーバリューに限定し、値の構造化はアプリ層の責務とします(キャッシュに検索やJOINを持ち込むと、それはもうキャッシュではなくDBだからです)。
全体構成は「アプリ → クライアントライブラリ(ルーティング担当)→ キャッシュノード群」の3層です。ルーティングをクライアント側に置くのはプロキシのホップを1段削ってレイテンシを稼ぐためですが、ノード一覧の配布が課題になるため、後述のようにルーティング表を中央のディレクトリ(例: ZooKeeper/etcd)から配ります。
キャッシュ配置パターンは要件で選びます。読み取り主体ならcache-asideが基本形です。
# cache-aside(読み取り時)
value = cache.get(key)
if value is None: # キャッシュミス
value = db.query(key)
cache.set(key, value, ttl)
return value
# cache-aside(更新時): DBを更新し、キャッシュは削除して次回に再読込
db.update(key, new_value)
cache.delete(key) # set ではなく delete が定石
更新時に cache.set ではなく cache.delete を使うのが定石です。2つの並行更新が「DB書き込み→キャッシュ書き込み」の順序で交差すると、古い値が新しい値を上書きして残る競合が起きるためで、削除にしておけば次の読み取りが必ずDBから正しい値を取り直します。
| 方式 | 書き込みの流れ | 強み | 弱み・リスク |
|---|---|---|---|
| cache-aside | アプリがDBを更新しキャッシュを削除 | 実装が単純・キャッシュ障害に強い | 初回ミス発生・並行更新の競合に注意 |
| write-through | キャッシュ経由でDBへ同期書き込み | 読み取りが常に最新・整合が単純 | 書き込みレイテンシがDB分増える |
| write-back | キャッシュに書きDBへは非同期反映 | 書き込みが高速・DB負荷を平準化 | ノード障害で未反映データを喪失 |
write-backは書き込みスループットを稼げますが、非同期反映の途中でノードが落ちると確定していない書き込みが消えます。金額など喪失が許されないデータには使わず、閲覧数カウンタのように「多少の誤差と喪失を許容できる」用途に限定するのが判断基準です。この配置戦略の背後には、書き込みをどこで永続化するかというデータベース側の一貫性設計と地続きの問題があります。
主要コンポーネントの深掘り
シャーディング: 一貫性ハッシュと仮想ノード
数十台へキーを分配する方法として、素朴な hash(key) % N は破綻します。ノードが1台増減してNが変わると、ほぼ全キーの割り当て先がずれてキャッシュが総崩れになり、その瞬間にDBへ全リクエストが流れ込むからです。
これを避けるのが一貫性ハッシュ(consistent hashing)です。ハッシュ空間を円環と見なし、ノードとキーを同じ空間へ写像して「キーから時計回りで最初に出会うノード」に割り当てます。ノードを1台足しても割り当てが変わるのは、その新ノードと反時計回り側の隣ノードに挟まれた区間のキーだけで、影響はキー全体の約1/Nに限定されます。
素朴な一貫性ハッシュはノードが円環上に偏ると負荷が不均等になるため、各物理ノードを100〜200個の仮想ノード(virtual node)として円環へばらまきます。仮想ノードを増やすほど区間長の分散が小さくなり、負荷が平準化されます。この仕組みの内部動作は一貫性ハッシュ法とシャーディング戦略で詳述しています。
TTLとエビクション: LRUとLFU
メモリは有限なので、TTLによる時間失効に加え、満杯時にどのキーを追い出すか(エビクション)を決めます。代表がLRU(Least Recently Used、最も長く使われていない物を追い出す)とLFU(Least Frequently Used、最も参照回数が少ない物を追い出す)です。
| 方式 | 追い出す対象 | 得意な状況 | 弱点 |
|---|---|---|---|
| LRU | 最後のアクセスが最も古いキー | 時間的局所性が強い一般的な負荷 | 一度の全件走査でホット物が押し出される |
| LFU | アクセス回数が最も少ないキー | 人気の偏りが安定して大きい負荷 | 過去に人気だった古い物が居座る |
LRUはバッチ処理などの全件走査で本当のホットキーが追い出される弱点があり、LFUは頻度カウンタが古い人気を引きずる弱点があります。実務ではこの中間を狙ったTinyLFUやW-TinyLFU(小さなLRU窓とLFU本体を組み合わせ、頻度推定にカウント最小スケッチを使う)が採用されます。
ホットキーとレプリケーション
特定キー(急上昇の投稿など)に読み取りが集中すると、そのキーを持つ1シャードだけがCPU・帯域を使い切り、他が空いていても詰まります。一貫性ハッシュは平均を均しますが、単一キーへの集中は解けません。
対策は2段です。第1に、ホットキーだけを複数ノードへ複製し、読み取りをレプリカへ分散します。第2に、アプリのプロセス内に短TTL(数百ミリ秒〜数秒)のローカルキャッシュを置き、集中読み取りをネットワークに出す前に吸収します。ローカルキャッシュは失効までの短時間だけ古い値を許容するトレードオフを伴うため、鮮度要件と引き換えに秒間数万の同一キー読み取りを1台の内部で捌けるかを見て採否を決めます。
ボトルネックとトレードオフ
横にスクロール
キャッシュスタンピード(thundering herd)
人気キーのTTLが切れた瞬間、そのキーを読む全リクエストが同時にミスし、一斉にDBへ殺到して過負荷を起こす現象がキャッシュスタンピード(thundering herd)です。対策は3系統あります。
第1はロックによる単一実行(single-flight)で、最初にミスした1リクエストだけがDBを引き、残りは短時間待って再取得します。第2は確率的早期再計算(probabilistic early expiration)で、失効が近づいたキーを一定確率で失効前に前倒し再計算し、全員が同時に切れる事態を確率的にずらします。第3は論理TTLで、実TTLとは別に値へ「論理的な失効時刻」を埋め込み、論理失効後は古い値を返しつつ裏で1つだけ再計算を走らせます(stale-while-revalidate)。
# 確率的早期再計算の考え方(擬似コード)
value, computed_at, ttl, delta = cache.read(key)
now = current_time()
# delta は再計算コストの目安。乱数と組み合わせ、失効が近いほど前倒し確率が上がる
if value is None or now - computed_at >= ttl + delta * ln(random()):
value = recompute_and_store(key)
return value
多数のキーへ一括で同じTTLを設定すると、生成時刻が近いキー群が同じ瞬間に失効し、スタンピードを人為的に量産します。TTLには乱数のジッタ(例: 基準300秒 ± 最大60秒)を足し、失効時刻をばらけさせるのが基本です。
無効化戦略と一貫性のトレードオフ
キャッシュ設計の最難問は無効化です。正確さ(DBの最新値と乖離させない)と、低レイテンシ・低負荷は本質的に綱引きの関係にあります。選択肢は、TTL失効(実装単純だが失効までは古い値を返す)、更新時の明示削除(乖離は最小だがDBとキャッシュの二重書き込みの原子性が問題)、write-through(読みは常に最新だが書き込みが遅い)の組み合わせです。
「DBを更新してからキャッシュを削除」する間にプロセスが落ちると、DBは新しくキャッシュは古い、という不整合が残ります。厳密さが要るなら、DBの変更ログ(CDC)を購読してキャッシュを無効化する、あるいは分散トランザクション相当の仕組みを別途用意する必要があります。
最終的な設計判断は要件次第です。強い整合が要る残高表示はwrite-throughか都度DB参照、多少の遅延を許せるプロフィールやタイムラインはcache-aside+短TTL+ジッタ、という住み分けになります。ここで問われるのは結局、どこまで古い値を許すかというCAP定理的な割り切りであり、キャッシュとは「一貫性を意図的に少し緩める代わりに、桁違いの読み取り性能を買う」仕組みだと整理できます。無効化イベントを複数ノードへ確実に配る部分は、メッセージキューの配信保証の考え方がそのまま効いてきます。
システム設計の記事ガイド
分散キャッシュの設計を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
分散キャッシュ
比較で見る軸
難易度: advanced / カテゴリ: システム設計 / タグ数: 5
導入後に効く点
シャーディングは一貫性ハッシュ+仮想ノードでノード増減時の再配置をキー全体の約1/Nに抑え、ホットキーはキー単位レプリケーションかローカルキャッシュで分散させる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- システム設計
- タグ数
- 5
判断チェックリスト
- 自社の用途が「分散キャッシュ / システム設計」に近いか確認する。
- 強みである「配置はcache-aside(読み主体・実装単純だが初回ミスと二重書き競合に注意)/write-through(読み一貫・書き遅延増)/write-back(書き高速・障害でデータ喪失)を要件で選ぶ。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。