sasaryo.dev  |  blog   about

高負荷環境でRDBをホットパスから外すという設計

#design#aws

はじめに

高負荷なシステムでは、RDBがボトルネックになることがあります。 ここでいう負荷とは、単位時間あたりに処理しなければならないリクエストの量のことです。 このとき「RDBを速くする」方向で戦うこともできます。インデックスを張る、クエリを直す、インスタンスを縦に積む。どれも正しい。 ただ、負荷がある水準を超えると、それとは別の発想が必要になります。 RDBをホットパスから外す、つまりリクエストの同期処理経路からRDBアクセスそのものを追い出すという発想です。

この記事では、この設計を3つの手法に整理します。具体例は、高負荷システムの代表格であるゲームサーバー。扱うのは設計パターンの整理で、個別ミドルウェアのチューニングには立ち入りません。

なぜ高負荷環境ではRDBがボトルネックになりやすいのか

アプリ層より先にRDBが詰まるのには、構造的な理由があります。

コネクション数に上限がある。 アプリサーバーはリクエスト量に応じてスケールアウトできますが、増えたアプリサーバーはそれぞれRDBへのコネクションを張ります。RDB側が受けられるコネクション数は有限です。たとえばPostgreSQLのデフォルト上限は100本、MySQLは151本。設定で増やすことはできますが、コネクション1本ごとにメモリと管理コストを消費するため(PostgreSQLはコネクションごとにプロセスを作る)、数千本のオーダーになるとコネクションを維持すること自体がRDBを圧迫し始めます。アプリサーバー1台がプールで20本張るとすれば、50台にスケールアウトした時点で1,000本。アプリのスケールアウトが、むしろRDBを締め上げる方向に働くわけです。

書き込みがスケールしにくい。 RDBを複数台で運用する場合、書き込みを受け付ける本体(プライマリ)と、その内容をコピーし続ける読み取り専用の複製(リードレプリカ)という構成をとるのが一般的です。読み取り(SELECT)はレプリカを増やせば分散できます。ただしレプリカへの反映は一瞬遅れるため、書いた直後に読み返す処理はプライマリに向ける必要があり、読み取りのすべてを逃がせるわけではない。そして書き込み(INSERT/UPDATE)は、複数台が同時に受けるとデータの整合性が取れなくなるため、プライマリ1台が一手に引き受けます。つまり読み取りは横に伸ばせても、書き込みの上限はプライマリ1台の性能で決まってしまうのです。

書き込みにはディスクI/Oが伴う。 ではそのプライマリ1台の性能は何で決まるのか。大きな要因がディスクI/Oです。RDBは障害が起きてもコミット済みのデータを失わないように、コミットのたびに更新記録(PostgreSQLならWAL、MySQLならredoログ)をディスクへ同期的に書き出します。つまりRDBへの書き込みは、どれだけメモリを積んでもディスクとの同期から逃れられない。SSDの進歩や、複数トランザクションの書き出しをまとめる仕組み(グループコミット)で緩和されてはいますが、ディスクを待つ時間がゼロになるわけではありません。とくにRDSのようなクラウド環境ではIOPS(秒間のディスクI/O回数)が課金枠として明示的に上限化されており、CPUには余裕があるのにIOPS枠を使い切って詰まる、というのは典型的なパターンです。後で見るオフロード先にRedisのようなインメモリストアが選ばれる理由の根っこもここにあります。相手はメモリ上で処理を完結させるので、そもそもディスクの同期を待つ必要がないのです。

競合が連鎖する。 同時に処理するリクエストが増えると、同じ行への更新がロック待ちを生み、ロック待ちがコネクションを占有し、占有されたコネクションがさらに待ちを生みます。リクエストが多いときほど1リクエストあたりの処理コストが上がるという、嫌な性質があります。

ゲームサーバーだとこれが極端な形で現れます。イベント開始の瞬間やメンテナンス明けには、プレイヤーが一斉にログインしてくる。平常時の数十倍のリクエストが数分間に集中し、全員が自分のプレイヤーデータを読みに来ます。しかも増えるのは読み取りだけではない。ログインという行為自体が、最終ログイン時刻の更新、ログインボーナスの付与、スタミナ回復の反映といった書き込みを伴います。つまりログインスパイクは読み取りと書き込みが同時に殺到するイベントで、読み取りはレプリカで逃がせても、書き込みはプライマリ1台に集中する。アプリサーバーはスケールアウトで受けられても、そこが天井になります。

