限界と改善
ここからが負荷試験の本番です。「何人まで耐えられるか」「なぜそこで止まるのか」「直すと何がどう変わるか」 を数字で言えるようになります。このページの数値はすべて実際に測ったものです。
STEP 8 ・ 限界点をさがす
やることは単純です。同じテストを、人数だけ変えて何回も流す。それだけです。 1回ずつは45〜60秒で十分です。
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からインターネット越しにかけています。
| スレッド数 | スループット | 平均応答 | 最大 | エラー | 判定 |
|---|---|---|---|---|---|
| 10 | 188.5 req/s | 47 ms | 2026 ms | 0% | 余裕あり |
| 20 | 357.8 req/s | 49 ms | 1087 ms | 0% | ここが美味しい点 |
| 40 | 373.4 req/s | 94 ms | 1454 ms | 0% | 飽和 |
| 80 | 378.7 req/s | 186 ms | 5209 ms | 0% | 完全に飽和 |
この表からは、はっきりした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 ・ ボトルネックを特定する
飽和している状態(さきほどなら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}}"
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。
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/products | 93 ms | 24,159 |
| 詳細 GET /api/products/id | 57 ms | 24,125 |
| 静的 GET /static/sample.png(90KB) | 521 ms | 24,043 |
ここから分かること
画像ファイルだけがAPI の6〜9倍遅い。しかしこれを配信している nginx の CPU使用率はわずか7%です。サーバーは暇なのに遅い。つまり原因はサーバーではありません。
90KB × 毎秒126件 = 約11MB/秒(90Mbps)。 回線が先に埋まっていたのです。
この教材で一番大事な学び
1回の試験の中に性質のまったく違うボトルネックが2つ同時に存在していました。
- API … サーバーのDBのCPUが原因 → インデックスやクエリで直せる
- 画像 … 回線が原因 → サーバーを速くしても1ミリも改善しない。CDNや圧縮の話になる
全部を合計した1つの数字(Avg: 186ms)だけを見ていたら、
絶対に気づけませんでした。
必ずサンプラー別・トランザクション別に分けて見てください。
応用:この切り分けは自分でもできる
「サーバーのCPUは暇なのに応答が遅い」ときは、ほぼネットワークかクライアント側です。 JMeter の Latency と Load time の差を見てください (基本の章参照)。差が大きければ転送量の問題です。
STEP 10 ・ 直して、同じ試験でもう一度測る
これが負荷試験の存在意義です。 「速くなった気がする」ではなく、同じシナリオ・同じ人数・同じ時間で前後を測り、 数字で証明します。
実験A ・ インデックスを消してみる
練習台には20万件のレビューが入っています。
review_index のスイッチを 0 にすると、索引を本当に削除します
(見せかけではなく DROP INDEX を実行します)。
まず単発で違いを見る
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件のときの数字で、負荷をかけると差はさらに開きます。
負荷をかけて比較する
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問題を直す
N+1問題とは
商品を20件表示するのに、まず20件のリストを1回引き、 そのあと1件ずつタグとカテゴリを引きに行く作り方のことです。 20件なら 1 + 40 = 41回もデータベースに問い合わせます。 プログラムとしては自然に書けてしまうため、実務で非常によく見つかります。
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/s | 112 ms | 823 ms |
| 改善後(N+1なし) | 329.5 req/s | 83 ms | 443 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 ・ 同時実行でしか出ないバグを再現する
性能の話はここまでです。最後は正しさの話をします。 1人でテストしている限り絶対に発見できない不具合が世の中にはあります。
【正しい】 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.jmx は Synchronizing Timer を使い、
30人全員がそろうまで待たせてから、一斉に注文させます。
在庫が1個しかない商品を狙います。
まず正しい実装で
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個でした。
次に壊れた実装で
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を確認した