導入
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))
}
実装のポイント
-
group.Do のキー
同じ処理として扱いたい単位でキーを決めます。上記の例では
"ranking"という固定文字列で、全リクエストを同一の処理として束ねています。 -
処理の順序
「キャッシュ確認 → singleflight → DB処理」の順にしています。これにより、キャッシュがある場合は singleflight を通さず即座にレスポンスできます。
-
戻り値
group.Doは3つの値を返します-
result: 処理結果 -
err: エラー -
shared: 他の処理の結果を共有した場合はtrue
-
参考
実際にsingleflightの動作確認用サンプルコードはGitHubで公開しています
まとめ
パフォーマンスチューニングにおいて「キャッシュ」は強力な武器ですが、「キャッシュがない時」の挙動を設計できてこそ、真に堅牢なアプリケーションと言えます。
高トラフィックな環境でキャッシュを利用する場合は、ぜひ singleflight を導入して、Cache Stampede からデータベースを守る設計を検討してみてください。