プログラマのための IT 教科書

性能と負荷対策

この部の 11 / 13 章 ・ 全体で 53 / 76 章 ・ 読了目安 50 分

この章を読むとできるようになること
  • p99 で性能を語れる
  • キャッシュを「無効化」から設計できる
  • 過負荷時に全滅させない仕組みを選べる

計算量とデータ構造では、自分が書いたコードが遅くなる理由を扱いました。 この章は、その外側——システム全体が、負荷の下でも動き続けるかという話です。

この2つは別の問題です。

計算量とデータ構造  1リクエストの処理が O(n²) だから遅い
この章   1リクエストは速いのに、同時に1000件来ると全部倒れる

サービスが落ちる時、原因の多くは後者です。

まず測る。平均を見ない

性能の議論は、数字が無いと始まりません。

指標意味
レイテンシ1件の処理にかかる時間
スループット単位時間あたりに処理できる件数
飽和度どれだけ待ち行列ができているか

そして、レイテンシは平均で見てはいけません。

1000件のリクエスト
  990件が 10ms
   10件が 5000ms
→ 平均 59ms(速そうに見える)
→ p99  5000ms(100人に1人が5秒待たされている)
平均は必ず嘘をつく

遅いリクエストは、たいてい少数です。 だから平均に埋もれます。

見るべきはパーセンタイルです。

表記意味
p50(中央値)半分のリクエストはこれより速い
p9520人に1人がこれより遅い
p99100人に1人。ここを SLO にすることが多い
p99.9大規模なサービスではここまで見る

さらに重要なこととして、利用者は1回のページ表示で複数のリクエストを投げます。 1ページに20個の API 呼び出しがあれば、 p99 が5秒の API にそのページの2割近くが当たります。

「p99 は1%だから無視できる」は成立しません。

ボトルネックを見つける

推測で直さない、というのはデバッグの技術・計算量とデータ構造と同じです。

1. どこで時間を使っているか(分散トレース・監視とオンコール)
   → サービス間のどこで待っているかが一目で分かる
2. そのサービスの中のどこか(プロファイル)
   → go tool pprof / Node の --prof
3. 資源のどれが限界か

資源を見る時は、USE の3つを揃えて見ると漏れません。

見るもの例
Utilization(使用率)どれだけ使われているかCPU 90%
Saturation(飽和)どれだけ待たされているかロードアベレージ、キューの長さ
Errors(エラー)失敗しているかタイムアウト、接続拒否
CPU 使用率が低いのに遅い

最も多いパターンです。この場合、CPU は原因ではありません。

□ DB のクエリ待ち(データベースの INDEX・N+1)
□ 外部 API のレスポンス待ち
□ コネクションプールの枯渇 ← 後述。非常に多い
□ ロックの競合
□ ディスク I/O

「待っている」時間は CPU 使用率に現れません。 だから使用率だけを見ていると、ボトルネックを見失います。

負荷試験

本番で初めて負荷を知る、を避けるためのものです。

# k6 の例(シナリオを JS で書ける)
k6 run --vus 100 --duration 60s script.js
 
# もっと単純に、1エンドポイントを叩くだけなら
vegeta attack -rate=200 -duration=30s | vegeta report

やる時の注意点です。

□ 本番相当のデータ量で行う(100件のテーブルと1000万件では別物)
□ ウォームアップしてから測る(JIT・キャッシュ・接続確立の分を除く)
□ 段階的に負荷を上げ、「壊れ始める点」を探す
□ 壊れ方を見る(緩やかに遅くなるのか、ある点で急に全滅するのか)
限界値より「壊れ方」が重要

知りたいのは「毎秒何件さばけるか」だけではありません。

限界を超えた時にどうなるかが本質です。

良い壊れ方: 処理できない分を早く断る。処理できた分は正常に返る
悪い壊れ方: 全部を受け付けて全部が遅くなり、最終的に全滅する

後者はなだれ的障害と呼ばれ、復旧も困難になります (再起動しても、溜まったリクエストで即座にまた倒れる)。

キャッシュ

最も効果が大きく、最も事故が多い手段です。

どこに置くか

[ブラウザ] → [CDN] → [ロードバランサ] → [アプリ内メモリ] → [共有キャッシュ] → [DB]
   近い                                                                    遠い
   速い・安い                                                              遅い・正確

利用者に近いほど速く、安く、しかし更新が届きにくくなります。

場所得意注意
ブラウザ / CDN静的ファイル、共通コンテンツ個人向けの内容を入れると他人に見える
アプリ内メモリ超高速インスタンスごとに別。全台で消せない
共有キャッシュ(Redis 等)全台で共有・削除できるネットワーク往復がある。単一障害点になり得る
キャッシュに個人情報を入れる事故

CDN や共有キャッシュのキーを URL だけにすると、 ログインユーザーごとに違う内容が、他人に配信されます。

実際に何度も起きている事故です。

