Skip to content
Published on

平均は何も語らない — レイテンシを分布でデバッグする方法

シェア
Authors

はじめに — 平均は9%悪化したのに中央値は46%改善した

2026年7月27日、Farid ZakariaがThe mean means nothingという記事を公開し、2日後にはHacker Newsのスレッドで73ポイントを集めて、しばらくの間上位に居座りました。題材はありふれています。1週間かけてキャッシュ層を段階的にロールアウトしたところ、ダッシュボード上の平均レイテンシが112msから122msへと9%上昇しました。ロールバック会議が招集されてもおかしくない図です。

ところが同じ期間、同じリクエストログで中央値は99msから54msへと46%下がりました。p95は224msから454msへ、p99は309msから678msへと2倍以上に上がりました。キャッシュは成功すると同時に失敗しました。どちらの文も間違っていません。

先に断っておきたいことがあります。元記事のデータは実際の障害記録ではなく、公開されたスクリプトが固定シードで生成した合成データです。スクリプト冒頭のコメントにもそう明記されており、著者自身もチャート生成にAIの助けを借りたと書いています。したがってこの記事は「こういう障害があった」という事例報告ではなく、「こういう形のデータをどんなレンズで見るべきか」を教える教材として読むほうが正確です。そして教材としてはかなりよくできています。キャッシュのヒットとミスが混ざって生まれる二峰分布は、キャッシュを一つでも組み込んだシステムならほぼ必ず出会う形だからです。

この記事はその状況を出発点として、なぜ平均が失敗するのか、負荷生成器がどのように裾を消してしまうのか、そして代わりに何を描くべきかを整理します。

一つのデータ、四つの結論

元記事が示す要約統計はこうです。

統計量ロールアウト前ロールアウト後変化
平均112ms122ms+9%
p50 (中央値)99ms54ms−46%
p95224ms454ms+103%
p99309ms678ms+119%

四つの数字はそれぞれ違う会議を生みます。平均だけを見るチームはロールバックを決定し、中央値だけを見るチームは成功事例として発表し、p99だけを見るチームはインシデントを起こします。三つとも同じログを見ていました。

平均があいまいな値を出す理由は算術的には単純です。平均は、速くなった多数と遅くなった少数を同じ天秤に載せて相殺してしまいます。半分が45ms速くなり、5%が370ms遅くなれば、合計はほぼゼロに近づきます。相殺された結果の符号は、このシステムについて何も語ってくれません。この点で平均は、間違った指標というより問いを一つも含んでいない指標なのです。

なぜ平均は失敗するのか — 多峰分布

核心はここにあります。キャッシュ層がリクエストの母集団を二つに割りました。キャッシュにヒットしたリクエストはバックエンドを飛ばし、以前の基準線より速くなります。ミスしたリクエストは、キャッシュを照会する往復が一つ余分に付いてから、ようやく元の経路をたどります。そのため、ロールアウト前はひとつだった山が、ロールアウト後には二つになります。