まとめるとこうです。アプリ層は横に伸びるが、RDBは伸びにくい。したがってRDBの処理能力を超える負荷が来たとき、アプリケーションサーバーだけを増やしても、システム全体のスループットは伸びません

ホットパスとは何か

ここで言うホットパスとは、ユーザーのリクエストに対して同期的に実行される処理の経路のことです。リクエストを受けてからレスポンスを返すまでの間に通る道、と言ってもいいかもしれません。

ホットパスの特徴は、そこにあるコストが全リクエストに掛け算で効くことです。ホットパス上にRDBアクセスが1回あれば、それは「全リクエスト数 × そのクエリ」の負荷としてRDBに降ってきます。

ゲームサーバーで言えば、クエスト開始、バトルの結果送信、ガチャを引く、といったプレイヤーの行動1回ごとに走るAPIがホットパスです。仮にクエスト開始のAPIがプレイヤーデータの読み取り3回と更新2回を含んでいたら、秒間1万リクエストの瞬間にはRDBへ秒間5万クエリが飛ぶ計算になります。

とはいえ、これは「RDBを使うな」という話ではありません。RDBのトランザクションと整合性保証は強力で、捨てる理由はどこにもない。論点はあくまで、RDBアクセスを同期経路の外に追い出せないかです。ここから3つの手法を見ていきます。

手法1: 読み取りをキャッシュに逃がす

最初の手は、読み取りをRedisなどのキャッシュに逃がすことです。いわゆるcache-asideパターンで、まずキャッシュを見て、なければRDBから読んでキャッシュに載せる。ヒットしている間、RDBはホットパスから外れます。

これが効くのは、read-heavyで、同じデータが繰り返し読まれる場合です。

ゲームサーバーには、この条件にぴったり合うデータがあります。アイテムの定義、クエストの構成、敵のパラメータといったマスタデータです。全プレイヤーが同じものを読み、更新はリリースやイベント切り替えのタイミングだけ。こういうデータをリクエストのたびにRDBから読むのは丸損で、キャッシュに載せれば読み取り負荷はほぼ消えます。ランキングも同じ構図で扱えます。スコアの元データはRDBに記録し、順位表はRedisのソート済みセット(Sorted Set)として持てば、RDBの ORDER BY で毎回順位を組み立てるより桁違いに軽い。順位表はあくまで派生データなので、飛んでも元データから再集計し直せるという点もキャッシュと同じです。

注意点は2つあります。1つはキャッシュミス時の殺到(thundering herd)。キャッシュの有効期限が切れた瞬間に大量のリクエストが同時にRDBへ流れ込み、平常時より酷いスパイクを作ることがあります。イベント切り替えでマスタデータのキャッシュが一斉に切れる瞬間は、まさにこれが起きやすい。もう1つはTTLと整合性のトレードオフで、キャッシュが効いている間は古いデータが見えます。どのデータなら何秒の遅れを許せるか、データごとに決めておく必要があると思います。

手法2: 書き込みを非同期に逃がす

読み取りはキャッシュで逃がせるが、書き込みはそうはいかない。そこで2つ目の手は、書き込みをキューに積んで、ワーカーが後からRDBに書くという形にすることです。SQSのようなマネージドキューを挟めば、ホットパスがやることは「キューに積む」だけになります。

ポイントは保証のレベルを変えることにあります。ホットパスでは「受け付けた」ことだけを保証し、RDBへの永続化は非同期に行う。キューへの書き込みはRDBへの書き込みよりはるかに軽く、スケールもします。

効くのは、即時の読み返しが不要な書き込みです。代表格が行動ログや計測データ。プレイヤーの行動履歴は分析や問い合わせ対応には必須ですが、その場でプレイヤーに見せるものではないので、同期で書く理由がありません。