□ 個人向けのレスポンスは Cache-Control: private, no-store
□ キャッシュキーにユーザー識別子を含める
□ 認証が必要なエンドポイントは、原則キャッシュしない

無効化 — キャッシュの本体はこちら

1. TTL で期限切れにする      最も単純。ズレる時間を許容できるかが判断基準
2. 更新時に明示的に消す      正確だが、消し忘れが起きる
3. 更新時に書き換える        正確だが、書き込みが重くなる

まず TTL を検討してください。 「5分古くても業務上問題ないか」を業務側に確認します。 問題ないなら、それが最も壊れにくい選択です。

キャッシュスタンピード

人気のあるデータのキャッシュが期限切れになった瞬間、 待っていた全リクエストが一斉に DB へ流れ込みます。

   キャッシュ有効中: DB へのアクセス 0 件/秒
   期限切れの瞬間  : DB へのアクセス 5000 件/秒 ← DB が落ちる

対策は、

  • TTL にばらつきを持たせる(300秒 + ランダム0〜60秒)
  • 期限切れ前にバックグラウンドで更新する
  • 同じキーの再計算は1リクエストだけに絞る(他は待つ)

「一斉に同じことをする」を避ける、という点では後述のジッターと同じ発想です。

キャッシュが無い状態で耐えられるか

これは、経験者でも見落とす観点です。

キャッシュを前提に容量を設計していると、 キャッシュが空になった瞬間にシステムが死にます。

・Redis を再起動した
・デプロイでアプリ内キャッシュが消えた
・キャッシュサーバーが障害で落ちた

「キャッシュはあくまで高速化であり、無くても動く」 —— この状態を保てているかを、設計時に確認してください。

無理な場合は、それはキャッシュではなく依存です。 だとしたら、キャッシュ層にも冗長化と監視が必要になります。

コネクションプール — 見落とされがちな上限

DB への接続は高コストなので、あらかじめ数本張って使い回します。

アプリ 1インスタンス × プール 10本 × 5インスタンス = DB への最大接続 50
「CPU も DB も余裕なのに遅い」の定番原因

プールが枯渇すると、リクエストは接続の空きを待つことになります。

その待ち時間は、DB のメトリクスにもアプリの CPU にも現れません。

□ プールの使用数・待ち時間をメトリクスに出す(監視とオンコール)
□ 1リクエストで接続を長く保持しない(外部 API 呼び出しをトランザクション内に入れない)
□ スケールアウト時に、DB の最大接続数を超えないか確認する

インスタンスを増やしたら DB が接続過多で落ちた、というのはよくある事故です。 アプリの水平スケールには、DB 側の上限という天井があります。

過負荷への備え

ここからが、Amazon や Google の運用文化で最も重視されている領域です。

リトライは負荷を増やす

マイクロサービスの実務で扱ったとおり、素朴なリトライは障害を悪化させます。

サーバーが不調 → タイムアウト → 全クライアントが再送 → 負荷が2倍 → 完全に停止

指数バックオフ + ジッターが標準的な解です。

1回目: 100ms 待つ
2回目: 200ms 待つ      ← 指数的に伸ばす
3回目: 400ms 待つ
      + それぞれにランダムなばらつき(ジッター)を足す

ジッターが無いと、全クライアントが同じタイミングで再送し、 波が同期して押し寄せます。ランダム化が本質です。

さらに、

□ リトライ回数の上限を決める(3回程度)
□ 多段のリトライを禁じる(各層でリトライすると 3×3×3 = 27倍になる)
□ リトライしてよいエラーか判定する(400 番台は再送しても無駄)
リトライは1箇所だけで行う

クライアント・ゲートウェイ・サービス間の各層でリトライを入れると、 掛け算で増幅されます。

どの層でリトライするかをチームで決めてください。 一般的には、最も外側(利用者に近い側)か、 最も内側(依存の直前)のどちらか一方に寄せます。

負荷制限(load shedding)

受け付けられない量が来たら、早く断る。

悪い: 全部受け付ける → 全部が遅くなる → 全員がタイムアウトする → 何も返せていない
良い: 処理できる分だけ受ける → 溢れた分は即座に 503 を返す
      → 受けた分は正常に返る

全滅させるくらいなら、一部を確実に成功させるほうが良いという考え方です。

// 単純だが有効: 同時実行数に上限を設ける
sem := make(chan struct{}, 100)
 
func handle(w http.ResponseWriter, r *http.Request) {
    select {
    case sem <- struct{}{}:
        defer func() { <-sem }()
        process(w, r)
    default:
        w.Header().Set("Retry-After", "1")
        http.Error(w, "混雑しています", http.StatusServiceUnavailable)
    }
}
古いリクエストは捨てる

キューに10秒溜まったリクエストは、利用者がもう見ていません。

処理しても価値が無いのに、資源だけを消費します。 context の締め切りを見て、期限切れなら処理せず捨てるのが正解です。

