7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【パフォーマンスチューニング】 キャッシュが切れた時の負荷にどう対処する?GoのsingleflightでCache Stampedeの対策

7
Last updated at Posted at 2025-12-24

導入

Webサービスのパフォーマンスチューニングにおいて、「重い処理の結果をキャッシュストア(RedisやMemcachedなど)に保存する」というのは定石中の定石です。

しかし、単純にキャッシュを導入しただけでは、キャッシュの有効期限(TTL)が切れた瞬間にDB負荷がスパイクし、最悪の場合サービスダウンを招くという重大なリスクを抱えることになります。

本記事では、この現象である 「Cache Stampede(キャッシュ・スタンピード)」 のメカニズムと、golang.org/x/sync/singleflightとして提供されているGo言語のパッケージ singleflight を用いた解決策について解説します。

キャッシュを入れたのにDBがダウンする理由

Cache Stampede(キャッシュ・スタンピード)とは

例えば、集計に 3秒 かかる「ランキング生成処理」をキャッシュする場合を考えます。

通常時はキャッシュが即座にレスポンスを返すため、DB負荷はゼロです。しかし、キャッシュの有効期限が切れた(Expire)瞬間、あるいはキャッシュがクリアされた瞬間に何が起きるでしょうか?

まず、最初の1リクエストがキャッシュミスし、DBで集計を開始します。この集計が完了してキャッシュにデータが再セットされるまでには 3秒 かかります。

問題は、この「再計算中の3秒間」です。

秒間1,000リクエストのアクセスがある場合、最初の人が計算を終えるのを待たずに、後続の 約3,000リクエスト(1,000req x 3秒) が次々とやってきます。この時点ではまだキャッシュが空であるため、これら全員が「キャッシュミス」と判定され、DBへの直接クエリを実行してしまいます。

これが Cache Stampede です。

DBのCPU使用率は一瞬で100%に張り付き、コネクションプールは枯渇。アプリケーションはタイムアウトエラーを返し、復旧には再起動が必要になることもあります。

解決策:singleflight パターン

この問題を解決するための強力な武器が、Goの golang.org/x/sync/singleflight です。

仕組み:実行中の処理に「相乗り」する

singleflight の考え方はシンプルです。

「あるキーに対する処理が実行中である場合、その間に到着した他のリクエストはすべて待機させ、最初の処理結果を全員で共有する」 というものです。

先ほどの例で言えば、最初の1人が集計を行っている3秒間に到着した1,000リクエストは、新たにDBへ行くのではなく、計算中の「最初の1人」の完了を待ちます。

これにより、DBに飛ぶクエリは、処理中に何千リクエスト来ようとも 「最初の1回だけ」 に抑え込まれます。

Go言語での実装

1. インストール

標準パッケージではなく準標準パッケージに含まれるため、go get が必要です。

go get golang.org/x/sync/singleflight

2. 基本実装パターン

import (
    "encoding/json"
    "net/http"
    "time"
    
    "golang.org/x/sync/singleflight"
)

// グローバル変数としてsingleflightのGroupを保持
var group singleflight.Group

func rankingHandler(w http.ResponseWriter, r *http.Request) {
    // 1. キャッシュから取得
    if data, found := getCache("ranking"); found {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(data))
        return
    }

    // 2. singleflight で重複処理を防ぐ
    result, err, shared := group.Do("ranking", func() (interface{}, error) {
        // --- ここは同時実行されず、最初の1人だけが実行 ---
        
        // 重いDB処理
        ranking, err := getHeavyRanking()
        if err != nil {
            return nil, err
        }
        
        // JSON化
        data, _ := json.Marshal(ranking)
        
        // キャッシュに保存(TTL 10秒)
        setCache("ranking", string(data), 10*time.Second)
        
        return data, nil
    })

    if err != nil {
        http.Error(w, "Internal Server Error", 500)
        return
    }

    // 結果を返す
    w.Header().Set("Content-Type", "application/json")
    w.Write(result.([]byte))
}

実装のポイント

  1. group.Do のキー

    同じ処理として扱いたい単位でキーを決めます。上記の例では "ranking" という固定文字列で、全リクエストを同一の処理として束ねています。

  2. 処理の順序

    「キャッシュ確認 → singleflight → DB処理」の順にしています。これにより、キャッシュがある場合は singleflight を通さず即座にレスポンスできます。

  3. 戻り値

    group.Do は3つの値を返します

    • result: 処理結果
    • err: エラー
    • shared: 他の処理の結果を共有した場合は true

参考

実際にsingleflightの動作確認用サンプルコードはGitHubで公開しています

まとめ

パフォーマンスチューニングにおいて「キャッシュ」は強力な武器ですが、「キャッシュがない時」の挙動を設計できてこそ、真に堅牢なアプリケーションと言えます。

高トラフィックな環境でキャッシュを利用する場合は、ぜひ singleflight を導入して、Cache Stampede からデータベースを守る設計を検討してみてください。

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?