「あの画面が遅い」という話から改善を始めると、たいてい外します。体感が指す場所と、実際に時間を使っている場所は一致しないからです。この連載は、その一致しなさを前提に組み立てます。
初回は計測です。地味ですが、ここを飛ばした改善は運が良ければ当たるという性質のものになります。当たっても再現できませんし、外れても理由が分かりません。
速くすること自体を目的にしません。速度は他の価値(可読性、開発速度、費用)と交換して得るものです。だから「速くしない」という判断も扱いますし(EP.11)、やりすぎの害も繰り返し出てきます。
1. 測る前に、何が問題かを決める
計測の前段として、何をもって遅いとするかを決めます。ここが曖昧なまま測ると、数字は出るのに判断できません。
- 誰が待っているのか — 画面の前の利用者か、後続のバッチか、外部システムか
- どこからどこまでか — ボタンを押してから表示されるまで。内部処理だけを測っても意味が薄い
- どれくらいなら許容できるのか — 決めていないと、改善の終わりが来ない
- どれくらいの頻度で起きるのか — 月1回の処理と、毎秒起きる処理では優先度が違う
3番目が特に効きます。目標値がないと、改善は際限なく続けられてしまう。「1秒を0.5秒にする」作業と「10秒を3秒にする」作業では価値がまったく違いますが、目標がないとその差が見えません。 のような形で明文化しておくと、判断が速くなります。
2. 平均を見てはいけない
応答時間を平均で見るのは、最もよくある失敗です。平均は、少数の極端に遅いケースを隠します。
| 状況 | 平均 | p95 | 実際に起きていること |
|---|---|---|---|
| 全員が少し遅い | 500ms | 600ms | 全体的に改善が必要 |
| 大半は速く、一部が極端に遅い | 500ms | 4,000ms | 特定条件で破綻している |
下の行が厄介です。大半の利用者は快適なので苦情が出にくく、しかし特定の条件に当たった人は使い物にならない状態を経験しています。データ量の多い顧客だけ遅い、といった形で現れます。
だから を見ます。p95 は「遅いほうから5%を除いた中で最も遅い値」で、大半の利用者が体験する最悪値にあたります。平均と p95 が大きく離れているなら、それ自体が調査すべき兆候です。
def summarize(samples): """応答時間の一覧から、判断に必要な数字を出す。""" s = sorted(samples) n = len(s)
def pct(p): # 小さい順に並べたとき、下から p% の位置の値 idx = min(int(n * p / 100), n - 1) return s[idx]
return { "件数": n, "平均": sum(s) / n, "中央値": pct(50), "p95": pct(95), "p99": pct(99), "最大": s[-1], }
# 大半は速いが、20件に1件だけ極端に遅いケースsamples = [100] * 950 + [4000] * 50for k, v in summarize(samples).items(): print(f" {k:6s}: {v:8.1f}")100件しか測っていないなら、p99 は上から1件目です。1件の外れ値でいくらでも動きます。分位点は、それを支える件数があって初めて意味を持ちます。件数が少ないうちは最大値と併せて見てください。
3. どこを測るか ── 外側から内側へ
測る順序は、外側から内側です。いきなり関数単位のプロファイルを取っても、それが全体のどれだけを占めるのか分かりません。
- 1利用者が待つ時間 — リクエスト開始から応答完了まで。まずこれ
- 2構成要素ごとの内訳 — アプリ / DB / 外部API / ネットワークのどこか
- 3その中の処理単位 — どの関数、どのクエリか
- 4その中の詳細 — 行単位、命令単位。ここまで来ることは稀
各段階で全体に占める割合を確認しながら降りていきます。3割しか占めていない箇所を半分にしても、全体は15%しか改善しません。逆に、7割を占める箇所が見つかればそこだけで勝負がつきます。
サービスが複数に分かれている場合、この切り分けが難しくなります。 は、1つのリクエストが複数のサービスを渡り歩く経路を識別子で紐づけて追跡する仕組みで、内訳を出すための前提条件になります。
4. 手元の計測と本番の計測は別物
手元で を取るのは有効ですが、手元で再現しない遅さが本番には存在します。原因はほぼ環境差です。
| 差 | 本番で起きること | 手元で気づけない理由 |
|---|---|---|
| データ量 | 件数に比例して遅くなる処理が顕在化 | 開発用データは小さい |
| データの偏り | 特定の顧客だけ極端に遅い | 均質なテストデータでは出ない |
| 同時実行 | や接続の枯渇 | 1人で触っている |
| ネットワーク | 往復の遅延が積み上がる | 同一マシン内では無視できる |
| キャッシュの状態 | 冷えた状態では遅い | 何度も実行して温まっている |
だから本番側にも計測を置きます。 は、実際の利用者の条件下での遅さを継続的に捉える仕組みです。手元のプロファイルは仮説を立てるため、本番の計測は事実を知るため、と役割を分けて考えると混乱しません。
import random
SAMPLE_RATE = 0.01 # 通常は 1% だけ詳細を残すSLOW_MS = 1000 # ただし遅かったものは必ず残す
def should_record(elapsed_ms: float) -> bool: if elapsed_ms >= SLOW_MS: return True return random.random() < SAMPLE_RATE
def finish_request(elapsed_ms, detail): # 件数の集計は必ず全件。詳細だけを絞る metrics.observe("request_ms", elapsed_ms) if should_record(elapsed_ms): traces.save(detail)要点は、件数や分位点の集計は全件に対して行い、詳細な記録だけを絞ることです。詳細まで一律に間引くと、分位点そのものが歪みます。軽い集計は全件、重い記録はサンプリング、と分けて考えてください。
本番で詳細を常時記録すると、それ自体が負荷になります。一定割合をサンプリングして記録すれば、傾向を掴むには十分です。ただし遅かったリクエストは必ず記録するという条件を足すと、調査に必要な情報が残ります。
5. 測ったあとに何を残すか
改善したら、改善前後の数字を残します。これを怠ると、後から「本当に効いたのか」が分からなくなり、同じ議論を繰り返すことになります。
- 何が遅かったか — 症状と、それが起きる条件
- 原因は何だったか — 推測ではなく、計測で確認したもの
- 何をしたか — 変更内容
- どう変わったか — 改善前後の分位点。平均だけでは足りない
- 何を犠牲にしたか — 可読性、メモリ、整合性。必ず何かは払っている
最後の項目が重要です。性能改善は必ず何かとの交換です。キャッシュを入れれば整合性のリスクを負い、索引を貼れば書き込みが遅くなる。払ったものを書き残さないと、後任者が「なぜこんな作りに」と考えることになります。
測らずに直したものは、直ったかどうかも分からない。計測は改善の前提ではなく、改善の一部である。
6. この連載で扱うこと
以降は、切り分け(EP.2)、SQL(EP.3〜4)、N+1(EP.5)、キャッシュ(EP.6)、バッチ(EP.7)、フロント(EP.8)、メモリと (EP.9)、(EP.10)と進み、速くしないという判断(EP.11)とアンチパターン(EP.12)で締めます。
実際に起きた障害の事例は データ基盤トラブル事件簿 に、CI の実行時間については LLM時代のテスト戦略 EP.03「テストを速くする」 にまとめてあります。考え方は共通していて、計測してから手を付けるという順序は変わりません。
目標値を決めてから測る。応答時間は平均ではなく分位点で見る。計測は外側から内側へ、各段階で全体に占める割合を確認しながら降りる。手元と本番は別物なので両方に計測を置く。改善したら払った代償まで書き残す。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。