計測して「遅い」と分かったら、次はどこが遅いかです。 は必ずどこか一箇所にあり、そこ以外をいくら速くしても全体は変わりません。
この回は切り分けの手順を扱います。全部を同時に疑うと進まないので、症状から候補を絞るという進め方をします。
1. まず「計算しているのか、待っているのか」
最初の分岐はここです。遅さには2種類しかありません。計算に時間がかかっているか、何かを待っているかです。判別は CPU の使用状況で概ねつきます。
| CPU | 遅い理由 | 疑う場所 |
|---|---|---|
| 高い(張り付いている) | 計算している | アルゴリズム、無駄な繰り返し、 |
| 低いのに遅い | 待っている | ディスク、ネットワーク、 |
| 高いが処理は進む | そもそも処理量が多い | 件数を減らせないか |
実務で圧倒的に多いのは待っている側です。業務システムの処理は、計算そのものより外部への問い合わせが時間を占めることがほとんどだからです。CPU が低いのに遅いなら、まず待ちを疑ってください。逆に言えば、アルゴリズムの改善から入るべき場面は思ったより少ないということでもあります。計算量の議論は魅力的ですが、実際の ボトルネック が待ちにあるなら、そこを詰めても全体は動きません。
待ちが原因の場合、台数を増やしても改善しません。むしろ待ち先(DBなど)への負荷が増えて悪化することがあります。増やす前に、何を待っているかを確認してください。
2. 回数を数える
待ちが疑われるとき、最初に見るべきは時間ではなく回数です。1回あたりが十分速くても、回数が多ければ積み上がります。
import timefrom collections import defaultdictfrom contextlib import contextmanager
stats = defaultdict(lambda: {"count": 0, "total": 0.0})
@contextmanagerdef measure(label): start = time.perf_counter() try: yield finally: elapsed = time.perf_counter() - start stats[label]["count"] += 1 stats[label]["total"] += elapsed
def report(): print(f"{'処理':22s} {'回数':>6s} {'合計(秒)':>10s} {'1回平均(ms)':>13s}") for label, s in sorted(stats.items(), key=lambda x: -x[1]["total"]): avg_ms = s["total"] / s["count"] * 1000 print(f"{label:22s} {s['count']:6d} {s['total']:10.3f} {avg_ms:13.2f}")この形で出すと、「1回は速いが回数が多い」という構造がすぐ見えます。1回2ミリ秒でも、1000回呼べば2秒です。個々の速さだけを見ていると、この積み上げを見落とします。
回数が多い場合の代表的な原因が で、EP.5 で扱います。一覧を取ったあと、各行の関連データを1件ずつ問い合わせてしまう構造です。開発時のデータが少ないと気づかず、本番で顕在化するのが厄介な点です。
3. レイヤーごとの見分け方
待ち先の候補は限られています。それぞれ特徴的な症状があるので、そこから絞ります。
| レイヤー | 特徴的な症状 | 確認すること |
|---|---|---|
| アプリ | CPUが張り付く。件数に対して非線形に遅くなる | で処理単位の内訳を出す |
| DB | 特定のクエリだけ遅い。件数増加で急に悪化 | を見る(EP.3) |
| ネットワーク | 1回あたり一定時間かかる。回数に比例 | 往復回数を数える |
| ディスク | 初回だけ遅い。メモリに乗ると速い | 読み込み量とキャッシュの効き |
| 排他制御 | 同時実行を増やすと悪化する | ロック競合 の有無 |
| 外部サービス | 不定期に跳ねる。自分たちの変更と無関係 | 呼び出し先の応答時間を分離して計測 |
最後の「外部サービス」は、切り分けの観点で特に重要です。自分たちのコードを一切変えていないのに遅くなったなら、外部要因を最初に疑うべきです。ここを分離して計測していないと、自分たちのコードを延々と調べることになります。
4. 件数との関係を見る
もうひとつ強力な手がかりが、件数を変えたときにどう変わるかです。処理時間の伸び方から、構造上の問題が見えます。
| 件数を10倍にしたとき | 時間の伸び | 示唆 |
|---|---|---|
| ほぼ変わらない | 一定 | 件数と無関係な固定コスト(接続確立など) |
| 約10倍 | 比例 | 正常。1件あたりの処理を速くするか、件数を減らす |
| 100倍以上 | 急激 | 構造的な問題。総当たりや N+1問題 を疑う |
3行目が見つかったら最優先です。件数が増えるほど破綻が加速するため、いま我慢できていても近い将来に停止します。1件あたりの最適化ではなく、構造を変える必要があるという判断になります。
import time
def measure_growth(func, sizes): """件数を変えて実行し、時間の伸び方を出す。""" base = None for n in sizes: data = list(range(n)) start = time.perf_counter() func(data) elapsed = time.perf_counter() - start
if base is None: base = elapsed or 1e-9 ratio = 1.0 else: ratio = elapsed / base print(f" 件数 {n:6d} 時間 {elapsed:8.4f}秒 初回比 {ratio:8.1f}倍")
# 総当たり(件数の2乗で増える)def quadratic(data): return sum(1 for a in data for b in data if a == b)
measure_growth(quadratic, [100, 200, 400, 800])件数を2倍にして時間が4倍になるなら、2乗で増えています。このまま件数が10倍になれば100倍です。実測でこの形が出たら、アルゴリズムの見直しが必要だと判断できます。
5. 切り分けの順序
以上をまとめると、次の順で進めるのが効率的です。
- 1全体の内訳を出す — アプリ / DB / 外部 / ネットワークの割合
- 2CPUが高いか低いかを見る — 計算しているのか待っているのか
- 3回数を数える — 1回あたりではなく合計で見る
- 4件数との関係を確かめる — 比例か、それ以上か
- 5最大の割合を占める箇所だけを深掘る — 他は後回し
5番目を守れるかが分かれ目です。調べていると細かい非効率が次々と目につきますが、全体の1%を占める箇所を半分にしても、全体は0.5%しか改善しません。目についたものを直したくなる気持ちを抑えて、割合の大きい順に処理してください。
簡単に直せる非効率ほど目につきますが、効くかどうかは割合で決まります。直しやすさではなく、全体に占める割合で優先順位をつけてください。これは EP.1 の計測の話と一続きです。
6. 切り分けができる状態を保つ
最後に、切り分けを毎回ゼロから始めなくて済むようにする話です。障害のたびに手探りになるなら、それは切り分けの技術ではなく観測の設計の問題です。
は、外から観測できる情報だけで内部の状態を説明できる状態を指します。監視が「決めた異常を見張る」のに対し、想定していなかった問題も追えることを目指す考え方です。切り分けに毎回時間がかかるなら、ここに投資する価値があります。
具体的には、レイヤーごとの所要時間が常に記録されている状態を作ります。事後に「どこが遅かったのか」を問える形にしておけば、切り分けの前半をまるごと省略できます。
最初の分岐は計算しているか、待っているか。待ちなら回数を数える。件数との関係を見れば構造的な問題が分かる(比例を超えていたら最優先)。深掘るのは割合が最大の箇所だけ。次回から、最も当たりやすい候補である SQL に入ります。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。