STEP 8-11 ・ 所要 120分

限界と改善

ここからが負荷試験の本番です。「何人まで耐えられるか」「なぜそこで止まるのか」「直すと何がどう変わるか」 を数字で言えるようになります。このページの数値はすべて実際に測ったものです。

STEP 8 ・ 限界点をさがす

観点 スレッド数を階段状に上げて、スループットが増えなくなる点を探す 時間 30分

やることは単純です。同じテストを、人数だけ変えて何回も流す。それだけです。 1回ずつは45〜60秒で十分です。

PowerShell — 人数を倍々にして流す
cd M:\html\jmetertest
foreach ($t in 10,20,40,80) {
  docker compose run --rm jmeter 04_stress_influx.jmx -Jthreads=$t -Jramp=10 -Jduration=45 -Jtesttitle="ladder$t"
}

記録するのは3つだけです。

指標どこを見るか限界に近づくとどうなるか
スループットsummary の = NNN/sある点から増えなくなる
応答時間summary の Avg: と Max飽和点を越えると急激に伸びる
エラー率summary の Err:さらに越えると立ち上がる

実測データを読む

実際に測った結果です。対象は専用のサーバー(2コア・メモリ1GB)、 負荷は別のPCからインターネット越しにかけています。

スレッド数スループット平均応答最大エラー判定
10188.5 req/s47 ms2026 ms0%余裕あり
20357.8 req/s49 ms1087 ms0%ここが美味しい点
40373.4 req/s94 ms1454 ms0%飽和
80378.7 req/s186 ms5209 ms0%完全に飽和

この表からは、はっきりした3段階が読み取れます。

10人 → 20人 : スループットがほぼ倍

188.5 → 357.8 req/s(+90%)。一方で応答時間は 47ms → 49ms とほぼ変わらず。 まだ余裕がある証拠です。人を増やせば増やしただけ仕事が増えています。

20人 → 40人 : スループットは増えないのに応答時間が倍

357.8 → 373.4 req/s(わずか +4%)なのに、応答時間は 49ms → 94ms(約2倍)。 この間のどこかが飽和点です。

40人 → 80人 : 完全に頭打ち、待たされるだけ

373.4 → 378.7 req/s(+1%)で、応答時間はまた倍の 186ms。 最大値に至っては 5.2秒。人を増やしても誰も得をしていません。

結論の言い方

「このシステムの処理能力は 約 370 req/s同時20人までは応答50msを保てるが、それを超えると応答時間だけが悪化する。 推奨する運用上限は同時20〜30人」──負荷試験の報告書はこう書きます。

なぜスループットが増えずに応答時間だけ伸びるのか

スーパーのレジを想像してください。レジが1台で、1人1分かかるとします。 このレジの処理能力は「毎分1人」で固定です。

行列の考え方
並んでいる人数 = 処理能力 × 待ち時間

  5人が並ぶ  →  毎分1人 × 5分待ち
 10人が並ぶ  →  毎分1人 × 10分待ち   ← 処理能力は同じ。待ち時間だけ倍
 20人が並ぶ  →  毎分1人 × 20分待ち   ← さらに倍

レジを増やさない限り、行列を長くしても誰も速く帰れません。 これが「スループットは頭打ちなのに応答時間だけ伸びる」の正体です (リトルの法則といいます)。

だから次にやることは決まっている

応答時間を縮めたいなら、行列を短くする(人を減らす)か、 レジを増やす/速くする(ボトルネックを直す)しかありません。 どのレジが詰まっているのかを突き止めるのが次のステップです。

STEP 9 ・ ボトルネックを特定する

観点 「遅い」で終わらせず、どの部品が限界なのかを名指しする 時間 30分

飽和している状態(さきほどなら80スレッド)で負荷をかけたまま、 Grafana の下半分とサーバーの状態を見ます。読み分け表がこれです。