ここで統計教育の古典がそのまま当てはまります。Hacker Newsのコメントで最も多く引用されたのも、Anscombeの四重奏(Anscombe's quartet)とその現代版であるSame Stats, Different Graphsでした。平均、分散、相関係数、回帰直線がすべて同じなのに、図はまったく異なるデータ集合を作れるというのが要点です。後者のデータセットのひとつは、散布図に描くと恐竜の形になります。

レイテンシデータで多峰性が生まれる原因は、たいてい決まっています。

  • キャッシュのヒットとミス
  • コールドスタートとウォームインスタンス
  • コネクションプールから即座に得た接続と、新規に結んだTLSハンドシェイク
  • リーダーリージョンとクロスリージョンフォールバック
  • 少量のレスポンスと、ページネーションなしでまるごと出て行く大量のレスポンス
  • GCの一時停止やコンパクションに巻き込まれたリクエスト

元記事の最終的な診断は、まさにこのリストの最後の項目のあたりでした。レスポンスサイズとレイテンシを一緒にプロットした散布図で、大きなレスポンスがキャッシュに収まりきらず、ミス側の山を作っていたのです。したがって処方も「ロールバック」ではなく「キャッシュ容量を増やすか、大きなレスポンスを分割する」になります。分布を分けた軸を見つけて、はじめて処方箋が書けます。

負荷生成器が消し去る裾 — coordinated omission

本番の指標ではなく負荷テストの結果を見ているなら、分布を描く前に確認すべきことがもう一つあります。測定そのものが裾を消してしまっている可能性です。

Gil Teneが名付けたのがcoordinated omissionです。その構造はこうです。負荷生成器が毎秒1,000件を送るよう設定されているとして、あるレスポンスが2秒かかったとします。同期式の生成器なら、その2秒間に送るはずだった2,000件を送りません。その2,000件はそれぞれ最大2秒待たされるはずだったリクエストですが、そもそも発射されていないのでヒストグラムには入りません。結果として、システムが最も遅かった区間のサンプルがまるごと消え、負荷生成器は自分が作り出したバックプレッシャーに「協調」してしまうのです。

この欠陥の症状には特徴があります。

  • 負荷を上げてもp99がほとんど動きません。実際にはキューが破裂しているのに、測定された裾は穏やかに見えます。
  • 報告されたスループットが設定した目標値より低いのに、レイテンシ分布は目標値で測定したかのような形をしています。
  • 同じシステムを本番トラフィックで観測すると、負荷テストよりはるかに悪い裾になります。

解決策は二つに分かれます。一つはwrk2のように、一定の目標スループットを維持しながら、予定発射時刻を基準にレイテンシを記録する生成器を使うことです。wrk2はリクエストごとのサンプルバッファをHdrHistogramに置き換え、応答レイテンシを「リクエストが送出されるべきだった時刻」から計算します。もう一つはHdrHistogramが提供する補正APIを使うことです。期待される間隔が分かっている場合に、欠落したサンプルを合成して埋めます。

// 期待間隔を1ms(=1,000,000ns)として記録すると、あるサンプルがその間隔を超えたとき
// 欠落していたはずの中間サンプルをヒストグラムが自分で埋めてくれる。
Histogram h = new Histogram(3600L * 1000 * 1000 * 1000, 3);
h.recordValueWithExpectedInterval(latencyNanos, 1_000_000L);

本番観測にはこの問題がありません。実際のユーザーは、前のリクエストが遅かったからといって次のリクエストを遅らせてくれたりはしないからです。まさにその理由で、負荷テストの結果と本番の指標が食い違うときは、たいてい負荷テストのほうを疑うべきです。

代わりに何を描くべきか — 五つのレンズ

元記事の実質的な価値は、同じ数字を複数の方法で描いて見せた部分にあります。それぞれのグラフが答える問いが違います。

密度プロット——ピークがいくつあるかに答えます。最初に描くべきグラフで、ピークが一つなら残りの分析の大部分は省略してかまいません。レイテンシは右側に長く裾を引くため、x軸は対数スケールが基本です。

CDFは「何%が何ms以内に終わったか」に答えます。変更前と変更後のCDFを同じ軸に重ねて描いたとき、もし二つの曲線が交差していれば、その地点が改善と悪化の境界です。元記事では140ms付近で交差しています。交差するCDFは、「どの単一の百分位数もこの変化を要約できない」ことの視覚的な証明です。Hacker Newsのコメントには一段階踏み込んだ提案もありました。CDFの代わりに1から引いた値(CCDF、つまりまだ終わっていないリクエストの割合)をlog-logで描くと、CDFでは1に張り付いた水平線に潰れてしまう裾が、全区間にわたって広がって見えます。裾を見るときはこちらのほうが優れています。

シフト関数は「どの百分位数までが得で、どこから損になるのか」に答えます。各百分位数pごとに、変更後の値から変更前の値を引いた値を描きます。元記事の曲線はp76付近まで負(改善)で、それを超えると急に正へと転じます。この一枚のグラフだけで、「ユーザーの76%は得をし、残りは損をした」という文章が作れます。SLOをどこに設定するかによって、デプロイの判断が分かれる地点です。

リッジラインプロットは「いつからこうなったのか」に答えます。日別の密度を上下に積み重ねると、ロールアウト比率が0%から100%へ上がっていく間に、二つ目の山が育っていく過程が見えます。リッジラインの実務的な価値は、回帰がデプロイと同時に来たのか、それとも以前から存在していたのかを見分ける点にあります。

ヒートマップはリッジラインと同じ情報を格子に押し込みます。x軸は時間、y軸はレイテンシバケット、色はそのバケットに落ちたトラフィック量です。リッジラインより情報密度が高く、何より常時ダッシュボードに掲げておけます。時間軸が数日以上に長くなると、リッジラインは重なって読めなくなりますが、ヒートマップは読み続けられます。

ここに元記事が最後に使った二つを加えれば、診断は完了です。条件付きCDF(キャッシュのヒット/ミスで分けてそれぞれ描くと、再び単峰になります)と散布図(レイテンシ対レスポンスサイズ)です。前者は「何が分布を分けたのか」を確認し、後者は「なぜそのグループなのか」を説明します。

診断のウォークスルー — 平均は横ばいなのに分布が割れているとき

元記事の事例は、平均が多少なりとも動いたからこそ、むしろ目立ちました。もっと厄介なのは、平均が完全に横ばいのまま分布だけが割れるケースです。半分が速くなった量と残りが遅くなった量がたまたま釣り合うとこうなり、この場合はアラームが一つも鳴りません。

手順はこう組みます。

  1. 密度またはヒートマップを先に見る。ピークがいくつあるか、いつから割れ始めたかを確認します。ここで単峰なら、2番には進まず直接6番(エンドポイント分解)に進みます。
  2. CDFを前後で重ねて描く。交差点を探します。交差しているなら、「平均・中央値・p99のどれか一つで報告する」という計画は捨てます。
  3. シフト関数で境界百分位数を特定する。SLOのしきい値がその境界のどちら側にあるかが、デプロイの判断を左右します。
  4. 分布を分けた軸を探す。候補を一つずつ当てはめて、条件付きで分けて描きます。キャッシュのヒットかミスか、エンドポイント、リージョン、インスタンス、クライアントバージョン、レスポンスサイズの区間。分けたときにそれぞれが単峰になる軸が正解です。
  5. その軸と他の変数の関係を見る。元記事ではレスポンスサイズでした。ここから処方が導かれます。
  6. エンドポイントごとに分解してもう一度見る。全体の指標はトラフィック加重平均なので、トラフィックが少なく遅いエンドポイントの回帰をまるごと隠してしまいます。

4番で候補となる軸が思い浮かばないときに使える手があります。遅い山に属するリクエストだけを選んでトレースをサンプリングすることです。分散トレーシングをすでに使っているなら、レイテンシ区間でフィルタしてスパン構成を比較するのが最も速い方法です。この部分はオブザーバビリティの三本柱とLLMワークロード編で扱ったトレースサンプリング戦略とつながっています。

そして4番をやる前に必ず確認すべきことがあります。ファンアウトのあるシステムでは、中央値のほうがp99よりもひどく嘘をつきます。一つのユーザーリクエストが100台のリーフサーバーに散らばり、全部が揃って初めて応答が出て行く構造では、各リーフのp99がユーザーレイテンシの中央値を支配します。Jeff DeanのThe Tail at Scaleが整理した通りであり、Hacker Newsのコメントでもこの指摘が出ていました。ファンアウトシステムでは「構成要素の裾」が「ユーザーの平均」になります。

本番運用でこれを実際に描くツール群

教材の図はplotnineで描かれていますが、本番で毎日見なければならない図は別のパイプラインに乗ります。

Prometheusのネイティブヒストグラムが、今のところ最も現実的な土台です。従来型のヒストグラムはバケット境界を事前に決めておく必要があり、境界を細かく刻むほど時系列が線形に増えていきます。ネイティブヒストグラムは指数スケールでバケットを自動配置し、このトレードオフをなくします。v3.8.0で正式機能になり、OpenTelemetryのexponential histogramとデータモデルが対応しているため、OTLPバックエンドへ渡す経路も開かれています。ただし有効化するにはスクレイププロトコルの折衝、ストレージ、ダッシュボードのクエリまで手を入れる箇所が複数あり、その実務はネイティブヒストグラムはstableになったのに、なぜまだ有効化できていないのか編に別途まとめてあります。

クエリはこんな形になります。

# 百分位数を一つだけ — この値だけを見ると、この記事で説明した罠にそのまま落ちる
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

# ヒートマップ用: バケットごとの増加率をそのまま出し、グラフ側で色にマッピングする
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))

