MySQLコネクションプーリングキャッシュの失敗:本番を壊した0.001%のバグの内部
要点まとめ
MySQLコネクションプーリングキャッシュが原因で本番環境に0.001%の空レスポンスを引き起こす稀なバグの仕組みを解説。プロトコル変換の落とし穴、キャッシュのカノニカル化、リセット後のセッション再適用など、ゲームバックエンドで今すぐ実践すべき具体的な対策と予防策を詳しく紹介します。
バックエンドが、常にデータを返すクエリに対して空の結果セットを返す。エラー率は0.001%——10万リクエストに1件程度。テレメトリには何も出ない。トレースにも何も出ない。そして唯一の手がかりは、キャッシュを無効にすると発生しないことだけ。
CloudflareのHyperdriveチームが、コネクションプーラーへのMySQLサポートを一般提供(GA)に移行する際に直面したのが、まさにこの状況だ。彼らが見つけたバグは、MySQLコネクションプーリングキャッシュが、ワイヤー上を流れるバイト列のフォーマットを2つのレイヤーが別々に解釈したときに、静かに壊れる仕組みを如実に示す好例である。ゲームバックエンドでプール&キャッシュされたデータベースレイヤーを運用しているなら——そして運用すべきだが——この障害モードはあなたにも当てはまる。
この記事では、このバグの内訳、それを可能にしたプロトコルの詳細、検出と修正の方法、そしてあなたのゲームバックエンドアーキテクチャへの影響を解説する。
なぜコネクションプーリングが存在するのか
データベースへのTCP + TLS + MySQLハンドシェイクの確立には、最低でも5回のネットワークラウンドトリップが必要だ。ゲームサーバーがリクエストごとに新しい接続を開く場合、リーダーボードの読み取り、セーブデータの取得、インベントリの参照のたびに、そのコストを支払うことになる。
プーリングは、温かい接続を開いたまま保持し、貸し出すことでこれを解決する。MySQLはPostgresよりもこれに適している——Postgresは接続ごとにOSプロセスを1つ生成するのに対し、MySQLの接続は比較的安価だ——しかし、数千人の同時プレイヤーがいる人気ゲームでは、すぐに接続上限を使い果たす可能性がある。
ただし、プーリングには独自の問題がある:セッション状態のリークだ。プレイヤーAのクエリがセッション変数を設定し、プレイヤーBが同じ接続を受け取った場合、正確性のバグになる。プーラーはチェックアウト間の接続クリーンアップでこれを解決する。MySQLはまさにこのための軽量コマンドを提供している:COM_RESET_CONNECTION。
そして、この物語の最初の微妙なバグはここに潜んでいる。
バグ#1:接続リセット後のエンコーディングドリフト
Hyperdriveチームはスタートアップハンドシェイクでutf8をネゴシエートしていた。それは機能していた——プレイヤーがチャットやキャラクター名で絵文字を使い始め、データが壊れて返ってくるまでは。
問題は、MySQLのutf8はutf8mb3の非推奨エイリアスであり、Unicodeコードポイントあたり1〜3バイトしかサポートしないことだ。完全なUTF-8(絵文字、CJK拡張、そしてグローバルなプレイヤーベースが使うほとんどの非ラテン文字を含む)は、コードポイントあたり最大4バイトを必要とする。修正は、代わりにutf8mb4をネゴシエートすることだ。
しかし、ここにほぼ全員が陥る罠がある:
-- Startup handshake: negotiate utf8mb4. All good.
-- Then, between checkouts, the pooler runs:
COM_RESET_CONNECTION;
-- Surprise: the server does NOT reset to the negotiated charset.
-- It resets to the SERVER DEFAULT, e.g. latin1.
COM_RESET_CONNECTIONは、起動時にネゴシエートした文字セットに接続を戻さない。サーバーのデフォルト文字セット——多くのMySQLインスタンスでは今もlatin1——にリセットされる。最初のリセット以降、プールされたすべての接続は静かに1990年代のエンコーディングに劣化する。
修正方法: 起動時だけでなく、リセットのたびにセッション設定を再適用する:
COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';
経験則:リセットのたびに接続は完全に未設定とみなし、重要な設定はすべて再適用すること。接続時に一度設定しただけのものは、いずれ漏れ出てしまう。
バグ#2:0.001%の空レスポンスバグ
これが、発見に数日を要したバグだ。大量トラフィックの顧客から、非空のレスポンスが保証されているクエリに対して、小さいが一貫した割合で空のレスポンスが返るという報告があった。エラーは約0.001%の確率で発生——エンドツーエンドのトレースを確実に取得するには稀すぎ、無視するには頻度が高すぎる。
チームはテレメトリを調べ尽くし、トレース率を上げ、ネットワーク異常を探した。何もない。それを解き明かした唯一の手がかりは、キャッシュが有効なインスタンスでのみ発生することだった。
それを可能にしたアーキテクチャ
Hyperdriveは分割トポロジーを採用している:
- クライアントノード — Workerの近くで動作し、ローカルキャッシュを処理し、クエリを転送する。
- エンドポイントノード — オリジンデータベースに最も近いデータセンターで動作し、実際のコネクションプールを保持する。
両方のノードは、それぞれのデータセンターにローカルなCDNキャッシュを使用する。同じデータセンターで実行される場合、1つのキャッシュを共有する。その共有はパフォーマンス上の利点だが、隠れた前提条件がある:同じクエリレスポンスに対して、両方のノードがまったく同じバイト列をキャッシュしなければならない。
プロトコルの不一致
MySQLのクライアント/サーバープロトコルには、クライアントとサーバーがケイパビリティをネゴシエートする接続フェーズがある。CLIENT_DEPRECATE_EOFのようなオプション機能で、結果セットの終了方法が変わる:
CLIENT_DEPRECATE_EOFなし:列定義の後にEOFパケット、結果セットの最後に別のEOF。- あり:メタデータのEOFは省略され、最後のEOFはOKパケットになる。
Hyperdriveはこのケイパビリティをサポートし、オリジンのMySQLサーバーとネゴシエートする。しかし、もう一方のクライアントライブラリ——たとえば、現在CLIENT_DEPRECATE_EOFをサポートしていないNode.js用のmysql2——はサポートしていないかもしれない。そのためHyperdriveは変換を行う必要がある:結果セットを書き換えてEOFパケットを挿入し、OKパケットをEOFに戻す。Workerのドライバーが期待するバイト列に一致させるためだ。
結果として、Hyperdriveがクライアントに書き戻すバイト列は、オリジンサーバーから受け取るバイト列とは異なるフォーマットになる。
誤った前提
Postgres用にクライアントキャッシュを実装したとき、チームはある前提を組み込んだ:クライアントに書き戻したバイト列をそのままキャッシュできる。Postgresのワイヤープロトコルではそれは真実だった。MySQLでは違う。
では、障害を組み立ててみよう:
- エンドポイントがクエリQを処理し、レスポンスのバイト列をHyperdriveケイパビリティフォーマットで共有キャッシュにキャッシュする。
- クライアント(同じデータセンター)がQのリクエストを受け取り、キャッシュエントリを見つけ、ケイパビリティを変換せずにWorkerに配信する——バイト列がすでにWorker向けに書き込まれたように見えるからだ。
CLIENT_DEPRECATE_EOFをネゴシエートしたことのないWorkerのドライバーが、OKで終了する結果セットを誤って解析し、空の結果、ハング、プロトコルデシンクを引き起こす。
このバグは、クライアントとエンドポイントが同じ場所にあり、かつ一方が他方のキャッシュエントリを読み取った場合にのみ発生する。だから0.001%で、通常のトレースでは見えないのだ。これはワイヤープロトコル版のデシンクバグ——共有状態について異なる前提を持つ2つのコンポーネント——である。クライアント予測とサーバー権威の間の状態乖離をデバッグしたことがあるなら、この障害の形は馴染みがあるだろう。それはUnreal EngineのRPCレプリケーション問題を修正してステートを壊さないようにするで説明されているのと同じクラスの問題で、データベースのワイヤーレイヤーで起きているにすぎない。
概念的な修正:カノニカルフォーマット(オリジンが送信したもの)でキャッシュし、読み出し時にリクエスト元クライアントのケイパビリティに変換する。変換がクライアントごとのネゴシエーションに依存する場合、変換後のバイト列をキャッシュしてはならない。
ランブック:自前スタックでキャッシュ破損を検出する
MySQL(またはステートフルなワイヤープロトコルを持つもの)の前にプーラーやキャッシュレイヤーを置いているなら、このポストモーテムをどう適用するかを示す。
検出
- エラーをトポロジーと相関させる。 このバグは、クライアントとエンドポイントがデータセンターとキャッシュを共有しているインスタンスでのみ発生した。各レスポンスをどのノードが処理したか、キャッシュから来たかどうかをログに記録しよう。エラー率がキャッシュヒットと相関するなら、書き手と読み手のフォーマット不一致を疑う。
- スパイクではなく、稀だが一貫したエラーを追え。 0.001%のエラー率はアラートを発動させない。エラートラッキングに、量に関係なく同じシグネチャの繰り返し発生を検出するクエリを設定しよう。低レートでの一貫性は、トラフィックに相関するスパイクよりも、ロジックバグの強いシグナルだ。
- 意図的にケイパビリティの非対称性でテストする。
CLIENT_DEPRECATE_EOFありのテストクライアントと、なしのテストクライアントを接続し、キャッシュを有効にして、同一のクエリを大量に送る。ほとんどのプロトコル変換バグは、この非対称性の下で数分以内に表面化する。
修正
- キャッシュエントリをカノニカル化する。 オリジンの生のレスポンスフォーマットを保存し、読み出し時に変換する。これは、クライアントが異種のケイパビリティを持つプーラーに対する正しい修正だ。
- リセット後にセッション状態を再適用する。 上記のとおり、
COM_RESET_CONNECTIONはネゴシエートした設定ではなく、サーバーのデフォルトを復元する。 - キャッシュキーにバージョンを付ける。 プロトコルの動作(ケイパビリティ、エンコーディング、レスポンスフレーミング)を変更する場合は、キャッシュキーにフォーマットバージョンを含めて、デプロイ中に古いエントリが新しいコードから配信されないようにする。
# Cache key hygiene, in pseudocode
def cache_key(query_normalized: str, schema_version: int, format_version: int) -> str:
# format_version bumps whenever wire-format handling changes,
# preventing stale-format entries from poisoning new deploys
return sha256(f"{schema_version}:{format_version}:{query_normalized}")
予防
- 2つのクライアントライブラリに対するワイヤープロトコル適合テストを書く。
CLIENT_DEPRECATE_EOFのギャップは、人気のNode.jsドライバーがサポートしていなかったからこそ見えたのだ。 - レイヤー間で「同じバイト列」を前提にしない。 メッセージを書き換えるコンポーネントは、出力が入力とバイト単位で同一であると想定してはならない。
- 初日からキャッシュを有効にしてロードテストする。 Hyperdriveチームは、キャッシュ固有のバグが表面化するまで数か月間信頼性を検証していた——稀なバグは機能の交差点(プーリング × キャッシュ × ケイパビリティ変換)に潜むからだ。
ゲームバックエンドにおけるプーリングとキャッシュの5つのベストプラクティス
- 常に
utf8mb4をネゴシエートし、決してutf8にしない。 プレイヤー名、チャット、セーブデータには4バイト文字が含まれる。そして、COM_RESET_CONNECTIONのたびにSET NAMES utf8mb4を再適用する。リセットはハンドシェイクではなくサーバーのデフォルトを復元するからだ。 - クライアント向けにフォーマットされたデータではなく、カノニカルデータをキャッシュする。 レスポンスを送信前に変換するレイヤーがある場合、変換後のバイト列を共有キャッシュに入れてはならない。オリジンのフォーマットを保存し、読み手ごとに変換する。
- キャッシュキーにフォーマットバージョンを追加する。 1行の変更で、ローリングデプロイ中の古いプロトコルエントリから救ってくれる。
- エラー率だけでなく、エラーの一貫性でアラートを出す。 トラフィック相関のない0.001%の再発シグネチャはロジックバグだ。トラフィックに相関するスパイクはキャパシティ問題だ。それぞれ別のルートに振り分けよう。
- ケイパビリティが不一致のクライアントをテストする。 プーラーの正確性は、チーム(またはSDKユーザー)が接続する可能性のあるすべてのクライアントライブラリを処理できるかどうかにかかっている。
バックエンドアーキテクチャの教訓
ここで最も深い教訓は、MySQLについてではない。ピアごとのネゴシエーションを持つレイヤードシステムについてだ。両側でプロトコルを話すミドルボックス——プーラー、プロキシ、キャッシュ——を挿入した瞬間、忠実に変換する義務と、普遍的に有効なフォーマットだけをキャッシュする義務を負う。Postgresは機能し、MySQLは壊れた。キャッシュのロジックで間違っていたのは、別のプロトコルから受け継いだ1つの前提だけだった。
ゲームスタジオにとって、これは独自のプーラー+キャッシュレイヤーを手作りすることへの強い反対論になる。ハッピーパスを動かすためのエンジニアリング工数はおそらく1週間。稀なパスを正しくするための工数——ケイパビリティネゴシエーション、エンコーディングのドリフト、キャッシュのコロケーション——は、専任のインフラチームがたった1つの0.001%バグを追い詰めるのに数日を要したものだ。Cloudflare自身の告白は繰り返す価値がある:ベータとGAの違いは「トレンチコートを着た20個のバグ修正」だった。
ゲームバックエンドに、プーラー、キャッシュ無効化スキーム、プロトコル変換レイヤーを自分で維持せずに信頼性の高い永続化が必要なら、そこでBackend-as-a-Serviceが価値を発揮する。horizOnは、リビジョン認識の読み書きを備えたアカウントバインドのCloud Saveを処理する——クライアントが競合を解決し、プレイヤーデータに関するプール接続+キャッシュの正確性の地雷原を丸ごと回避できる。リーダーボードやリモート設定と組み合わせれば、この記事で説明したクエリレイテンシの問題は、ほとんどあなたの問題ではなくなる。
次のステップ: 今日、自分のプーラー設定を監査しよう。3つ確認する——ネゴシエートされた文字セット、接続リセット後にセッション状態が再適用されるか、キャッシュがカノニカルバイトと変換済みバイトのどちらを保存するか。10分で3つすべてに答えられなければ、やるべき仕事がある——そして、それをまったくやりたくないなら、horizOnを見て、そのCloud Saveとリーダーボードが、現在データベースレイヤーで手作業で行っていることをカバーできるか確認してほしい。