1. 「キャッシュヒット率99%」という甘い罠

WebサービスやAPIのパフォーマンスチューニングにおいて、キャッシュの導入はもっとも即効性のある銀の弾丸に見えます。「とりあえず取得結果をインメモリキャッシュやRedisに入れて、TTLを5分に設定しておこう」。この素朴な実装だけで、通常時のDB負荷は劇的に下がり、レスポンスタイムもミリ秒以下に短縮されます。

しかし、アクセス数が急増した本番環境では、まさにこの「キャッシュが切れる瞬間」に壊滅的な障害が発生します。これがいわゆる Cache Stampede(キャッシュスタンピード)、別名 Thundering Herd(雷鳴の群れ) や Dog-piling と呼ばれる現象です。

[通常時]
Client A, B, C ----> [ Cache (HIT) ] (高速返却: 1ms)
                       DB負荷: ゼロ

[TTLが切れた瞬間 (Stampede発生)]
Client 1 ----> [ Cache (MISS) ] --------> [ Database (Heavy Query) ]
Client 2 ----> [ Cache (MISS) ] --------> [ Database (Heavy Query) ]
Client 3 ----> [ Cache (MISS) ] --------> [ Database (Heavy Query) ]
... 数百リクエストが同時にDBへ雪崩れ込む!
=======> コネクション枯渇・CPU 100%・DBダウン・サービス全停止

キャッシュヒット率が99%あったとしても、秒間1,000リクエストが押し寄せる人気コンテンツのTTLが切れた場合、DBへの再問い合わせが完了するまでの数百ミリ秒の間に、数百ものゴルーチンやスレッドが一斉に「キャッシュミスだ!」と判定し、全く同一の重いクエリをDBに並行して叩き込みます。

結果として、DBのコネクションプールは一瞬で枯渇し、タイムアウトエラーが連鎖してアプリケーションサーバー全体が共倒れになります。

「障害が起きたので、とりあえずキャッシュのTTLを1時間から24時間に延ばしました。」

現場ではこのような対症療法が取られがちですが、これは「時限爆弾のタイマーを先送りにした」に過ぎません。TTLが長くなればなるほどデータの鮮度維持やキャッシュ破棄(Invalidation)の難易度が跳ね上がり、システムの複雑性を泥沼化させます。

2. 安易な Mutex や Redis 分散ロックはなぜ悪手なのか?

Cache Stampede への対策として真っ先に思い浮かぶのが「排他制御(ロック)」です。しかし、設計の解像度が低いままロックを導入すると、別の致命的な問題を引き起こします。

単純なアプリケーション Mutex による巻き添え停止

単一の sync.Mutex でキャッシュ更新処理を直列化しようとすると、キーに関係なくすべてのキャッシュミスリクエストが待たされます。商品Aのキャッシュ再構築が重いせいで、全く無関係な商品Bや商品Cを閲覧したいユーザーまでブロックされ、システム全体のスループットが極端に低下します。

「じゃあキーごとに動的 Mutex を生成すればいいのでは?」と考えるかもしれませんが、今度は無制限に肥大化するロックオブジェクトのメモリリーク管理や、デッドロックの防止など、余計な並行処理の泥臭いバグを抱え込むことになります。

Redis 分散ロック(Redlock等)の過剰な複雑性(Over-engineering)

「マルチサーバー構成だから」と、安易に Redis による分散ロックを持ち込むのも典型的な過剰設計です。分散ロックには以下のような過酷な課題が付きまといます。

巨大な分散システムを構築する前に、まず問うべきは 「本当に外部インフラに依存しなければ解決できない問題なのか?」 という点です。実は Go 言語のエコシステムには、標準思想に極めて近い形でこの問題を解決するエレガントな武器が存在します。

3. 決定解: golang.org/x/sync/singleflight

