アーキテクチャ
数百万リクエストを処理するシステムの設計
リクエスト量の多さが難所になることはまれです。難しいのは、状態、書き込み経路、そして需要がキャパシティを超えたときにシステムがどう振る舞うかです。
· 5分で読めます · Starone エンジニアリング
数百万のリクエストを処理するうえで重要なのは、単純なスループットよりも限界付近での振る舞いです。ロードバランサーの背後にあるステートレスなサービスは、インスタンスを追加すればスケールします。設計の労力を要するのは、その背後にある状態、最も遅いリクエストのレイテンシ、そして処理しきれない量の仕事が到着したときのシステムの劣化の仕方です。
私たちは、過負荷時の振る舞いは最適化に着手する前に設計すべきだと考えています。上限に達しても超過分の負荷を切り捨てて応答性を保つシステムは、上限は高くてもそこに達した途端に崩壊するシステムより価値があります。
まず計算から始める
1日あたり100万リクエストは、平均すると毎秒およそ12件であり、適切に構成されたサーバー1台で余裕を持って処理できます。設計を左右する数字は1日の合計ではなくピークであり、あわせて読み取りと書き込みの比率、そしてリクエスト1件あたりのコストです。キャッシュされた読み取りを毎秒1,000件受けるシステムは、それぞれが複数のテーブルに触れるトランザクション書き込みを毎秒50件受けるシステムよりも単純な問題です。
これらの数字を、たとえ見積もりであっても書き出しておくと、よくある2つの誤りを防げます。1つは、データベース1台で担える負荷のために分散アーキテクチャを構築してしまうこと。もう1つは、書き込み経路に誰も計測していなかった厳然たる上限があると、後になって気づくことです。
ステートレスなサービスと、意図して置く状態
アプリケーションのインスタンスは、他のインスタンスが必要とする状態を保持すべきではありません。セッションは共有ストアまたは署名付きトークンに置き、アップロードはオブジェクトストレージに送り、スケジュールされた処理は、たまたま起動したノードで実行するのではなく、ロックまたはキューを通じて調整します。そうすれば、どのインスタンスでもどのリクエストでも処理でき、デプロイ中にインスタンスを追加、削除、置換してもユーザーが気づくことはありません。
ステートレス化は問題を移動させるだけで、取り除くわけではありません。状態は今度はデータベース、キャッシュ、キューに置かれ、システムがどこまでスケールするかを決めるのはこれらのコンポーネントです。
キャッシュの階層と、その壊れ方
キャッシュは階層として機能し、各層がその背後の層から負荷を取り除きます。静的アセットとキャッシュ可能なレスポンスにはCDN、頻繁に参照されるマスターデータにはインプロセスキャッシュ、共有される計算結果にはRedisなどの分散キャッシュを使います。レスポンスをユーザーに近い場所で返すほど、消費するインフラは少なくなります。
どの層も、整合性の問題と障害モードを持ち込みます。障害につながるのはスタンピードです。人気のあるキーが期限切れになり、数百のリクエストが同時にキャッシュミスを起こし、そのすべてが同じ高コストのクエリをデータベースに対して実行します。
- 有効期限にジッターを加え、同時に投入されたキーが同時に期限切れにならないようにする
- 欠落したキーの再計算は1つのリクエストに任せ、残りは待機するか古い値を受け取る
- 書き込み時に無効化するか、上限のある鮮度の遅れを許容するかを、データ種別ごとに決める
- データベースがコールドキャッシュに耐えられることを確認するか、制御下でウォームアップする手順を用意する
データベースのスケーリング:まずレプリカ、次にパーティショニング
読み取りの多いワークロードでは、リードレプリカが最初の一手です。ただしレプリカは非同期であるため、書き込んだ直後にレプリカから読み取ったユーザーには、自分の変更が見えないことがあります。一般的な対処は、書き込み直後の読み取りを短時間だけプライマリへルーティングするか、セッションが必要とするレプリケーション位置を追跡することです。
レプリカは書き込み量に対しては何の効果もありません。単一のプライマリが書き込みを吸収しきれなくなったとき、あるいはデータを保持しきれなくなったときの選択肢は、1つのデータベース内でのパーティショニングか、複数のデータベースにまたがるシャーディングです。パーティションキーは、後から覆すのが難しい決定です。良いキーは負荷を均等に分散させ、重要なクエリを1つのパーティション内に収めます。悪いキーは、1社の大口顧客や1つの混雑した時間帯にトラフィックの大半が集中するホットパーティションと、ファンアウトしてマージしなければならないパーティション横断クエリを生み出します。
シャーディングは、順序としては後のほうに置くべきです。インデックスの追加、クエリチューニング、コネクションプーリング、コールドデータのアーカイブ、インスタンスの大型化は、いずれもより安価で、元に戻せます。私たちがシャーディングに移行するのは垂直方向の余力が尽きかけているときであり、早い段階での意思表明として行うことはありません。
バックプレッシャー、キュー、ロードシェディング
どのシステムにも、処理できる仕事量の最大レートがあります。設計上の問いは、それを超えたリクエストがどうなるかです。制限がなければ、リクエストはスレッドプール、コネクションプール、無制限のキューに滞留し、全員のレイテンシが上昇し、クライアントはタイムアウトしてリトライし、そのリトライがすでに障害を起こしているシステムにさらに負荷をかけます。このフィードバックループこそ、一時的なスパイクが長時間の障害へと変わる仕組みです。
バックプレッシャーは、制限を明示的にして上流へ伝播させることで、このループを断ち切ります。キューには上限を設けます。外向きの呼び出しにはすべて、呼び出し元自身のデッドラインより短いタイムアウトを設定します。リトライにはジッター付きの指数バックオフとリトライバジェットを適用し、トラフィックが何倍にも膨らまないようにします。サーキットブレーカーは、明らかに障害を起こしている依存先への呼び出しを止めます。
キューはバーストを平準化するものであり、キャパシティを増やすものではありません。到着が処理レートを上回る状態が長く続けば、キューは障害を先送りするだけで、復旧したときには古くなった仕事を処理することになります。上限に達したら、システムは429または503とRetry-Afterヘッダーを返して早期に、かつ低コストで拒否すべきであり、切り捨ては優先度に基づいて行うべきです。決済の確認は残し、レコメンドのパネルは落とします。
大規模ではテールレイテンシが支配する
リクエストがファンアウトするようになると、平均値は判断を誤らせます。1つのページが100個のバックエンドを並列に呼び出し、それぞれが1パーセントの確率で遅くなるとすると、ページ読み込みのおよそ63パーセントに少なくとも1つの遅い呼び出しが含まれます。ユーザーが体験するのは最も遅い依存先であるため、各サービスのp99が全体の典型的な体験になります。
- レイテンシ目標は依存先ごとにp99で設定し、エンドツーエンドの目標に対して予算として配分する
- バッチ化と、最初のレスポンスに不要な呼び出しの削除によってファンアウトを減らす
- 冪等な読み取りにはヘッジリクエストを使い、短い遅延の後に2回目の試行を送る
- 必須ではない依存先がデッドラインを超えた場合は、部分的な結果を返す
ユーザーより先に限界を見つける
キャパシティは計測して得る数値です。私たちは、現実的な操作の組み合わせとデータ量で負荷テストを行い、応答時間にかかわらず一定のレートでリクエストが到着するオープンワークロードモデルを使います。各仮想ユーザーがレスポンスを待ってから次のリクエストを送るクローズドモデルは、システムが苦しくなるにつれてテスト自体が減速し、明らかにすべきレイテンシそのものを隠してしまいます。
テストは、曲線の屈曲点を過ぎて何かが壊れるまで続けるべきです。目的は、どのコンポーネントが最初に故障するのか、そして負荷が下がったときにシステムが自力で回復するのかを知ることだからです。過負荷の後に手動での再起動を必要とするシステムには、ローンチ前に修正する価値のある欠陥があります。