# ネイティブヒストグラムなら le ラベルはない。シリーズ自体が分布そのものだ
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])))
histogram_fraction(0, 0.14, sum(rate(http_request_duration_seconds[5m])))

最後の行のhistogram_fractionが、この記事の文脈では特に有用です。「140ms以内に終わったリクエストの割合」を直接取り出せるので、CDFの特定の地点を時系列として張っておけます。交差点が分かっているなら、その一点をアラームにするほうがp99アラームよりずっと正確です。

Grafanaのヒートマップパネルは、上の2番目のクエリをそのまま受け取ります。フォーマットをHeatmapにし、y軸を対数スケールに変えるのが、事実上必須の設定です。y軸が線形のヒートマップは下側のバケットが全部一本の線に潰れてしまい、二峰性を見せられません。

カーネル側の分布が必要なら、bpftraceのhist()が最も安上がりです。アプリケーション計装なしで、特定のシステムコールや関数のレイテンシ分布をlog2バケットでそのまま出力します。

# ブロックI/O完了レイテンシ分布 (usec, log2バケット)
sudo bpftrace -e '
kprobe:blk_account_io_start { @s[arg0] = nsecs; }
kprobe:blk_account_io_done  /@s[arg0]/ {
  @us = hist((nsecs - @s[arg0]) / 1000); delete(@s[arg0]);
}'