Go の準標準パッケージに含まれる singleflight は、「同一のキーに対する複数の重複リクエストを1つに集約し、最初の実行結果を待機中の全ゴルーチンで共有する」 という機能を提供します。外部ミドルウェアを一切使わず、プロセス内のメモリ上だけで完結する極めて美しいプリミティブです。

singleflight の動作メカニズム

singleflight.Group は内部でマップとミューテックス、そして各キーごとの呼び出しを表す構造体(結果を待つチャンネル)を保持しています。

  1. ゴルーチンAが g.Do("item:100", fetchFn) を呼び出す。キーが実行中でなければ、fetchFn を同期実行する。
  2. ゴルーチンAの実行が完了する前に、ゴルーチンB・Cが同一の "item:100" で g.Do を呼び出す。
  3. ゴルーチンB・Cは新しい fetchFn を起動せず、ゴルーチンAの完了を待機するチャンネルをリッスンして待つ。
  4. ゴルーチンAが結果を返すと、その結果とエラーがゴルーチンB・Cにもそのまま分配される。
package cache

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/sync/singleflight"
)

type ItemService struct {
    cache   InMemoryCache // 任意のキャッシュ実装
    db      Database      // データベースクライアント
    sfGroup singleflight.Group
}

func (s *ItemService) GetItem(ctx context.Context, itemID string) (*Item, error) {
    cacheKey := fmt.Sprintf("item:%s", itemID)

    // 1. キャッシュヒット時は即座に返却
    if val, found := s.cache.Get(cacheKey); found {
        return val.(*Item), nil
    }

    // 2. キャッシュミスの際、同一キーのDBアクセスを1本に集約する
    v, err, shared := s.sfGroup.Do(cacheKey, func() (interface{}, error) {
        // ここに到達するのは、同一キーの中で「最初の1人」だけ
        item, err := s.db.FetchItemFromDB(ctx, itemID)
        if err != nil {
            return nil, err
        }

        // DBから取得できた結果をキャッシュに書き込む(TTL: 5分)
        s.cache.Set(cacheKey, item, 5*time.Minute)
        return item, nil
    })

    if err != nil {
        return nil, err
    }

    if shared {
        // メトリクス送信: 同一クエリが集約されてDBアクセスが抑止されたことを観測できる
        metrics.Increment("cache.singleflight.suppressed")
    }

    return v.(*Item), nil
}

このわずか数行の組み込みによって、TTL切れの瞬間に1,000本のゴルーチンが押し寄せたとしても、実際にDBへ向かうクエリは厳密に「1回」だけ に抑え込まれます。残りの999本のゴルーチンは、その最初のクエリの完了を待って同じ結果を受け取ります。DB負荷は瞬時に 1/1000 に激減します。

コンテキストキャンセルの伝播事故を防ぐ DoChan

ただし、実務で singleflight を使う際には1つだけ重大な罠があります。それは 「最初のゴルーチンの Context がキャンセルされたときの挙動」 です。

もし最初のゴルーチン(クライアントA)がタイムアウトやブラウザの切断によってキャンセルされた場合、その ctx を使って実行されている fetchFn まで途中で中断され、結果として後ろで待っていた無実のクライアントBやCまで context.Canceled エラーを食らって失敗してしまいます。

これを防ぐためには、集約処理に渡すコンテキストを親のキャンセルから切り離すか(Go 1.21 で導入された context.WithoutCancel の活用)、あるいは DoChan を利用してクライアント個別のタイムアウト制御を行うのが現場のベストプラクティスです。

// Go 1.21+ context.WithoutCancel を用いた防御的実装
v, err, _ := s.sfGroup.Do(cacheKey, func() (interface{}, error) {
    // 呼び出し元が切断されても、DBからの取得とキャッシュ格納は完遂させる
    detachedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
    defer cancel()

    item, err := s.db.FetchItemFromDB(detachedCtx, itemID)
    if err != nil {
        return nil, err
    }
    s.cache.Set(cacheKey, item, 5*time.Minute)
    return item, nil
})

