午前3時、データベースのCPU使用率が100%に張り付いています。トラフィックは正常に見えます。リクエスト数も異常に高いわけではありません。それなのに、プライマリ・ストアがダウンしかけています。なぜなら、それらすべてのリクエストが、全く同じタイミングで、全く同じ内容を要求しているからです。

これが「サンダリング・ハード(thundering herd)」問題です。

コンサート後のスタジアムを想像してみてください。1時間かけてであれば、同じ出口から全員をスムーズに退場させることができます。しかし、もし観客全員がわずか10秒の間にその一つの出口から出ようと決めたら、出口が壊れるわけではありません。単に、その同時実行数(concurrency)を処理しきれないだけなのです。問題はドアではなく、押し寄せ(crush)なのです。

群れが形成される場所

このパターンは、多くのプロセスが単一のイベントに対して同期する際に発生します。悪意のあるトラフィックは必要ありません。通常のシステムが自ら引き起こしてしまうのです。

キャッシュの期限切れ。 よく使われる(hotな)キャッシュキーが失効します。それは製品カタログかもしれませんし、フィーチャーフラグやユーザーの権限リストかもしれません。数千台のアプリケーションサーバーが、同時にその空きスロットに気づきます。各サーバーは善意に基づいて、独自のデータベース接続を開き、値を再構築するために同じ重いクエリを実行します。データベースは、同じ高コストな質問に2,000回並列で答えるようには構成されていません。たった一つの期限切れキーが、クラスター全体をダウンさせてしまうのです。

コネクションプールのウェイクアップ。 一部のアーキテクチャでは、多くのワーカープロセスが単一の条件でブロックし、タスクが届くのを待機しています。その条件が満たされると、オペレーティングシステムはスリープ中のすべてのワーカーを起床させます。実際に接続やタスクを取得できるのは、たった一つのワーカーだけです。残りのワーカーは起床し、レースに負けたことを悟って、再びスリープに戻ります。このサイクルはコンテキストスイッチによってCPUを浪費し、実際の生産的な作業を阻害する可能性があります。

リトライ・ストーム。 下流のサービスが一時的な不具合を起こします。すべてのクライアントがタイムアウトを検知し、例えばちょうど1秒といった固定の間隔を置いてから再試行します。サービスが復旧して再びトラフィックを受け入れられるようになると、すべてのクライアントが全く同じ瞬間にアクセスします。復旧直後でまだ不安定なサービスは、再びダウンします。このサイクルが繰り返されます。

Cronの衝突。 インスタンス群全体で、メンテナンス、レポート生成、またはキャッシュのウォームアップを、正確に 00:00 UTC に実行するようにスケジュールしているなら、時計のような正確さでトラフィックのスパイクを自ら作り出していることになります。負荷は予測可能ですが、その集中度は致命的です。

リクエストの集約(Request Coalescing)

キャッシュ・スタンプディに対する最も効果的な解決策は、同じ質問に二度以上答えるのをやめることです。キャッシュミスが発生したとき、2,500個のスレッドがそれぞれデータベースクエリを実行してほしくはありません。1つのスレッドにクエリを実行させ、残りの2,499個のスレッドにはその結果を待たせたいのです。

このパターンは、しばしば「リクエスト・コアレッシング(request coalescing)」と呼ばれます。Go言語では、golang.org/x/sync パッケージの singleflight が標準的な実装を提供しています。特定のキーに対して最初に Do を呼び出した goroutine が作業を開始します。同じキーを持つ後続の呼び出し元は、その Do 呼び出しでブロックされます。作業が完了すると、待機していたすべての呼び出し元がまとめて結果を受け取ります。あなたは1回のクエリを実行するだけで、2,500件のリクエストを処理できたことになります。

Promise、Future、または Channel を用いたインメモリの並行マップを使用して、同様の動作を構築することもできます。コツは、実行中のリクエスト(in-flight request)をアトミックにチェックして登録することです。リクエストが失敗した場合は全員がエラーを受け取りますが、これは通常正しい挙動です。成功した場合は全員がキャッシュされた値を受け取り、それ以降のリクエストは直接キャッシュにヒットします。データベースの負荷は、垂直なスパイクから平坦なラインへと劇的に減少します。

確率的な早期失効(Probabilistic Early Expiration)

スタンプディを管理するのではなく、完全に回避したい場合もあります。そこで、XFetch のようなアルゴリズムを通じて実装される「確率的な早期失効(probabilistic early expiration)」の登場です。

固定の期限切れ時間(hard expiration time)の代わりに、各キャッシュ値は「ソフトな期限切れウィンドウ(soft expiration window)」を持ちます。リクエストが届くと、アプリケーションは乱数を生成し、単純な確率チェックを行います。サイコロの目が「Yes」であれば、その単一のリクエストが早めにキャッシュを更新します。「No」であれば、そのリクエストは少し古い(staleな)値を返します。

各リクエストが独自のサイコロを振るため、更新処理はソフトなウィンドウ全体に分散されます。あるクライアントは厳密な期限切れの40秒前に更新し、別のクライアントは12秒前に行い、他のほとんどは更新を行いません。作業は、同期された単一のスパイクから、緩やかな