- はじめに — 平均は80msなのに、なぜ遅いという苦情が来るのか
- 平均が隠すもの — ロングテール分布の算術
- p50・p95・p99・p99.9がそれぞれ答える問い
- p99は「100人に1人」ではない — レイテンシの掛け算効果
- パーセンタイルは平均できない — だからヒストグラムが必要になる
- Prometheusのヒストグラム — バケット境界と補間誤差
- SLOをパーセンタイルで定義するときの実務基準
- おわりに — ひとつの数字ではなく分布を見よ
はじめに — 平均は80msなのに、なぜ遅いという苦情が来るのか
ダッシュボードの平均応答時間は80msです。この一か月グラフは平らで、アラートも鳴っていません。ところがカスタマーサポートには「決済画面が数秒ずつ止まる」という問い合わせが毎日届いています。
この二つの事実は矛盾していません。平均とはもともとそのように振る舞う指標だからです。平均は分布の形をひとつの数字に潰しながら、ちょうどユーザーが怒っている区間の情報を真っ先に捨てます。
この記事はその区間を取り出し直す方法を扱います。パーセンタイルが何に答えるのか、なぜパーセンタイル同士を平均できないのか、そしてPrometheusのヒストグラムが実際にどんな数字を返すのかまで、自分で計算できるレベルまで下りていきます。
平均が隠すもの — ロングテール分布の算術
応答時間の分布は正規分布ではありません。左側には壁があり(0msより速くはなれません)、右側に長く伸びます。GC停止、コールドキャッシュ、コネクションプール待ち、リトライ、うるさい隣人 — 遅くなる方法はたくさんあり、速くなる方法はありません。
数字で見てみます。10,000件のリクエストのうち9,900件が50msで終わり、100件が3,000msかかったとします。
# アクセスログの応答時間(ms)カラムだけを取り出して分布を直接計算する
awk '{print $NF}' access.log | sort -n > sorted.txt
wc -l < sorted.txt
# 10000
awk '{s+=$1} END {printf "mean %.1f\n", s/NR}' sorted.txt
# mean 79.5
for n in 5000 9500 9900 9990; do
printf "n=%s\t%s\n" "$n" "$(awk -v r=$n 'NR==r' sorted.txt)"
done
# n=5000 50 <- p50
# n=9500 50 <- p95
# n=9900 50 <- p99 (境界にまたがっている)
# n=9990 3000 <- p99.9
平均は79.5msです。実際に79.5msで応答を受け取ったリクエストは一件もありません。平均は存在しないユーザーを描写しています。
さらに悪いのは感度です。遅い100件が3,000msから6,000msへと二倍悪化しても、平均は79.5msから109.5msに動くだけです。30msの上昇はどんなアラートのしきい値も超えません。一方でその100件に当たったユーザーには、サービスが完全に壊れたように見えます。平均はテールが悪化することをほとんど検知できません。
p50・p95・p99・p99.9がそれぞれ答える問い
パーセンタイルごとに答える問いが違います。ひとつだけを見るのは、ひとつも見ないことより悪いです。
| 指標 | 答える問い | 代表的な誤解 |
|---|---|---|
| 平均 | 総処理時間をリクエスト数で割るといくらか | 典型的なユーザー体験を表している |
| p50 | リクエストの半分はこの時間内に終わる | ほとんどのリクエストがこの程度だ |
| p95 | 二十回に一回味わう遅さの大きさ | 無視してよい外れ値だ |
| p99 | 百回に一回味わう遅さの大きさ | ユーザーの1%だけが経験する |
| p99.9 | インフラ異常とGC停止が表面化する区間 | トラフィックが少なくても意味がある |
実務的な読み方はこうです。
- p50が悪化したらシステム全体が遅くなったということです。容量不足や共通依存の問題を疑います。
- p50はそのままでp99だけが悪化したら、特定の条件でのみ発生する問題です。特定のテナント、特定のクエリ経路、特定のノード、ロック競合、GCを見ます。
- p99とp99.9の間隔が広がったら、まれだが非常に高価な経路ができたということです。リトライストームやタイムアウト設定を確認します。
- p50とp99がほぼ同じなら分布が圧縮されています。良い兆候である可能性もありますが、手前でタイムアウトによって切られている可能性も併せて見ます。
p99.9はトラフィック量が支えてくれるときにだけ意味を持ちます。5分に1,000件が入るサービスでのp99.9はたった一件のリクエストです。一件だけの信号にアラートを掛ければ、アラートは乱数発生器になります。
p99は「100人に1人」ではない — レイテンシの掛け算効果
もっとも多く、もっとも高くつく誤解です。p99が2秒だという言葉は「ユーザーの1%が2秒を経験する」ではなく「リクエスト100個のうち1個が2秒を経験する」という意味です。一人のユーザーが複数のリクエストを生むなら、体感確率はリクエスト数の分だけ掛け算されます。
// ひとつの画面がN個のバックエンド呼び出しをするとき
// 「少なくともひとつがp99を超える」確率
const p = 0.99
for (const n of [1, 5, 20, 50, 200]) {
const hit = 1 - Math.pow(p, n)
console.log(`${String(n).padStart(3)}回の呼び出し → ${(hit * 100).toFixed(1)}%`)
}
// 1回の呼び出し → 1.0%
// 5回の呼び出し → 4.9%
// 20回の呼び出し → 18.2%
// 50回の呼び出し → 39.5%
// 200回の呼び出し → 86.6%
ダッシュボードのひとつの画面が20個のAPIを呼ぶ構造なら、画面を一度開くときにp99レイテンシに出会う確率は18パーセントです。一日に200回リクエストを生む活発なユーザーなら、87パーセントが一日に一度以上は最悪の区間を経験します。p99は少数の不運なユーザーではなく、ほとんどすべてのユーザーの日常です。
ここから二つの結論が出ます。第一に、ファンアウトが大きいアーキテクチャほどテールレイテンシの管理が重要です。サービスひとつのp99を改善するほうが、画面全体のp50を改善するより効果が大きいです。第二に、クライアント側の指標を別に見る必要があります。ユーザーが実際に経験する値はサーバーのp99ではなく、画面が完成した時点のパーセンタイルです。
パーセンタイルは平均できない — だからヒストグラムが必要になる
この節がこの記事でもっとも実務的に重要な部分です。合計と個数は足せますが、パーセンタイルは足すことも平均することもできません。
インスタンス二台を例に挙げます。
- サーバーA: リクエスト9,900件、すべて50ms → p99(A) = 50ms
- サーバーB: リクエスト100件、すべて3,000ms → p99(B) = 3,000ms
二つのp99を平均すると1,525msです。リクエスト数で加重平均すると79.5msです。ところが両サーバーのリクエスト10,000件を一列に並べて計算した本当のp99は3,000msです。三つの数字はすべて違い、前の二つには何の意味もありません。
ですから次のPromQLは間違いです。
# 間違い: インスタンスごとのp99をそれぞれ求めてから平均する
avg(
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
正しい順序は、バケットカウンターを先に合算し、合算された分布からパーセンタイルを計算することです。カウンターは集計可能で、パーセンタイルは集計不可能だからです。
# 正しい: leラベル基準でrateを先に合算してからパーセンタイルを計算する
histogram_quantile(
0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
サービスごとに分けて見たいなら、sum by句にleと一緒にそのラベルを入れます。leは絶対に抜けてはいけません。
# ルートごとのp99 — le と route を一緒に保持する
histogram_quantile(
0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
同じ理由で、Prometheusのsummaryタイプはインスタンスの中ですでにパーセンタイルを計算して出力するため、複数のインスタンスや複数のルートを合算する方法がありません。単一プロセスのローカル診断には使えますが、サービスレベルの指標としては使えません。ヒストグラムは精度をバケット解像度の分だけ諦める代わりに、集計可能性を得ます。分散システムではほとんどの場合、後者が正しい取引です。
Prometheusのヒストグラム — バケット境界と補間誤差
histogram_quantileが返す値は観測された実際の値ではありません。バケット境界の間を直線で結んだ推定値です。
curl -s localhost:8080/metrics | grep '^http_request_duration_seconds_bucket'
# http_request_duration_seconds_bucket{le="0.005"} 0
# http_request_duration_seconds_bucket{le="0.01"} 0
# http_request_duration_seconds_bucket{le="0.025"} 12
# http_request_duration_seconds_bucket{le="0.05"} 4103
# http_request_duration_seconds_bucket{le="0.1"} 8890
# http_request_duration_seconds_bucket{le="0.25"} 9604
# http_request_duration_seconds_bucket{le="0.5"} 9740
# http_request_duration_seconds_bucket{le="1"} 9812
# http_request_duration_seconds_bucket{le="2.5"} 9993
# http_request_duration_seconds_bucket{le="5"} 10000
# http_request_duration_seconds_bucket{le="+Inf"} 10000
p99は9,900番目の観測値です。この値はle=1バケット(累積9,812)とle=2.5バケット(累積9,993)の間にあります。線形補間はこう計算します。
python3 - <<'PY'
lo, hi = 1.0, 2.5
c_lo, c_hi = 9812, 9993
target = 0.99 * 10000
est = lo + (target - c_lo) / (c_hi - c_lo) * (hi - lo)
print(f"p99 estimate = {est:.3f}s")
PY
# p99 estimate = 1.729s
1.729秒という精密に見える数字が出てきますが、このバケットの中の181件が実際どこにあるのかは誰も知りません。全部1.05秒かもしれませんし、全部2.4秒かもしれません。バケットが広ければ、histogram_quantileの出力は小数点第三位まで出てくる当て推量です。
ここから出てくる実務ルールが三つあります。
第一に、SLOのしきい値とまったく同じバケット境界を必ず入れます。300msを基準にするつもりなら、le=0.3バケットがなければなりません。境界があれば補間なしで正確な比率を数えられます。
第二に、関心のある区間にはバケットを細かく置きます。デフォルトのバケットはウェブAPI基準で0.1秒より上が粗すぎます。
// prom-client — サービスの実際の分布に合わせて境界を自分で決める
const { Histogram } = require('prom-client')
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTPリクエストの処理時間',
labelNames: ['method', 'route', 'code'],
// SLOのしきい値 0.3 を境界として含め、100ms~1s の区間を細かく置く
buckets: [0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.6, 0.8, 1, 2, 5, 10],
})
第三に、最上段の有限バケットを実際のタイムアウトより大きく取ります。p99が最後の有限境界を越えると、histogram_quantileはその境界値そのものを返すか(実装によります)上限に張り付いてしまい、どれだけ悪いのか分からなくなります。
バケット境界を自分で選ぶこと自体が負担なら、Prometheusのネイティブヒストグラムを検討する価値があります。指数間隔のバケットを自動で作ってくれるので、境界選択の問題と解像度の問題をかなりの部分なくしてくれます。ただし保存フォーマットとクエリ経路、リモートストレージの互換性がクラシックヒストグラムと異なるため、スタック全体の対応状況を先に確認する必要があります。
SLOをパーセンタイルで定義するときの実務基準
ここで最後のどんでん返しがあります。レイテンシSLOを「p99が300ms以下」と定義するのは、実務では良い選択ではありません。
理由は前に全部出ています。パーセンタイルは集計できず、時間軸方向にも合算できません。5分窓のp99を30日分集めても30日のp99にはなりません。エラーバジェットを計算することも、バーンレートを求めることもできません。
代わりに同じ内容を比率として表現します。「リクエストの99%が300ms以内に完了する」という文は、しきい値を超えたリクエストの比率として直接計算され、この比率は足せます。
# レイテンシSLI: 300ms以内に完了したリクエストの比率
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[30d]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[30d]))
# そのままエラーバジェットの消費率につながる (目標99%、つまり予算1%)
(
1 -
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[1h]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[1h]))
) / 0.01
この形の長所は三つです。補間誤差がありません(le=0.3バケットをそのまま数えるからです)。どんな時間窓でも計算し直せます。そしてバーンレートアラートにそのまま差し込めます。
基準を決めるときに参考になる実務値も整理しておきます。
- 目標パーセンタイルはユーザージャーニーのファンアウトを考慮して決めます。ひとつの画面が20個を呼ぶなら、サービスひとつの目標は99%ではなく99.9%であってはじめて画面レベルで98%になります。
- しきい値は人間の知覚限界から逆算します。即座の反応と感じる100ms、流れが途切れない1秒、注意が離れる10秒という古い基準線は今も有効です。
- 測定窓は28日または30日のローリングにし、デプロイ周期より長く取ります。窓が短いとデプロイ一回のミスがそのまま予算を焼き尽くします。
- パーセンタイルのダッシュボードは見続けます。SLOは比率で管理しつつ、原因調査はp50とp99、p99.9を重ねて見るグラフから始めます。
おわりに — ひとつの数字ではなく分布を見よ
覚えておくべきことは一文です。平均は分布をひとつの数字に潰しながらユーザーが怒っている部分を真っ先に捨て、パーセンタイルはその部分を見せてくれますが決して合算し直せません。
だから実務の順序はこうなります。ヒストグラムで元の分布を保存し、バケット境界にSLOのしきい値を埋め込み、パーセンタイルは人間が見るダッシュボードでのみ使い、アラートとSLOは集計可能な比率で定義します。平均応答時間のグラフは消してかまいません。その場所にp50とp99を並べて置くだけで、ほとんどのチームはすでに見えていなかった事故を見始めます。
さらに掘り下げるための資料です。
현재 단락 (1/111)
ダッシュボードの平均応答時間は80msです。この一か月グラフは平らで、アラートも鳴っていません。ところがカスタマーサポートには「決済画面が数秒ずつ止まる」という問い合わせが毎日届いています。