REST API が遅いとき — ボトルネックを探す順序

読了 5分

「API が遅いです」はバックエンド開発者が最もよく受ける報告であり、最も漠然とした報告です。遅い場所はデータベースかもしれず、シリアライズかもしれず、外部 API かもしれず、ネットワークかもしれません。この記事はその候補を勘ではなく順序で絞り込む方法を整理します。サーバー・インフラのレベルの診断はサーバーが遅い理由シリーズで扱ったので、この記事はその上の層、アプリケーションコードの視点です。

ステップ 0 — 「遅い」を数字に変えます #

始まりは測定です。二つを確定します。

  • どのエンドポイントが、どれだけ — APM やアクセスログの集計で、エンドポイント別の応答時間のランキングを出します。体感の報告と実際の犯人が違うことは多いのです。
  • 平均ではなく分位数で — p50 が 100ms でも p99 が 5 秒なら、ユーザー 100 人に 1 人は 5 秒を経験しています。そしてその 1% はたいてい重いデータを持つヘビーユーザー、つまり最も重要なユーザーです。目標も「p99 < 500ms」のように分位数で設定します。

ステップ 1 — 区間を割ります #

エンドポイントを特定したら、リクエスト一つの時間を区間別に分けます。トレーシングの道具があればスパンで、なければ区間ログ数行でも始められます。分けてみると大半はこう分解されます。

リクエスト時間の分解(例)
全体 1,240ms
├─ ミドルウェア・認証        18ms
├─ DB クエリ (23 回)       780ms   ← ここ
├─ 外部 API 呼び出し (2 回)  310ms   ← そしてここ
├─ シリアライズ(JSON 変換)   95ms
└─ その他のロジック          37ms

この分解が診断の半分です。以下の常連 4 か所のどこが膨らんでいるかが、すぐに見えるからです。

常連 ① データベース — まず回数、次に速度 #

DB の区間が大きければ、二つを順に見ます。クエリの回数が先です。上の例の「23 回」が典型で、一覧を回りながら項目ごとにクエリを投げる N+1 パターンが大半です。クエリ一つひとつは速いのでスロークエリログには残らず、数を数えて初めて見えます。ORM の eager loading(select_relatedprefetch_related の類)で数個にまとめるのが処方で、詳しくは Django 上級 #3 で扱いました。

回数が正常なのに遅ければ、個々のクエリの速度です。EXPLAIN で実行計画を見るこの領域は、データベースが遅くなるときで整理しました。インデックスがなぜこの問題の中心なのかは、次の記事で別途扱います。

常連 ② 外部呼び出し — 自分のコードではなく他人の時間 #

決済、通知、検索、LLM のような外部 API の呼び出しが応答の経路の中にあると、自分の API のレイテンシの下限は他人のサービスが決めます。点検の順序はこうです。

  • 応答の経路から外せるか — 結果を即座に返す必要のない呼び出し(通知の送信、ログの書き込み)はキューに入れて、応答を先に返します。最も効果の大きい処方です。
  • 並列にまとめられるか — 互いに依存しない呼び出し二つを逐次で待つコードは、往復の掛け算を自ら作り出しているのと同じです。
  • タイムアウトとフォールバックがあるか — 速度の問題であり安定性の問題です。外部が遅くなったとき自分の API が一緒に遅くなる結合を、タイムアウトとキャッシュ値へのフォールバックで断ちます。

常連 ③ シリアライズとペイロード — データが大きい分だけ遅い #

数千件のオブジェクトを JSON に変えるシリアライズは CPU を使うコードなので、ペイロードが大きくなるほど確実に遅くなります。応答が数 MB なら、問いは「シリアライズをどう速くするか」ではなく「なぜこれを全部送るのか?」です。ページネーション(無限スクロールならカーソルベース)、フィールドの選別(一覧には要約だけ)、圧縮(gzip/brotli は転送時間を減らします)が順序どおりの処方です。特に「全件取得してアプリ側で切る」のような、DB からすでに大きいデータを持ってくるパターンは、DB の区間とシリアライズの区間を同時に膨らませます。

常連 ④ そして繰り返しの計算 — キャッシュが答えのケース #

同じ入力に同じ出力を毎回計算し直しているなら(集計、ランキング、設定データ)、キャッシュが処方です。ただしキャッシュは最後に使います。N+1 やペイロードの問題をキャッシュで覆うと、キャッシュが冷めるたび(期限切れ、デプロイ、再起動)に元の問題がスパイクとして戻ってくるからです。構造を直した後、それでも高い計算にキャッシュを載せる順序が正しいのです。キャッシュの保存先としてよく使う Redis がなぜこの用途に合うのかは、別の記事で扱います。

ここまでやっても遅ければ — 下の層へ #

アプリケーションの区間が全部正常なのに全体が遅ければ、問題はコードの外です。ワーカー・コネクションプールの枯渇(リクエストが処理の前に列を作るケース)、GC の停止、サーバーのリソース不足が候補になります。ここからはサーバーが遅い理由 #1 の診断の手順へ進みます。Python のサーバーなら、プロファイラでコードの中を直接のぞく方法を別の記事で扱います。

まとめ #

  • 始まりは測定です。エンドポイントのランキングと分位数(p99)で「遅い」を数字に変え、リクエスト一つを区間に割ります。
  • DB の区間はクエリの回数(N+1)をまず見て、その次に個々のクエリの速度(EXPLAIN)を見ます。
  • 外部呼び出しは応答の経路から外し(キュー)、並列化し、タイムアウト・フォールバックで結合を断ちます。
  • 大きいペイロードはシリアライズと DB を同時に膨らませます。ページネーションとフィールドの選別が先で、キャッシュは構造を直した後です。
  • アプリケーションの区間が正常なのに遅ければ、下の層(ワーカープール、サーバーのリソース)へ進みます。
X