キャッシュ戦略入門 - Cache-Aside・Write-Through から無効化とスタンピード対策まで

キャッシュ戦略入門 - Cache-Aside・Write-Through から無効化とスタンピード対策まで

作成日:
読了:約38分
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

「遅いからキャッシュを入れよう」は、アプリケーションの性能改善で最初に出てくる発想です。実際、データベースの前に Redis や Memcached を1枚置くだけで、応答時間もDBの負荷も大きく下がることがあります。一方で、キャッシュは同じデータのコピーをもう1か所に持つことでもあります。コピーを持った瞬間に「いつ作るか」「いつ捨てるか」「元データと食い違ったらどうするか」「一斉に切れたらどうなるか」という問題がついてきます。

この記事では、アプリケーション側のキャッシュ設計を、効果の見積もり、置き場所、読み込みと書き込みのパターン、無効化、スタンピード(thundering herd)対策、ネガティブキャッシュとホットキー、エビクションとの関係の順に整理します。容量が尽きたときに何を捨てるかというアルゴリズムの話はLRUキャッシュ入門、HTTP の Cache-Control と CDN の話はCDN キャッシュ設定の実践記事にまとめているので、ここではその間にある「アプリケーションがキャッシュをどう使うか」に絞ります。

なぜキャッシュするのか - ヒット率で効果を見積もる

キャッシュの効果は、ほぼヒット率で決まります。リクエストのうち割合 h がキャッシュで返せたとすると、オリジン(DB や下流 API)に届くのは残りの (1 - h) だけです。平均応答時間も、キャッシュの読み取り時間を c、ミス時にオリジンへ行く時間を d とすると、おおよそ c + (1 - h) × d になります。

仮に毎秒 1,000 リクエスト、キャッシュの読み取り 1ms、DB への問い合わせ 20ms として計算すると、次のようになります(実測ではなく、この仮定を式に入れた値です)。

ヒット率オリジンへのリクエスト(毎秒)平均応答時間の目安
50%50011ms
90%1003ms
95%502ms
99%101.2ms

ここから2つのことが読み取れます。1つは、ヒット率が 90% から 99% に上がるとオリジンの負荷は10分の1になることです。上のほうの数ポイントの差が、DB にとっては桁違いの差になります。もう1つは、オリジンは「キャッシュが効いている前提の負荷」に合わせて用意されがちだということです。ヒット率が急に落ちる瞬間(キャッシュの再起動、人気キーの期限切れ、デプロイ直後の全消去)には、オリジンへ何倍もの負荷が一気に来ます。後半で扱うスタンピード対策は、この「急に落ちる瞬間」を守るための道具です。

キャッシュに向くのは、読み込みが書き込みよりずっと多いデータ、多少古くても困らないデータ、作るのが重いデータ(集計、ランキング、外部 API の応答、レンダリング結果)です。逆に、書き込みのたびに必ず最新が必要なデータ(残高や在庫の引き当てなど)は、キャッシュの前にインデックス設計やクエリの見直しで速くできないかを先に考えたほうが安全です。

キャッシュをどこに置くか

キャッシュはリクエストの通り道のどこにでも置けます。ユーザーに近いほど速く、オリジンに近いほど制御しやすいという関係があります。

置き場所例速さ共有範囲無効化のしやすさ
ブラウザHTTP キャッシュ、Service Worker最速(通信なし)そのユーザーだけ難しい(相手の端末にある)
CDN・リバースプロキシCloudFront、Fastly、Varnish速い全ユーザーパージ API や短い TTL で対応
アプリ内メモリプロセス内の辞書、LRUとても速いそのプロセスだけプロセスごとにばらばらになる
分散キャッシュRedis、Memcached速い(ネットワーク1往復)全アプリサーバーキー単位で削除できる

ブラウザと CDN は HTTP ヘッダで制御する世界で、詳細はCDN の記事に譲ります。アプリ内メモリはネットワークを挟まないぶん最速ですが、サーバーが10台あれば10個の独立したコピーができ、更新をすべてに行き渡らせるのが難しくなります。そこで、複数のアプリサーバーから共有できる分散キャッシュ(Redis や Memcached)を DB の前に置くのが、Web アプリケーションでは最もよく見る構成です。大量のキーを複数ノードに分けて持つときのキーの振り分けはコンシステントハッシュが担います。

以降は主に、この分散キャッシュと DB の組み合わせを前提に話を進めます。

読み込みパターン - Cache-Aside と Read-Through

Cache-Aside(Lazy Loading)