見えるもの疑うべき犯人打つべき手
DBコネクションプールの queued が伸びる DB待ち。プールが小さいか、クエリが遅い クエリを速くする。プールを増やす
MySQL のコンテナCPUが上限に張り付く DBのCPUが限界 インデックス、クエリ削減、キャッシュ
Node のイベントループ遅延が伸びる アプリのCPUが限界 重い処理を減らす。アプリの台数を増やす
アプリのCPUは低いのに応答が遅い 何かを待っている(DB、外部API) 待ち先を特定する
特定のサンプラーだけ極端に遅い そのリクエスト固有の問題(後述) サンプラー別に分けて見る
サーバーはどこも余裕なのに遅い ネットワーク、またはJMeter側が限界 負荷生成機を分ける・増やす

サーバー側を直接見るコマンド

サーバーの場合も Grafana で完結します

監視ツールはサーバーに同居させてあるので、 上半分(JMeter側)も下半分(サーバー側)も同じ画面で見られます。 下のコマンドは、グラフを使わずに素早く確認したいときの手段です。

Grafana を使わなくても、対象のマシンで1行打てば分かります。

負荷をかけている最中に実行
docker stats --no-stream --format "{{.Name}} {{.CPUPerc}} {{.MemUsage}}"
サーバーで5秒おきに10回サンプリングする(SSHしてから実行)
for i in $(seq 1 10); do
  sudo docker stats --no-stream --format "{{.Name}} {{.CPUPerc}}" | tr '
' ' '
  echo
  sleep 5
done

グラフと docker stats を見比べる

最初のうちは両方を並べて見てください。 「Grafanaのこの線は docker stats のこの数字だ」と対応が取れると、 グラフの読み方が一気に身につきます。

実測:同じ試験の中に2つのボトルネックが見えた

80スレッドで飽和させながら実際に測った結果です。まずサーバー側のCPU。

docker stats の実測(負荷中・6回サンプリングの代表値)
loadlab-mysql-1    80.7%   143.9MiB / 768MiB   ← 断然これ
loadlab-app2-1     24.3%    51.9MiB / 384MiB
loadlab-app1-1     23.0%    40.8MiB / 384MiB
loadlab-nginx-1     7.0%     8.2MiB / 128MiB
loadlab-redis-1     4.2%     8.2MiB / 128MiB

MySQL は自分に割り当てられた1コアの約80%を使っています。 アプリは2台合わせても50%弱、nginx にいたっては7%。 犯人はデータベースです。 アプリのサーバーを増やしても、この状況は1ミリも改善しません。

ところが、JMeter 側の記録をサンプラー別に分けて見ると、別の絵が出てきました。

サンプラーp95 応答時間件数
一覧 GET /api/products93 ms24,159
詳細 GET /api/products/id57 ms24,125
静的 GET /static/sample.png(90KB)521 ms24,043

ここから分かること

画像ファイルだけがAPI の6〜9倍遅い。しかしこれを配信している nginx の CPU使用率はわずか7%です。サーバーは暇なのに遅い。つまり原因はサーバーではありません。

90KB × 毎秒126件 = 約11MB/秒(90Mbps)回線が先に埋まっていたのです。

この教材で一番大事な学び

1回の試験の中に性質のまったく違うボトルネックが2つ同時に存在していました

  • API … サーバーのDBのCPUが原因 → インデックスやクエリで直せる
  • 画像 … 回線が原因 → サーバーを速くしても1ミリも改善しない。CDNや圧縮の話になる

全部を合計した1つの数字(Avg: 186ms)だけを見ていたら、 絶対に気づけませんでした必ずサンプラー別・トランザクション別に分けて見てください。

応用:この切り分けは自分でもできる

「サーバーのCPUは暇なのに応答が遅い」ときは、ほぼネットワークかクライアント側です。 JMeter の LatencyLoad time の差を見てください (基本の章参照)。差が大きければ転送量の問題です。

STEP 10 ・ 直して、同じ試験でもう一度測る

これが負荷試験の存在意義です。 「速くなった気がする」ではなく、同じシナリオ・同じ人数・同じ時間で前後を測り、 数字で証明します。