4. さらなる高みへ: Stale-While-Revalidate(SWR)との融合

singleflight は強力ですが、1つだけ弱点があります。それは「最初の1人」がDBからデータを取得するまでのレイテンシ(例えば 200ms)の間、後ろの待機ゴルーチンも一緒に待たされるという点です。つまり、アクセススパイク時にエラーは出なくなりますが、一時的なレイテンシの悪化(テールレイテンシの跳ね上がり)は残ります。

これを完全にゼロにし、「すべてのユーザーに対して常に 1ms 以内で応答しつつ、DB負荷もゼロに抑える」 ための設計が、HTTPヘッダでおなじみの Stale-While-Revalidate(SWR)パターン のサーバーサイド適用です。

二重TTL設計(Soft TTL と Hard TTL)

考え方は非常にシンプルです。キャッシュデータに2種類の有効期限を持たせます。

クライアントからのリクエストが来たとき、Soft TTL を過ぎていたとしても 「手元にある古いデータを即座にクライアントに返却」 します。そしてバックグラウンドで非同期に singleflight を発火させ、裏側で最新データをフェッチしてキャッシュを上書きします。

type CacheEntry struct {
    Data      *Item
    SoftValid time.Time // この時刻を過ぎたら非同期再検証
    HardValid time.Time // この時刻を過ぎたら破棄
}

func (s *ItemService) GetItemSWR(ctx context.Context, itemID string) (*Item, error) {
    cacheKey := fmt.Sprintf("item:%s", itemID)
    now := time.Now()

    entry, found := s.cache.GetEntry(cacheKey)

    // キャッシュが存在し、Soft TTL 内なら文句なしに最速返却(0.1ms)
    if found && now.Before(entry.SoftValid) {
        return entry.Data, nil
    }

    // キャッシュが存在するが Soft TTL を超過している場合(Stale状態)
    if found && now.Before(entry.HardValid) {
        // バックグラウンドで非同期に最新化を試みる
        go func() {
            // singleflight により、何千回呼ばれても裏で走る非同期フェッチは1つだけ
            s.sfGroup.Do(cacheKey, func() (interface{}, error) {
                bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
                defer cancel()

                freshItem, err := s.db.FetchItemFromDB(bgCtx, itemID)
                if err == nil {
                    s.cache.SetEntry(cacheKey, &CacheEntry{
                        Data:      freshItem,
                        SoftValid: time.Now().Add(1 * time.Minute),
                        HardValid: time.Now().Add(10 * time.Minute),
                    })
                }
                return nil, err
            })
        }()

        // 待たせることなく、手元の古いデータを即座に返す!
        return entry.Data, nil
    }

    // 初回アクセス等、キャッシュが完全に存在しない場合のみ同期フェッチ
    return s.fetchSync(ctx, itemID, cacheKey)
}

このアーキテクチャの凄まじい点は、「ユーザーがキャッシュミスのペナルティ(DBアクセスの待ち時間)を体感することが完全に消滅する」 点にあります。すべてのリクエストはインメモリから即座に返され、DBへの問い合わせは常に裏側で、しかも singleflight によって最小限の頻度で静かに実行されます。

5. まとめ: インフラを増やす前に、アーキテクチャで勝つ

高負荷や障害に直面したとき、多くの開発チームは「サーバーインスタンスのスケールアップ」や「Redisクラスタの増設」といったインフラの力技に頼ろうとします。しかし、アーキテクチャの根底にある Cache Stampede という構造的欠陥を放置したままインフラを拡大しても、コストが跳ね上がるだけで、アクセスの絶対値が増えた瞬間に再び同じ悲劇が繰り返されます。

今回紹介したアプローチの要点を整理します。

外部ライブラリや新しいミドルウェアをホイホイ追加する前に、手元にある言語標準や並行処理プリミティブの力を信じること。シンプルで依存の少ない設計こそが、障害に強く、10年後も安心して運用できるシステムの揺るぎない土台になります。