ブログ一覧

キャッシュスタンピードとは?Redis・TTL・singleflightで防ぐDBへの集中

· 約12分で読めます

RedisのTTLが切れるたびにDBが重くなる原因と対策を解説。TTLのジッター、Goのsingleflight、古い値を返す更新方式、分散ロックの違いを、毎秒1万件の商品APIの計算例と実装例で比較します。

キャッシュスタンピードとは?TTL切れでDBが重くなる仕組み

商品詳細APIが、最初にRedisを確認し、見つからなければDBを読み、結果をRedisへ保存する。このキャッシュアサイド(cache-aside)方式では、キャッシュを作り直している間にも次の要求が届きます。人気商品のTTL(有効期間)が切れ、全員が同じDB取得を始めると、キャッシュスタンピードが起きます。

対策がない場合:先頭の要求がキャッシュを再作成する前に、後続もDBを読む
  1. API / Redis: 人気商品のキーが期限切れになる
  2. 利用者: 要求Aが届き、キャッシュミスする
  3. DB: Aの商品データを取得中
  4. 利用者: 要求B・Cも同じキーでミスする
  5. DB: B・Cも同じ取得を開始し、負荷が増える
  6. API / Redis: Aが結果を保存するまで、重複する取得が続く

DBが遅くなるほど再作成に時間がかかり、その間にさらにミスが増えます。調査では、期限切れの時刻、同じ取得処理の開始数、DBの待ち時間を重ねて見ます。TTL切れの直後に毎回山が立つなら有力な手がかりですが、メモリ不足による追い出しやRedisの通信障害でもミスは増えるため、期限切れだけを原因と決めつけません。

計算例:毎秒1万件、再取得500msなら何件が重なるか

キャッシュが使えない500msの間に届く要求は、10,000件/秒 × 0.5秒 = 約5,000件です。対策がなければ、その要求がそれぞれ同じ取得を試みます。これは窓内の要求数の概算で、5,000本のSQLが同時実行できるという意味ではありません。接続プールで待つ要求もあり、DBの飽和で500msが延びれば、影響する件数も増えます。

次に、API全体の読み取りを毎秒1万件として、定常時の負荷を見ます。ミス1回につきDB読み取り1回で、集約も再試行もない単純モデルなら、DBの読み取り数は 要求数 × (1 − ヒット率)。ヒット率99%で毎秒100回、90%で1,000回、キャッシュが全く使えず全件DBへ進めば10,000回です。99%からの変化は最大100倍になります。

仮定の計算例:API全体で毎秒1万要求、各ミスが1回DBを読む場合の毎秒のDB読み取り数
  • ヒット率99%: 100回/秒
  • ヒット率90%: 1,000回/秒
  • ヒット率0%・全件DBへ: 10,000回/秒

時間平均のヒット率は、失効直後の短い集中を隠します。ヒット率に加えて、短い時間窓でのDB流入と、同じキーの再取得の重複を確認します。商品IDをそのままメトリクスのラベルに大量に追加せず、調査用のトレースやサンプリングしたログでキーの偏りを追います。

対策の比較:TTLジッター・singleflight・古い値・分散ロック

困っていることまず検討する対策残る課題
同時に作った多数のキーが一斉に切れるTTLにランダムな幅を付ける1つの人気キーへの集中は残る
同じキーで重い再取得が重複するsingleflightで進行中の処理を共有する共有するGroupの範囲外では別の処理が走る
再取得を待たせたくない期限内の古い値を返し、裏で更新する古さの上限と更新処理の集約が必要
複数プロセス間でも再取得を減らしたいRedisなどで期限付きの更新権を取る期限超過・障害時の重複や古い書き戻しが残りうる
多種類のキーのミスやRedis障害でDBが飽和するDBへの同時実行数・待ち行列を制限する一部要求を縮退・拒否する判断が必要

TTLのジッターは複数キーの失効を分散する

バッチで大量の商品キャッシュを一度に作り、全てに300秒のTTLを付けると、5分後の失効も重なります。たとえば270〜300秒の一様乱数をTTLにすれば、失効時刻を30秒の幅へ散らせます。300秒を許容上限とするなら、300秒に正の乱数を足して上限を超えないようにします。

TTLを決める疑似コード:最大300秒という条件を守る例
ttl_seconds = uniform_random(270, 300)
cache.set(key, value, ttl=ttl_seconds)

ただし、同じ人気キーを読む全員に見える失効時刻は1つです。TTLをばらすだけでは、そのキーの失効後に再取得が重複する問題は解消しません。 長いTTLも発生頻度を下げる選択肢ですが、古い値を長く見せる代償があり、ミス時の守りは別に必要です。

Goのsingleflightで、同じキーの再取得をまとめる

Goのsingleflightは、同じGroupとキーで進行中の関数の結果を共有します。Groupをリクエストごとに作ると集約できないため、サービスのインスタンスなどで共有します。キーには結果を変えるテナント・言語・権限範囲も含め、異なる利用者の結果を混ぜないようにします。

Goのミス処理の抜粋:DoChanで要求の待機と共有更新の期限を分ける
// Service.flights: one shared singleflight.Group per process.
// Service.lifecycle: canceled when this service shuts down.
func (s *Service) onCacheMiss(ctx context.Context, key string) ([]byte, error) {
    result := s.flights.DoChan(key, func() (any, error) {
        refreshCtx, cancel := context.WithTimeout(s.lifecycle, 800*time.Millisecond)
        defer cancel()
        // Recheck the cache, acquire a bounded DB permit on a miss,
        // read the DB, then populate the cache before returning.
        return s.fetchAndCache(refreshCtx, key)
    })

    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    case r := <-result:
        if r.Err != nil {
            return nil, r.Err
        }
        return r.Val.([]byte), nil
    }
}