最もよく使われるのが Cache-Aside です。AWS のホワイトペーパー「Database Caching Strategies Using Redis」も、Cache-Aside(Lazy Loading)を「最も一般的なキャッシュ戦略」と紹介しています。手順は単純です。

  1. まずキャッシュを見る。あればそれを返す(ヒット)。
  2. なければ DB から読む(ミス)。
  3. 読んだ値を TTL つきでキャッシュに入れてから返す。

redis-py を使ったスケッチは次のとおりです(接続や直列化の細部は省いています)。

import json
import redis
 
r = redis.Redis()
TTL_SECONDS = 300
 
 
def get_product(product_id: int) -> dict:
    key = f"product:{product_id}"
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)  # ヒット
 
    product = db_fetch_product(product_id)  # ミス: DB から読む
    r.set(key, json.dumps(product), ex=TTL_SECONDS)  # TTL つきで入れる
    return product

キャッシュに入るのは実際に読まれたデータだけなので、容量を無駄にしにくいのが利点です。キャッシュが落ちても、遅くなるだけで DB から読めるので壊れにくいという性質もあります。Facebook の論文「Scaling Memcache at Facebook」(NSDI 2013)も、memcache を「demand-filled look-aside cache」として使い、ミスしたら Web サーバーが DB から読んでキャッシュに入れると説明しています。

欠点は、ミスのたびにキャッシュと DB の2往復ぶん遅くなることと、キャッシュの読み書きのロジックがアプリケーションのあちこちに散らばりやすいことです。そして後で見るとおり、人気キーが切れた瞬間にミスが集中するという弱点を構造的に持っています。

Read-Through

Read-Through は、ミスしたときの DB 読み込みをキャッシュ側(またはキャッシュライブラリ)が肩代わりする形です。アプリケーションはキャッシュにだけ問い合わせ、キャッシュは自分の中に無ければ、登録されたローダーを呼んで DB から読み、保存してから返します。Oracle Coherence のドキュメントでは、キャッシュに無いキーを要求されると CacheStore に読み込みを委ね、取得した値をキャッシュに置いてから返す動作を Read-Through と呼んでいます。

Cache-Aside との違いは「誰がミス処理を書くか」だけで、データの流れはほぼ同じです。Read-Through にするとミス処理が1か所にまとまり、同じキーへの同時ミスをキャッシュ側でまとめる(後述の singleflight 相当の処理を入れる)こともやりやすくなります。アプリ内で使う「読み込み関数を登録しておくと、無ければ呼んで覚えておく」形のキャッシュライブラリは、この形に近い使い方です。

書き込みパターン - Write-Through・Write-Behind・Write-Around

データを更新するとき、キャッシュをどう扱うかにもいくつかの型があります。

Write-Through

DB を更新したら、同じ処理の中でキャッシュも新しい値に更新する方式です。AWS の ElastiCache のドキュメントは、利点として「キャッシュのデータが古くならない」ことと、書き込みのたびにキャッシュと DB の2回書くぶん遅くはなるものの、利用者は読み込みより書き込みの遅さのほうを許容しやすい(読み込み時の遅さを書き込み時に移せる)ことを挙げています。欠点としては、ノードの追加や障害で空になったキャッシュは DB 側で追加・更新されるまで埋まらないこと(Missing data)と、ほとんど読まれないデータまでキャッシュに入って資源を無駄にすること(Cache churn)を挙げています。前述のホワイトペーパーも、Write-Through は「ほとんどの場合 Lazy Loading と組み合わせて実装される」と書いています。書き込みでキャッシュを温めつつ、無いときは読み込み側で埋める、という併用が基本です。

Write-Behind(Write-Back)

アプリケーションはキャッシュにだけ書いてすぐ戻り、DB への反映はキャッシュ側が後でまとめて非同期に行う方式です。Coherence のドキュメントでは、変更されたエントリーを設定した遅延の後に非同期でデータソースへ書き込む動作として説明されています。書き込みが速く、同じキーへの連続した更新を1回にまとめられる利点がある一方、DB に書く前にキャッシュが落ちると更新そのものを失う危険があります。キャッシュが一時的に「正のデータ」を持つことになるので、永続化や複製の仕組みを持つキャッシュ製品でないと採用しにくい方式です。CPU のキャッシュでいうライトバックと同じ発想で、背景はCPU キャッシュとメモリ階層の記事も参考になります。

Write-Around

更新時はDB にだけ書き、キャッシュには入れない方式です。キャッシュは読み込み側(Cache-Aside)で必要になったときだけ埋まります。書いたきり読まれないデータでキャッシュを埋めないのが利点で、ログや一度きりのデータの書き込みに向きます。ただし、その時点でキャッシュに古い値が残っていると読み手に古い値が見え続けるので、実際には「DB に書いたらキャッシュのキーを削除する」と組み合わせて使います。これが次の節のテーマです。