実験A ・ インデックスを消してみる

観点 データベースの索引が有るか無いかで何倍変わるかを体感する 時間 20分

練習台には20万件のレビューが入っています。 review_index のスイッチを 0 にすると、索引を本当に削除します (見せかけではなく DROP INDEX を実行します)。

まず単発で違いを見る

PowerShell — 通常の状態で商品詳細を1回
curl.exe -s http://localhost:8080/api/products/500

返ってきた JSON の reviewTookMs という値に注目してください。

索引を削除する
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"review_index\":0}" http://localhost:8080/api/admin/flags

もう一度同じ URL を叩きます。

期待値(実測値)

状態reviewTookMs
索引あり(通常)1〜2 ms
索引なし60〜124 ms

約60倍です。索引が無いと、20万行を1行ずつ全部見に行くためです。 しかもこれは同時アクセス1件のときの数字で、負荷をかけると差はさらに開きます。

負荷をかけて比較する

索引なしのまま45秒
docker compose run --rm jmeter 04_stress_influx.jmx -Jthreads=30 -Jduration=45 -Jtesttitle=no_index
索引を戻して、同じ条件でもう一度
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"review_index\":1}" http://localhost:8080/api/admin/flags

docker compose run --rm jmeter 04_stress_influx.jmx -Jthreads=30 -Jduration=45 -Jtesttitle=with_index

確認すること

  • スループットが明確に上がる
  • Grafana の MySQL のスレッド数MySQLのCPU が下がる
  • DBコネクションプールの queued が減る

実験B ・ N+1問題を直す

観点 「よくある非効率」を直すと数字がどれだけ動くか 時間 30分

N+1問題とは

商品を20件表示するのに、まず20件のリストを1回引き、 そのあと1件ずつタグとカテゴリを引きに行く作り方のことです。 20件なら 1 + 40 = 41回もデータベースに問い合わせます。 プログラムとしては自然に書けてしまうため、実務で非常によく見つかります。

N+1 の状態にして測る
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"n_plus_one\":1}" http://localhost:8080/api/admin/flags

docker compose run --rm jmeter 04_stress_influx.jmx -Jthreads=30 -Jduration=60 -Jtesttitle=before
直した状態で、まったく同じ条件で測る
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"n_plus_one\":0}" http://localhost:8080/api/admin/flags

docker compose run --rm jmeter 04_stress_influx.jmx -Jthreads=30 -Jduration=60 -Jtesttitle=after

期待値(実測値・ローカル環境)

スループット平均最大
改善前(N+1あり)243.8 req/s112 ms823 ms
改善後(N+1なし)329.5 req/s83 ms443 ms
変化+35%−26%−46%

スループットが35%増え、最悪ケースは半分になりました。 「同じ試験で前後を測る」とはこういうことです。

実際にやらかした失敗の記録

この練習台を作るとき、N+1の「改善後」を テーブルを結合して1回で取る方法で実装しました。ところが測ってみると──

スループット
改善前(N+1あり)243 req/s
「改善後」(結合して1回)135 req/s(悪化)

直したつもりが半分近くまで遅くなっていました。 結合と集約の処理が、41回の軽い問い合わせより CPU を食っていたためです。

正しい直し方は「まとめて引く」(IDを集めて IN 句で1回)でした。 こう書き直して 329 req/s になっています。 良かれと思った変更が逆効果になることは実際にある。だから測るのです。

STEP 11 ・ 同時実行でしか出ないバグを再現する

観点 負荷試験でしか見つからない種類のバグを、自分の手で発生させる 時間 20分

性能の話はここまでです。最後は正しさの話をします。 1人でテストしている限り絶対に発見できない不具合が世の中にはあります。

在庫チェックの2つの書き方
【正しい】 1文で「在庫が足りていれば減らす」を同時に行う
   UPDATE products SET stock = stock - 1 WHERE id = 7 AND stock >= 1