eBPFで分布を採取するアプローチ全般は、eBPFがオブザーバビリティを飲み込んでいる編でさらに扱っています。

負荷テストなら、先に述べたwrk2系統か、HdrHistogramを内蔵して目標スループットを維持する生成器を使います。どのツールを使うにせよ、結果を見るときに確認すべきことは一つです。報告された実際のスループットが、設定した目標スループットと一致しているか。一致していなければ、その実行のレイテンシ分布は信頼できません。

おわりに — 要約統計量は仮説であって結論ではない

まとめると、こうなります。

  • 平均は、速くなった多数と遅くなった少数を相殺します。相殺された値の符号は、システムについて何も語りません。
  • 二つのCDFが交差するなら、どの単一の百分位数もその変化を要約できません。この場合は百分位数をもう一つ選ぶのではなく、シフト関数を描くべきです。
  • 分布が割れているなら、割った軸を見つけることが診断のすべてです。条件付きで分けたときにそれぞれが単峰になる軸が原因です。
  • 負荷テストで裾が不自然におとなしいなら、まずcoordinated omissionを疑います。目標スループットと実際のスループットの不一致がそのサインです。
  • ファンアウトシステムでは、構成要素の裾がユーザーの平均になります。

一つの数字だけを見てロールバックを決める前に、一枚のグラフを描いてみるコストは、すでにヒストグラムを収集しているならクエリ一行分です。問題は道具ではなく、習慣のほうにあります。

参考資料