無効化 - TTL、更新時の削除、バージョン付きキー

コンピュータサイエンスで難しいのはキャッシュの無効化と名前付けだけだ、という有名な冗談があるとおり、キャッシュ設計の苦労の大半はここにあります。

TTL は最後の安全網

すべてのキーに TTL(有効期限)を付けるのが基本です。どんなバグや競合で古い値が残っても、TTL が切れれば必ず DB の値に戻るからです。AWS の ElastiCache のドキュメントも、Lazy Loading と Write-Through の両方の欠点を和らげる方法として、書き込みのたびに TTL を付けることを勧めています。TTL の長さは「どれだけ古い値を許容できるか」と「ヒット率」の綱引きで決めます。設定値や商品マスタなら数分から数時間、ランキングのように計算が重く多少の遅れが許されるものなら数十秒、といった具合です。

更新時は set ではなく delete する

DB を更新したとき、キャッシュに新しい値を set するのではなく、キーを delete して次の読み込みに作り直させるのが定石です。Facebook の論文は、書き込み時は DB に SQL を発行した後に memcache へ delete を送ると書き、その理由を「delete は冪等だから」と説明しています。

set で上書きする方式には、並行する2つの更新の順序が入れ替わる問題があります。更新 X(値 1)と更新 Y(値 2)がこの順に DB へ反映されても、キャッシュへの set が Y、X の順に届けば、キャッシュには古い 1 が残ります。delete なら何回、どの順で届いても「キーが無い」という同じ状態に落ち着くので、この入れ替わりの影響を受けません。順序も大事で、Microsoft の Azure Architecture Center の Cache-Aside パターンの解説は「DB を更新してからキャッシュの項目を削除する」よう勧め、先に削除すると DB 更新前の値を別のクライアントが読んで入れ直してしまう小さな隙間ができると注意しています。

それでも古い値が残るレース

ただし「DB 更新の後に delete」でも完全ではありません。Cache-Aside の読み手と、更新する書き手が次の順に動くと、古い値がキャッシュに残ります。手順を1ステップずつ並べて再現したコードです。

# 読み手 A(Cache-Aside のミス処理)と書き手 B(DB 更新 + キャッシュ削除)が
# 次の順番で交互に動いたときに何が残るかを、1ステップずつ再現する
db = {"price": 100}
cache = {}
 
# A: キャッシュを見る -> ミス
a_hit = cache.get("price")
# A: DB から読む(まだ古い値 100)
a_value = db["price"]
# B: DB を 120 に更新
db["price"] = 120
# B: キャッシュを削除(まだ何も入っていないので空振り)
cache.pop("price", None)
# A: 手元の古い値をキャッシュへ書く
cache["price"] = a_value
 
print("DB   :", db["price"])
print("cache:", cache["price"])

実行結果(Python 3.13.7):

DB   : 120
cache: 100

読み手が DB から古い値を読んでからキャッシュに書くまでの間に、書き手の更新と削除が割り込むと、削除は空振りし、その後に古い値が書き込まれます。このキーは次の TTL 切れまで古いままです。読み込みが遅いほど(重いクエリほど)起こりやすくなります。

対策はいくつかあります。

  • TTL を必ず付ける: 古い値が残る時間の上限を決める。最低限の対策です。
  • 書き込み権のトークンで検証する: Facebook の leases は、ミスしたクライアントに 64 ビットのトークンを渡し、set のときにそのトークンを添えさせます。その間に delete が来ていればトークンは無効になり、古い値の set は拒否されます。論文はこの問題を「stale set」と呼び、leases で防いでいます。
  • 値にバージョンを持たせる: DB 側の更新番号や更新時刻を値に含め、キャッシュに書くときに「今入っているものより新しい場合だけ書く」比較を(Redis なら Lua スクリプトなどで)原子的に行う。

どこまでやるかは、古い値が見えたときの損害で決めます。数十秒古いランキングが見える程度なら TTL で十分なことが多く、価格や権限のように古い値が事故になるデータは、そもそもキャッシュしないか、短い TTL と検証付きの書き込みを組み合わせます。トランザクションの分離レベルと同じく、「どの程度の食い違いを許すか」を決めることが設計の中心です(トランザクションと分離レベルの記事も参照)。

バージョン付きキー

キーの名前にバージョンを含める方法もあります。user:42:profile:v7 のように値の版をキーに入れておけば、更新時はバージョンを上げるだけで、読み手は新しいキーを見に行き、古いキーは TTL かエビクションで自然に消えます。「商品カテゴリ 10 に属するキャッシュをまとめて無効化したい」のように、多数のキーを一度に捨てたい場合にも便利です。category:10:ver という小さなキーに世代番号を持たせ、各キャッシュキーを category:10:v{世代}:... の形で組み立てれば、世代番号を1つ増やすだけでカテゴリ全体を無効にできます。その代わり、読み込みのたびに世代番号を引く1往復が増え、古い世代のキーがメモリを占めたまま残る点には注意が要ります。