これには公開されている実例があります。グランブルーファンタジーでは、かつて各種ログをMySQLへ同期的にINSERTしており、ログの書き込みが終わるまでトランザクションが完了せず、リクエストの増加とともにレスポンス遅延の原因になっていました。そこでCygamesはログ書き込みを非同期化します。アプリはローカルのファイルにログを書き出すだけにして、自社開発の転送エージェントがS3とSQSを経由して運び、バッチがAurora(MySQL互換のRDB)へまとめて書き込む(デブサミ2017の講演レポート)。RDBにログを書くこと自体はやめず、その書き込みをホットパスの外に追い出した、教科書のような事例だと思います。

ログが楽な例なのは、誰もその場で読み返さないからです。同じ手をプレイヤーに見えるデータ——ミッションの進捗や報酬の付与——に広げようとすると、技術の問題である前にプロダクト仕様の問題になります。非同期化した瞬間、書いた結果が見えるまでの遅れが生まれる。「ミッションの達成反映には少し時間がかかることがあります」を仕様として許容できるかは、エンジニアだけでは決められません。先に仕様として合意してから手を出すべきで、順序が逆になると後で揉めることになりそうです。

手法3: ホットなデータをKVSに持たせる

3つ目は、アクセスが集中するデータの置き場所そのものをRDBからKVS(RedisやDynamoDB)に移すことです。キャッシュ(手法1)と似て見えますが、別物。キャッシュは「RDBが本体で、その写しを置く」のに対して、こちらはそのデータの本体をKVSに置くという考え方です。

ゲームサーバーでの典型例を挙げます。

このときRDBがお役御免になるわけではありません。RDBは**真実の記録(システムオブレコード)**として残ります。プレイヤーの資産や課金履歴のような、絶対に失えず、整合性が問われるデータはRDBが持ち続ける。ホットパスを毎秒叩くデータがKVSへ、確実さが命のデータがRDBへ、という役割分担です。

注意点は、KVSに移した瞬間にクエリの柔軟性を失うことです。RDBなら後から「この条件で集計したい」が WHERE 句1つで済みますが、KVSはキーで引くことしかできない。だからこの手法は、アクセスパターンが固まっているデータにだけ適用します。「どう読むかまだ分からないデータ」をKVSに置くと、あとで困ることになりそうです。

トレードオフ: 失うものは整合性と単純さ

3つの手法を並べましたが、どれもタダではありません。共通して払う代償を整理しておきます。

失うもの具体的に何が起きるか
強い整合性キャッシュは古い値を見せ、非同期書き込みは反映が遅れる
構成の単純さRedis・キュー・ワーカーとコンポーネントが増え、それぞれが新しい障害点になる
障害対応の単純さ「RDBを見れば全部わかる」が成り立たなくなる

特に障害時の運用コストは見落とされやすいと思います。同期書き込みなら、エラーが返ったらその処理は失敗、で済む。非同期化すると「キューには積まれたがワーカーが落ちて未処理」のような中間状態が生まれ、障害のあとにキューのどこまでが処理済みかを調べてRDBと突き合わせる仕事が発生します。それがログなら欠損の調査で済みますが、報酬のようなプレイヤーに見えるデータなら、問い合わせ対応と補填までついてくる。設計の複雑さは、そのまま運用の複雑さになるわけです。

だからこれらは最初からやる設計ではない。まずはスロークエリの改善、インデックス、リードレプリカといった素直な手を打つべきで、それでもRDBの天井が見えてきたときに初めて検討する選択肢です。逆に、ゲームサーバーのようにスパイクが仕様として確定している(イベント開始に全員が来ることが分かっている)システムなら、最初からホットパス設計に織り込む判断もあり得ます。要は、負荷の見通しに対して払う複雑さが釣り合っているかどうか、だと思います。

おわりに

RDBの性能問題は、「RDBを速くする」だけでなく「RDBに同期的に頼る箇所を減らす」ことで解ける場面があります。整理すると次の3つでした。

  1. 繰り返し読まれるデータは、キャッシュに逃がす
  2. 即時性のいらない書き込みは、キューに積んで非同期に書く
  3. アクセスが集中するデータは、本体ごとKVSに移し、RDBは真実の記録に徹する

共通する考え方は、ホットパス上のRDBアクセスは全リクエストに掛け算で効く、ということです。私もまずは自分の関わるシステムの一番太いAPIを1本選んで、レスポンスを返すまでにRDBアクセスが何回あるかを数えるところから始めてみたいと思います。そのうち何回が「本当に同期でなければならないか」を考えれば、この設計の入り口に立てるはずです。