TT Lab
はじめる
学ぶ 学習パス コース

負荷テスト

拒否は失敗ではなく保護だ

TT Labで続きを見る

一言でいうと

設計の問題は「キューを置くかどうか」ではなく、どのキューが先に限界に達し、そのとき何をするかです。

なぜ必要なのか

リクエスト1つが処理されるまでの道には、キューが連なっています。ソケットバッファー → スレッドプールのキュー → コネクションプール待ち → DBのロック待ち → 処理。どれか1つが先に飽和すると、その後ろはすべて待ちに変わります。そのため、ボトルネックの調査は、このラダーを順番にたどる作業です。

キュー長の上限は計算できます。許容される待ち時間を、平均の処理時間で割った値です。目標200ms、処理20ms、ワーカー10個なら、上限はおよそ100です。この上限を超えて無限のキューを置くと、障害をレイテンシに置き換えるだけです。拒否は失敗ではなく保護です。早く503を返すほうがましです。

どう動くのか

最も有名な実戦の事例は、2つのデフォルト値の衝突です。HikariCPのmaximum-pool-sizeのデフォルト値10と、Tomcatのthreads.maxのデフォルト値200。トラフィックが増えると、ほとんどのスレッドがコネクション待ちに陥り、Connection is not available, request timed out after 30000msが大量に出ます。解決は、3つを一緒に変えることです。プールを30に上げ、connection-timeoutを30秒から5秒に短縮して早く失敗させ、Tomcatのthreads.maxはむしろ50に縮小します。再発防止のアラートは、hikaricp.connections.pending > 0が30秒以上続いたときにかけます。

飽和する地点を探す手順は、5段階です。

  1. 公式で初期値を決めます。プールサイズ = (コア数 × 2) + ディスク軸の数。8コアなら20前後です。
  2. 目標の負荷で、プール待ちのp99とスループットを記録します。
  3. 半分に減らしてみます。スループットが維持されるなら、元が大きすぎたという意味です。
  4. スループットが増えなくなる地点まで増やしてみて、平らになり始めた値より少し小さいところが適正です。
  5. その値でも待ちが長いなら、プールではなくクエリを直す必要があります。

コネクション待ちが長いということは、占有時間が長いという意味で、それはたいてい、遅いクエリか長いトランザクションです。プールサイズは、その症状をしばらく隠すだけです。そのため、原則はこうです。プールサイズは、データベースが同時にうまく処理できる件数であるべきで、アプリケーションが送りたい件数であってはいけません。

現場での姿

限界に達したあとで同時実行数をさらに増やすと、スループットXは増えず、応答時間Wだけが比例して増えます。やがて、コンテキストスイッチ、同時実行数の2乗に近いロック競合、キャッシュのヒット率の低下、リトライ負荷が重なって、かえって減ります。この式が与えるメッセージは、数字そのものではなく桁です。正解は数百ではなく数十です。

計測ツールがボトルネックを隠す

ボトルネックを見つけられない最もよくある理由は、サーバーではなく、計測側にあります。負荷ツールが、応答を受け取ってから次のリクエストを送ると、サーバーが止まっているあいだは、リクエストがそもそも発射されません。その停止は、どのリクエストの応答時間としても記録されないので、サーバーが1秒まるごと固まっていても、指標には現れません。これをコーディネーテッドオミッション(coordinated omission)と呼びます。実際のユーザーは、サーバーの事情を見ながらリクエストを遅らせたりしないので、こうして測ったp99は、現実よりはるかに楽観的です。

避ける方法は2つです。1つ目は、目標の到着率を固定する方式をサポートするツールを使います。応答を待たずに決まった間隔でリクエストを発射し、遅れた分を応答時間に加えて記録します。2つ目は、ツールがそれをできなければ、サーバー側でキューの待ち時間を別に計測します。リクエストがソケットに到着した瞬間と、ワーカーが取り上げた瞬間の差がその値で、この値が大きくなる時点が、本当の飽和点です。

平均値も、似たやり方で人をだまします。応答時間の分布は左右対称ではなく、右に長く伸びているので、平均は、ほとんどのユーザーが体験しない値になります。そのため、パーセンタイルで見ます。ところが複数のインスタンスのp99を平均すると、その値には何の意味もありません。パーセンタイルは、足したり割ったりできる値ではないからです。ヒストグラムのバケットを集めて、全体の分布を作り直してから、パーセンタイルを求める必要があります。

もう1つ、指摘しておくことがあります。1画面が内部呼び出し10回でできていて、各呼び出しのp99が100msなら、画面全体のp99は100msではありません。10回のうち1回でも遅いほうに当たる確率がずっと大きいので、ユーザーが実際に体験するレイテンシは、それよりはるか上に上がります。内部呼び出しを1つ増やす決定は、その呼び出しの平均ではなく、裾をもう1つ上乗せする決定だと見る必要があります。そのため、性能を守る最も効果的な方法は、各呼び出しを少しずつ速くすることではなく、呼び出しの数そのものを減らすか、並列に重ねることです。

次のラボですること

一度に1つずつしか処理しないPythonのHTTPサーバーを、わざと作って起動します。同時実行数1と10で測定して、スループットはそのままなのにレイテンシだけが増える直列化のサインを確認し、ボトルネックの仮説を階層ごとに3つ立てたあと、同時処理ができるサーバーに変えて、スループットが何倍に跳ねるかを比較します。最後に、目標のトラフィックを処理できるインスタンス数を計算します。