キャッシュスタンピードとその対策

何が起きるのか

人気のあるキーが TTL で切れた瞬間を考えます。毎秒 200 件読まれているキーで、作り直しに 300ms かかるとすると、最初のミスが DB から戻ってくるまでの 300ms の間に来たおよそ 60 件のリクエストも、すべてミスして DB へ向かいます。これがキャッシュスタンピード(cache stampede、thundering herd、dogpile とも呼ばれる)です。Vattani らの論文は、人気のある項目が切れて多数のリクエストが同時にミスし、同じ項目を同時に作り直す状況と定義し、同時の再計算が1件ごとの再計算を遅くし、さらに多くのリクエストを巻き込む連鎖的な障害になると説明しています。

DB が遅くなると作り直しの時間 Δ が延び、その間に来るリクエストが増え、さらに DB が遅くなる、という悪循環です。レジリエンスパターンの記事で扱ったリトライストームと同じ構造で、キャッシュの内側で起きるサンダリングハードと言えます。

下のシミュレーションは、1つの人気キーに対してポアソン到着のリクエストを60秒間流し、3つの方式でオリジンへの問い合わせがどう変わるかを描いたものです。棒は1秒ごとのオリジン問い合わせ件数で、縦軸は3方式で共通です。

▶ TTL 切れの瞬間に何件がオリジンへ行くか(1つの人気キー・60秒間)
ポアソン到着のリクエストを3方式に同じ順番で流し、1秒ごとのオリジン問い合わせ件数を描きます。縦軸は3方式で共通です。条件を変えると再計算します。
リクエスト数(毎秒)
再計算にかかる時間
TTL
XFetch の β
乱数の種 42 / 総リクエスト 11,885 件
対策なし期限切れを見たリクエストが全員オリジンへ
オリジン問い合わせ 324 回
同時実行の最大 68
待たされた 324 件
平均待ち 300 ms
ロック(singleflight)1件だけが再計算し、残りは結果を待つ
オリジン問い合わせ 5 回
同時実行の最大 1
待たされた 310 件
平均待ち 151 ms
確率的早期再計算(XFetch)期限前に確率的に1件が作り直す
オリジン問い合わせ 8 回
同時実行の最大 2
待たされた 8 件
平均待ち 300 ms
単純化した模型です。再計算時間は負荷によらず一定、キーは1つ、ロックの取得失敗や再試行の待ち時間は考えていません。

初期状態(毎秒 200 件、再計算 300ms、TTL 10 秒、β = 1、乱数の種 42、総リクエスト 11,885 件)でこのデモのロジックを Node で実行した結果は次のとおりです。

方式オリジン問い合わせ同時実行の最大オリジンを待たされたリクエスト
対策なし324 回68324 件(各 300ms)
ロック(singleflight)5 回1310 件(平均 151ms)
確率的早期再計算(XFetch)8 回28 件(各 300ms)

対策なしでは TTL が切れるたびに 60 件前後のスパイクが立ちます。ロックはオリジンへの問い合わせを1件に絞れますが、その間に来たリクエストは結果を待たされます。XFetch は期限の少し前に1件だけが作り直すので、ほとんどのリクエストは待たずにヒットし続けます。XFetch の問い合わせ回数がロックより多いのは、期限より前に作り直すぶん1周期が少し短くなることと、まれに2件が同時に早期再計算を引くことによるものです。同じ条件で β を 0.5 にすると XFetch は 38 回、β を 2 にすると 16 回でした。β が小さいと早期再計算が間に合わずに期限切れのスパイクが残り、大きいと早めに作り直しすぎる、という傾向がデモでも確認できます。

以下、代表的な対策を順に見ていきます。

ロックと singleflight - 作り直すのは1人だけ

最も素直な対策は、同じキーの作り直しは1件だけに任せ、他はその結果を待つことです。1つのプロセスの中であれば、キーごとのロックで実現できます。50 本のスレッドが期限切れ直後に同時に読みに来た状況を、ロックなしとロックありで比べたコードです。

import threading
import time
 
cache = {}
origin_calls = 0
count_lock = threading.Lock()
 
 
def load_from_db(key):
    global origin_calls
    with count_lock:
        origin_calls += 1
    time.sleep(0.2)  # 重いクエリのつもり
    return f"value-of-{key}"
 
 