抜粋ではcontext・time・golang.org/x/sync/singleflightを使う周辺定義を省いています。fetchAndCacheは、キャッシュを再確認し、まだミスならDBの処理枠を上限付きで取り、DB取得とキャッシュ保存を行う自作関数です。各I/OはrefreshCtxの期限を守り、返す[]byteは呼び出し側で変更しないものとします。キャッシュ障害時の扱いも、この関数の契約として決めます。

DoChanを使うと、各要求はctx.Done()で待機をやめられます。共有更新はサービスのライフサイクルに結び付け、独立した800msの期限で制限しています。これにより先頭の利用者の切断だけで全員の更新が中断されるのを避けますが、待つ人がゼロになっても期限までは処理が残りえます。期限はDB・キャッシュのクライアント側でも検知させます。

stale-while-revalidate:古い値を返せる期限を2段階にする

ランキングや商品説明のように多少の古さを許せるデータでは、更新を待たずに前の値を返す設計ができます。ここでは保存完了を起点に、freshUntil = 60秒後、staleUntil = 90秒後とするアプリ側の方式を考えます。Redisの物理TTLも60秒で切ってしまうと古い値が残らないため、少なくともstaleUntilまで保存する設計が必要です。メモリ圧迫などで先に失われる場合は、通常のミスとして扱います。

キャッシュの状態利用者へ返す値更新処理
保存から60秒未満キャッシュの値通常は不要
60秒以上90秒未満許容した古い値更新を集約して裏で実行
90秒以上、または値が存在しない上限付きで取得を待つか、縮退・エラーDBの処理枠が取れれば取得する

裏で更新する場合も、要求ごとに無制限の更新タスクを作るとスタンピードが移動するだけです。キー単位の集約と、全キーを合わせたワーカー数・キューの上限を設けます。更新に失敗しても、古い値の許容期限を無条件に延ばしません。保存時点で既に古いレプリカを読んでいた場合、保存後90秒という条件だけでは元データからの古さは保証できないため、必要なら元データの版や取得時刻も扱います。

Redisの分散ロックを使う場合:取得・期限・解放をセットで考える

プロセス間の重複も減らすなら、更新する権利を共有します。単一Redisを使う基本形は、ランダムな所有者トークンと期限を付けてSET NX PXを行い、成功した処理だけが再取得する方式です。ロック取得後もキャッシュを再確認します。以下の1,500msは説明用で、再取得時間の観測と障害時の方針から決める値です。

Redis:期限付きの取得と、所有者を照合するLuaでの解放(別々の操作)
SET lock:product:42 <unique-owner-token> NX PX 1500

-- Release atomically, only if this caller still owns the lock.
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0

解放スクリプトはKEYS[1]にロックキー、ARGV[1]に取得時のトークンを渡して実行します。期限切れ後に別の処理が取得したロックを消さないため、GETとDELをアプリ側で分けず、原子的に照合します。取得できない要求は、許容期限内の古い値を返すか、短い期限とジッター付きの間隔でキャッシュを再確認します。全員がすぐDBへ進むと対策の意味がなくなります。

Redisが落ちたら?全件をDBへ流す前に上限を決める

同じキーを集約しても、異なる商品のミスが大量に来ればDBの取得は増えます。Redisに接続できないときも、全要求を無条件にDBへ通す設計では守れません。DBへの同時実行数と待機数を制限し、期限までに処理枠を取れない要求は簡易表示や503などへ切り替えます。制限をプロセス内に置くなら、最大プロセス数との積でDB全体の上限を見積もります。

この制御は、キャッシュ読み取り失敗後の経路とバックグラウンド更新の両方に適用します。Redisが壊れているなら、同じRedis上のロックも使えない可能性があります。復旧後は人気キーを優先して少しずつ温め、DBの余力を見ながら流量を戻します。共有更新の失敗後に次々とやり直す経路にも、バックオフや失敗を短時間共有する仕組みを用意します。

負荷試験と監視:ヒット率だけで対策を評価しない

試験条件確かめること
1つの人気キーを失効させる再取得の同時数が集約範囲に収まり、待つ要求が期限を守るか
多数キーをまとめて作成・失効させるTTLの分散と全体の更新処理数の上限が効くか
DBを遅延・エラーにする共有更新が終了し、失敗後の再取得が連打されないか
Redisとの通信を失敗させるDBの上限を超えず、縮退・拒否へ切り替わるか
キャッシュが空の状態から復旧する温め処理と通常要求が同じDBの枠を奪いすぎないか
更新中の停止・ロック期限超過を起こす再取得が再開し、古い結果の上書きを扱えるか

試験結果には、アプリのプロセス数、キーの分布、データ量、TTL、DBの状態、試験時間を残します。利用者のp99が下がっても、古い値を許容期限外まで返していたら要件を満たしていません。通常時の速さと、失効・障害時にDBを守れることをそれぞれ確認します。

よくある疑問:Redisを増やす、TTLを短くするだけで防げる?

設計面接で「キャッシュが落ちたら?」と聞かれたときの答え方

仕様と障害時の挙動を確認する一次資料

この記事で深掘りしたTips