【壊れている】 確認と更新を分ける
   SELECT stock FROM products WHERE id = 7     ← 「1個ある」
   (ここで他の人も同じことをする。全員が「1個ある」と読む)
   UPDATE products SET stock = stock - 1       ← 全員が減らす

05_stock_race.jmxSynchronizing Timer を使い、 30人全員がそろうまで待たせてから、一斉に注文させます。 在庫が1個しかない商品を狙います。

まず正しい実装で

PowerShell
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"stock_mode\":0}" http://localhost:8080/api/admin/flags

curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"value\":1,\"maxId\":100}" http://localhost:8080/api/admin/stock

docker compose run --rm jmeter 05_stock_race.jmx -Jthreads=30

期待値(実測値)

出力
summary =     90 in 00:00:01 =   80.1/s Avg:    37 Err:    29 (32.22%)

エラー29件は正常です。在庫1個に30人が殺到したので、 1人が成功し、29人が「売り切れ(409)」になりました。これが正しい動きです。 在庫は 0 になり、売れた数はきっちり1個でした。

次に壊れた実装で

PowerShell — 確認と更新を分ける実装に切り替える
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"stock_mode\":1}" http://localhost:8080/api/admin/flags

curl.exe -s -X POST -H "x-admin-token: loadlab-admin" -H "content-type: application/json" -d "{\"value\":1,\"maxId\":100}" http://localhost:8080/api/admin/stock

curl.exe -s -X POST -H "x-admin-token: loadlab-admin" http://localhost:8080/api/admin/reset-data

docker compose run --rm jmeter 05_stock_race.jmx -Jthreads=30

期待値(実測値)— これが事故です

出力
summary =     90 in 00:00:01 =   75.4/s Avg:    44 Err:    10 (11.11%)

エラーが減っています。一見「改善した」ように見えますが、逆です。 データベースを確認するとこうなっています。

正しい実装壊れた実装
注文の成功数1件20件
商品7の在庫0−19

1個しかない商品が20個売れて、在庫がマイナス19になりました。 本番でこれが起きれば、19人に謝罪と返金をすることになります。

自分の目で確認する
curl.exe -s -H "x-admin-token: loadlab-admin" http://localhost:8080/api/admin/status

返ってくる JSON の oversoldProducts が「在庫がマイナスになった商品の数」です。 正しい実装なら 0、壊れた実装なら 1 以上になります。

この章で一番覚えてほしいこと

エラー率が下がったから良くなった、とは限りません。 このケースでは、エラー率が 32% から 11% に「改善」した裏で、 データが壊れていました。 性能の数字だけでなく、データが正しいかを必ず確認してください。 これができるのが負荷試験の本当の価値です。

終わったら必ず元に戻してください。

後始末
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" http://localhost:8080/api/admin/flags/reset
curl.exe -s -X POST -H "x-admin-token: loadlab-admin" http://localhost:8080/api/admin/reset-data

おまけ:負荷生成機を同居させてはいけない理由

「はじめに」で触れた掟3を、実測で確認します。まったく同じテストプラン・同じ設定です。

構成スループット
JMeter と対象を同じPCで動かす329.5 req/s
JMeter を別PCにして、対象を専用サーバーに置く374.0 req/s(+14%)

ネットワークを挟んだ方が速いのはなぜか

普通に考えれば、インターネットを挟んだ方が遅くなるはずです(実際、応答時間には約25msの 往復時間が上乗せされています)。それでもスループットが高いのは、同居させたときに JMeter 自身が CPU の 42% を奪っていたからです。 測っていたのは対象システムの限界ではなく、PCの限界でした。

STEP 8-11 完了の判定

  • スレッド数を変えた表を自分で作り、飽和点を指させる
  • 「スループット頭打ち+応答時間だけ倍」の意味を説明できる
  • Grafana か docker stats でボトルネックを名指しできる
  • サンプラー別に分けて見ると別の結論になり得ることを知っている
  • 改善前後を同じ条件で測り、数字で示せる
  • 在庫をマイナスにして、自分の目で oversoldProducts を確認した