def get_naive(key):
    value = cache.get(key)
    if value is None:  # ミスしたら各自が DB へ
        value = load_from_db(key)
        cache[key] = value
    return value
 
 
key_locks = {}
key_locks_guard = threading.Lock()
 
 
def get_with_lock(key):
    value = cache.get(key)
    if value is not None:
        return value
    with key_locks_guard:
        lock = key_locks.setdefault(key, threading.Lock())
    with lock:  # 同じキーの再計算は1つだけ
        value = cache.get(key)  # 待っている間に誰かが入れたかもしれない
        if value is None:
            value = load_from_db(key)
            cache[key] = value
    return value
 
 
def run(getter, n=50):
    global origin_calls
    cache.clear()
    origin_calls = 0
    barrier = threading.Barrier(n)
 
    def worker():
        barrier.wait()  # 期限切れの直後に50件が同時に来た状況を作る
        getter("ranking")
 
    threads = [threading.Thread(target=worker) for _ in range(n)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return origin_calls
 
 
print("対策なし :", run(get_naive), "回 DB へ")
print("ロックあり:", run(get_with_lock), "回 DB へ")

実行結果(Python 3.13.7):

対策なし : 50 回 DB へ
ロックあり: 1 回 DB へ

ロックを取った後にもう一度キャッシュを見直す(ダブルチェック)のがポイントです。待っていた間に先行者が値を入れているので、2番目以降はそれを使えます。

Go には、この「同じキーの重複呼び出しをまとめる」処理が golang.org/x/sync/singleflight パッケージとして用意されています。pkg.go.dev の説明では、Do は同じキーについて実行中の処理が1つだけになるようにし、重複した呼び出し側は元の処理の完了を待って同じ結果を受け取ります。戻り値の shared は、その値が複数の呼び出し側に渡されたかどうかを表します。

package main
 
import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
 
	"golang.org/x/sync/singleflight"
)
 
func main() {
	var g singleflight.Group
	var originCalls atomic.Int32
	var sharedCount atomic.Int32
 
	var wg sync.WaitGroup
	start := make(chan struct{})
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			v, err, shared := g.Do("ranking", func() (any, error) {
				originCalls.Add(1)
				time.Sleep(200 * time.Millisecond) // 重いクエリのつもり
				return "value-of-ranking", nil
			})
			if err == nil && shared {
				sharedCount.Add(1)
			}
			_ = v
		}()
	}
	close(start)
	wg.Wait()
	fmt.Println("origin calls:", originCalls.Load())
	fmt.Println("shared results:", sharedCount.Load())
}

実行結果(Go 1.26.0、golang.org/x/sync v0.24.0):

origin calls: 1
shared results: 50

50 個の goroutine が同時に呼んでも、関数の実行は1回で、50 件すべてが共有された結果を受け取りました。

注意点は、singleflight もプロセス内のロックも1つのプロセスの中でしか効かないことです。アプリサーバーが 20 台あれば、最悪 20 件は DB に届きます。多くの場合はそれで十分ですが、全台で1件に絞りたいなら、Redis の SET lock:キー トークン NX PX 10000 のように「無ければ書く(NX)」と有効期限(PX)を組み合わせた分散ロックを使い、ロックを取れなかった側は少し待ってからキャッシュを読み直します。分散ロックは、取得側が落ちたときの期限切れ、待つ側のタイムアウト、ロック自体の障害と、考えることが一気に増えるので、本当に必要な場面に絞るのがよいでしょう。

TTL にジッターを入れる

多数のキーを同じ時刻にまとめて作る処理(起動時のウォームアップ、バッチでの一括投入など)で全キーに同じ TTL を付けると、全キーが同じ時刻に切れ、DB にミスが集中します。AWS のホワイトペーパー(Cache Validity の章)は、TTL に少しのジッター(例: TTL = 初期 TTL + ランダムな秒数)を加えることを推奨し、バックエンドの DB の負荷と、期限切れキーの削除に伴うキャッシュエンジンの CPU 使用を減らせると説明しています。1つのキーのスタンピードは防げませんが、「多数のキーが同時に切れる」ことによる波を平らにする、安くて効果の大きい対策です。

確率的早期再計算(XFetch)

期限切れの瞬間に誰かがミスするのが問題なら、切れる前に誰か1人が作り直してしまえばよい、というのが確率的早期再計算です。Vattani、Chierichetti、Lowenstein の論文「Optimal Probabilistic Cache Stampede Prevention」(Proceedings of the VLDB Endowment, Vol. 8, No. 8, 2015)は、各リクエストが期限の前にランダムに再計算を申し出る方法を分析し、最適なアルゴリズムとして XFetch を示しています。論文の擬似コードは次の数行です。