if err := ctx.Err(); err != nil {
    return err   // 呼び出し元はもう待っていない
}

レート制限

こちらは公平性のための仕組みです。

負荷制限   自分を守る(全体の量を制限する)
レート制限 利用者ごとに上限を設ける(1人が全体を食い潰さないように)

トークンバケット方式が一般的で、429 Too Many Requests と Retry-After ヘッダで返します。

静的安定性

依存先が壊れても、現状のまま動き続けられるかという考え方です。

悪い: 設定を配信するサービスが落ちた → 全サービスが起動できなくなる
良い: 最後に取得した設定を保持しており、配信サービスが落ちても動き続ける
悪い: 認可サービスが落ちた → 全リクエストが失敗
良い: 直前の判定結果を短時間保持し、その間は動き続ける(安全側の判断で)

依存先の障害時に「新しいことはできないが、今の状態は維持できる」 —— これが静的安定性です。設計時に「この依存が落ちたら何が起きるか」を 1つずつ確認してください。

フォールバックは慎重に

「A が失敗したら B を呼ぶ」というフォールバックは、直感的には安全に見えます。

しかし、

  • フォールバック経路は普段動かないので、いざという時に壊れている
  • 障害時に B へ一斉に負荷が移り、B も落ちる
  • 結果、障害の範囲が広がる

Amazon の運用文化では、フォールバックを避け、 主経路を確実に動かすことに投資するという方針が知られています。

フォールバックを入れるなら、平常時から定期的に実行して動作を確認してください。

スケールさせる

垂直スケール(強いマシンに)  単純。ただし上限があり、停止を伴うことが多い
水平スケール(台数を増やす)  上限が緩い。ただし状態を持てない

水平スケールの前提は、アプリがステートレスであることです。

□ セッションをメモリに持たない(認証と認可の実装。共有ストアかトークンへ)
□ ローカルディスクにファイルを保存しない(オブジェクトストレージへ)
□ どのインスタンスに来ても同じ結果を返す
オートスケールは急な波に間に合わない

新しい Pod が起動して受け付け可能になるまで、 数十秒から数分かかります(イメージの取得、起動、ウォームアップ)。

テレビ放映や通知の一斉配信のような急峻な波には、間に合いません。

□ 分かっている波には、事前にスケールしておく
□ 起動を速くする(イメージを小さく、初期化を軽く)
□ 間に合わない分は、負荷制限で守る

p50 が 20ms、p99 が 4秒という API があります。CPU も DB の負荷も低いままです。最初に疑うのはどれですか。

実務の落とし穴まとめ

  1. 平均レイテンシで判断 — p99 を見る。1ページ複数リクエストで確率は跳ね上がる
  2. CPU 使用率だけ見る — 待ち時間は現れない。飽和度を見る
  3. キャッシュキーに利用者を含めない — 他人の情報が配信される
  4. TTL が全部同じ — 一斉に期限切れしてスタンピードが起きる
  5. キャッシュ前提の容量設計 — キャッシュが消えた瞬間に落ちる
  6. コネクションプールを監視していない — 「余裕なのに遅い」の定番
  7. 各層でリトライ — 掛け算で増幅する
  8. ジッターなしのリトライ — 波が同期して押し寄せる
  9. 過負荷時に全部受け付ける — 全滅する。早く断るほうがよい
  10. フォールバック経路を普段動かさない — いざという時に壊れている
  11. オートスケールに頼り切る — 急な波には間に合わない

まとめ

  • 性能は測ってから。平均ではなくパーセンタイル(p99)で見る
  • ボトルネックは Utilization / Saturation / Errors を揃えて見る。 待ち時間は CPU に現れない
  • 負荷試験では、限界値より壊れ方を見る
  • キャッシュは無効化が本体。まず TTL、そして キャッシュが無くても動くかを必ず確認する
  • コネクションプールの枯渇は「原因不明の遅さ」の定番。メトリクスに出す
  • 過負荷対策は指数バックオフ + ジッター、負荷制限、レート制限、 そして静的安定性(依存先が落ちても現状維持で動く)
  • リトライは1層だけ。フォールバックは普段から動かしていないなら入れない
  • 水平スケールの前提はステートレス。オートスケールは急な波に間に合わない

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
The Amazon Builders Libraryhttps://aws.amazon.com/builders-library/
Google SRE Bookhttps://sre.google/books/
Brendan Gregg: USE 法https://www.brendangregg.com/usemethod.html
web.dev(日本語)https://web.dev/?hl=ja

章末問題

人気商品のキャッシュ(TTL 5分)が切れた瞬間に DB が過負荷で落ちました。最も直接的な対策はどれですか。

ピーク時に全リクエストのレスポンスが 30 秒を超え、結局すべてがタイムアウトしました。設計として何を入れるべきでしたか。

次の章では、第5部の最後として、 確率的にしか答えないもの——AI をサービスに組み込む話を扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。