function XFetch(key, ttl; β = 1)
    value, Δ, expiry ← CacheRead(key)
    if !value or Time() − Δ·β·log(rand()) ≥ expiry then
        start ← Time()
        value ← RecomputeValue()
        Δ ← Time() – start
        CacheWrite(key, (value, Δ), ttl)
    end
    return value

キャッシュには値と一緒に「前回の作り直しにかかった時間 Δ」を保存しておきます。rand() は 0 から 1 の一様乱数なので −log(rand()) は正の値(指数分布)になり、各リクエストは「現在時刻に Δ·β·(−log(rand())) を足した時刻が期限を過ぎていたら、自分が作り直す」と判定します。期限まで遠いうちはほぼ起きず、期限が近づくほど起きやすくなります。1件のリクエストが早期再計算を引く確率は exp(−残り時間 / (Δ·β)) です。Δ = 0.3 秒、β = 1 のときに計算すると次のようになります。

import math
 
delta, beta = 0.3, 1.0
for remaining in [3, 2, 1, 0.5, 0.3, 0.1, 0.0]:
    print(f"残り {remaining:>4} 秒: {math.exp(-remaining / (delta * beta)):.4f}")

実行結果(Python 3.13.7):

残り    3 秒: 0.0000
残り    2 秒: 0.0013
残り    1 秒: 0.0357
残り  0.5 秒: 0.1889
残り  0.3 秒: 0.3679
残り  0.1 秒: 0.7165
残り  0.0 秒: 1.0000

1件ごとの確率は小さくても、毎秒 200 件来るキーなら残り1秒の時点で誰かがほぼ確実に引きます。逆にアクセスがまばらなキーでは誰も引かないまま期限を迎えますが、そういうキーはそもそもスタンピードが起きません。アクセスが多いキーほど自然に早めに作り直されるのがこの方式のうまいところで、ロックも共有状態も要りません。論文は、β の既定値 1 で十分に効果があり、早めの期限切れが気にならないなら β を大きくすればさらに強く保証できる、と述べています。作り直し時間 Δ を値と一緒に保存する必要があるので、キャッシュに入れる値の形を少し変えることになります。

stale-while-revalidate - 古い値を返しながら裏で作り直す

もう1つの考え方は、期限が切れてもしばらくは古い値を返し続け、その間に裏で作り直すことです。HTTP の世界では RFC 5861(M. Nottingham、2010年5月、Informational)が stale-while-revalidate という Cache-Control 拡張として定義しています。Cache-Control: max-age=600, stale-while-revalidate=30 なら、600 秒は新鮮で、その後さらに最大 30 秒は、非同期の再検証を試みながら古いレスポンスを返してよい、という意味です。RFC はこの期間内にリクエストが来なければ再検証は起こらず、期間を過ぎたら通常どおりブロックして取りに行く、と説明しています。

同じ発想はアプリケーションのキャッシュにも使えます。値に「新鮮な期限(soft TTL)」と「捨てる期限(hard TTL)」の2つを持たせ、soft TTL を過ぎた値を読んだリクエストは古い値をそのまま返しつつ、バックグラウンドで1件だけ(ロックや singleflight で絞って)作り直しを始めます。リクエストを待たせない点が単純なロックより優れており、古い値を許容できるデータなら最も体感のよい方式です。CDN 側での使い方はCDN の記事を参照してください。

Facebook の leases - 待たせる時間をサーバーが配る

前述の leases は、スタンピード対策も兼ねています。Facebook の論文によると、各 memcached サーバーはトークンを返す頻度を制御しており、既定ではキーごとに 10 秒に1回しかトークンを出しません。トークン発行から 10 秒以内に同じキーを読みに来たクライアントには「少し待て」という特別な通知を返し、その間にトークンを持つクライアントが数ミリ秒で値を入れるので、待った側が再試行するとたいていキャッシュに値が入っている、という仕組みです。論文は、スタンピードの起きやすいキーの集合で1週間計測したところ、leases なしでは DB へのクエリが最大で毎秒 17K だったのに対し、leases ありでは毎秒 1.3K だったと報告しています。キャッシュサーバー自身が「作り直してよい人」を1人に絞る、分散版の singleflight と見ることができます。同じ論文には、削除された直後の古い値を短い間だけ保持し、多少古くてもよい用途にはそれを返して待ち時間を減らす工夫も書かれています。

ネガティブキャッシュ・ペネトレーション・ホットキー

存在しないデータのキャッシュ(ネガティブキャッシュ)

Cache-Aside は「DB にあったものをキャッシュする」ので、DB に存在しないキーへの問い合わせは毎回ミスして DB まで届きます。存在しない商品 ID や削除済みユーザーへのアクセスが多いと、キャッシュがあるのに DB の負荷が下がりません。悪意のあるリクエストがランダムな ID を大量に送ってくると、キャッシュを素通りして DB を直撃します。これをキャッシュペネトレーション(cache penetration)と呼ぶことがあります。

対策の基本は、「無かった」という結果もキャッシュすることです(ネガティブキャッシュ)。値の代わりに「該当なし」を表す番兵を、通常より短い TTL で入れておきます。後からデータが作られる可能性があるので TTL は短めにし、作成時にそのキーを削除するのを忘れないようにします。ID の形式チェックで明らかに不正なリクエストを手前で弾く、存在するキーの集合をブルームフィルタで持って「確実に存在しない」ものをキャッシュより前で返す、といった方法も併用されます。外部からの大量アクセスそのものを抑えるならレート制限の出番です。

ホットキー

1つのキーにアクセスが極端に集中すると、分散キャッシュではそのキーを持つ1台のノードだけが過負荷になります。キーの振り分けはハッシュで決まるので、ノードを増やしてもそのキーの負荷は分散されません。代表的な対策は次のとおりです。

  • アプリ内メモリに短い TTL で2段目を置く: 数秒だけでもプロセス内に持てば、分散キャッシュへの問い合わせはアプリサーバーの台数ぶんに抑えられます。
  • キーを複製する: ranking:top:0 から ranking:top:7 のように同じ値を複数のキー(=複数のノード)に置き、読み手はランダムに1つを選びます。更新時は全部を消す必要があります。
  • キャッシュ側の複製: Facebook の論文には、同時に多数のキーを読み、データ全体が1〜2台に収まり、1台では捌けない要求率になるキーの種類について、プール内で複製して負荷を分ける運用が書かれています。

エビクション方針との関係

TTL で消えるのとは別に、キャッシュはメモリが一杯になるとキーを追い出します(エビクション)。何を捨てるかの方針はヒット率に直結し、キャッシュ戦略の前提になります。アルゴリズムの詳細はLRUキャッシュ入門で扱っているので、ここでは Redis の設定との関係だけ押さえます。

Redis は maxmemory で上限を決め、上限に達したときの動きを maxmemory-policy で選びます。redis.conf の既定値は noeviction で、これはキーを捨てず、新しいデータを書こうとするコマンドにエラーを返す設定です。Redis をキャッシュとして使うなら、既定のままにしないことが重要です。Redis のドキュメントに載っている主な方針は次のとおりです。

方針追い出す対象
noeviction追い出さない(書き込みがエラーになる)
allkeys-lru全キーから、最も長く使われていないもの
allkeys-lfu全キーから、使われる頻度が最も低いもの
allkeys-random全キーからランダムに
volatile-lru / volatile-lfu / volatile-randomTTL が付いたキーの中から、それぞれの基準で
volatile-ttlTTL が付いたキーの中から、残り時間が最も短いもの
allkeys-lrm / volatile-lrm最も長く書き換えられていないもの(Redis 8.6 以降)

ドキュメントは、特に理由がなければ allkeys-lru がよい既定の選択であるとし、一部のキーがほかよりずっと頻繁に読まれる場合にも allkeys-lru を挙げています。また、volatile 系の方針は TTL が付いたキーが1つも無いと noeviction と同じ動きになる点にも触れています。キャッシュ専用のインスタンスなら allkeys 系、永続的に残したいキーと同居させるなら volatile 系、という使い分けになります。Redis の LRU と LFU はサンプリングによる近似で、maxmemory-samples(既定 5)でその精度を調整できます。

エビクションとキャッシュ戦略は互いに影響し合います。Write-Through で一度も読まれないデータまで入れると、LRU でよく読まれるデータが押し出されてヒット率が下がることがありますし、バージョン付きキーで古い世代が残り続ければ、それもメモリを圧迫します。Redis の INFO stats に出る evicted_keys や keyspace_hits / keyspace_misses を監視し、ヒット率とエビクションの数を合わせて見ておくのが運用の基本です。

パターンの選び方

ここまでのパターンを、何を優先するかで並べると次のようになります。

パターン向いている場面主な弱点
Cache-Aside読み込み中心の大半のケース。まずはこれミス時に遅い。人気キーの期限切れでスタンピード
Read-Throughミス処理を1か所にまとめたい。ライブラリが対応しているキャッシュ側(ライブラリ)への依存が増える
Write-Through書いた直後に読まれることが多い。古い値を見せたくない書き込みが遅くなる。読まれないデータも入る
Write-Behind書き込みが非常に多く、まとめて DB に反映したいキャッシュ障害で更新を失う危険
Write-Around書いたデータがすぐには読まれない書いた直後の読み込みは必ずミスする

スタンピード対策の比較です。

対策効く範囲待ち時間実装の手間
プロセス内ロック / singleflight1プロセス内の同一キー先行者の完了まで待つ小さい
分散ロック全サーバーの同一キー待つ・再試行が要る大きい
TTL ジッター多数のキーの同時期限切れなしとても小さい
XFetch(確率的早期再計算)人気キーの期限切れほぼなし(引いた1件だけ)小さい(Δ の保存が要る)
stale-while-revalidate古い値を許せるデータなし中程度(裏で作り直す仕組みが要る)

導入時に確認したいことをチェックリストにまとめます。

  • そのデータは、どれだけ古くてよいか(TTL の上限)を決めたか
  • すべてのキーに TTL を付けたか。TTL にジッターを入れたか
  • 更新時は「DB を更新してからキーを削除」しているか(set で上書きしていないか)
  • 古い値が残るレースを、TTL で許容するのか、トークンやバージョン比較で防ぐのか決めたか
  • 人気キーの期限切れに対して、ロック、XFetch、stale-while-revalidate のいずれかを入れたか
  • 存在しないキーへの問い合わせをネガティブキャッシュや入力チェックで止めているか
  • 特定のキーにアクセスが集中したときの逃がし方(アプリ内キャッシュ、複製)を考えたか
  • Redis の maxmemory と maxmemory-policy を設定したか(既定の noeviction のままにしていないか)
  • ヒット率、エビクション数、オリジンへの問い合わせ数を監視しているか
  • キャッシュが全部消えた状態(再起動、障害、デプロイ)でも DB が耐えられるか、または段階的に温める手順があるか

まとめ

  • キャッシュの効果はヒット率で決まり、ヒット率の上のほうの数ポイントがオリジンの負荷を桁で変える。だからこそ、ヒット率が急落する瞬間への備えが要る。
  • 読み込みは Cache-Aside が基本。ミス処理をキャッシュ側に任せる Read-Through もある。
  • 書き込みは Write-Through、Write-Behind、Write-Around があり、Cache-Aside と組み合わせて使うのが一般的。
  • 無効化は「TTL を必ず付ける」「DB 更新後に delete する」が基本。それでも古い値が残るレースがあり、許容するか、leases やバージョン比較で防ぐかを決める。
  • スタンピードには、ロックと singleflight、TTL ジッター、XFetch、stale-while-revalidate、leases がある。待たせたくないなら XFetch か stale-while-revalidate が有力。
  • ネガティブキャッシュ、ホットキー対策、エビクション方針(Redis では noeviction のままにしない)まで含めて、キャッシュ戦略になる。

キャッシュは、オリジンを守るための仕組みであると同時に、オリジンの前にもう1つ障害点を足す仕組みでもあります。レジリエンスパターンと同じく、「壊れたとき、切れたとき、一斉に来たときにどうなるか」から逆算して設計するのが近道です。

参考リンク

WordPress Transients API 徹底解説 - キャッシュ・落とし穴・オブジェクトキャッシュ連携まで

WordPress Transients API 徹底解説 - キャッシュ・落とし穴・オブジェクトキャッシュ連携まで

約22分

WordPress 標準の一時キャッシュ機構「Transients API」を徹底解説。set_transient / get_transient / delete_transient の基本から、site_transient のマルチサイト挙動、wp_options への保存と Redis/Memcached 連携時の挙動の違い、そして「期限切れトランジェントが残り続ける遅延ガベージコレクション」などの落とし穴と本番運用での回避策までを実装サンプル付きで網羅します。

CPUキャッシュとメモリ階層 入門 - キャッシュラインと局所性から実測まで

CPUキャッシュとメモリ階層 入門 - キャッシュラインと局所性から実測まで

約30分

CPUキャッシュとメモリ階層の仕組みを、キャッシュライン・参照の局所性・マッピング方式・書き込みポリシーから、配列の走査順やAoS/SoA、false sharing、perfやInstrumentsでの計測まで、一次情報と実測値をもとに解説します。

CRDT と OT 入門 - 共同編集が衝突せずに収束する仕組み

CRDT と OT 入門 - 共同編集が衝突せずに収束する仕組み

約39分

Google ドキュメントや Figma のような同時編集を支える2つの考え方、Operational Transformation(OT)と Conflict-free Replicated Data Types(CRDT)を解説します。transform 関数、半束とマージの性質、G-Counter・LWW-Register・OR-Set、テキスト用の RGA・YATA・Fugue、Yjs・Automerge・Loro、トゥームストーンの注意点まで、TypeScript の動く実装付きで追います。