メトリクスとヒストグラム
実装:
observability/metrics// 実行:go test ./observability/metrics/
システムの健康は数字で見るが、増える一方の総数・上下する同時接続数・ばらつく応答時間では、数字の持ち方が変わる。中でも応答時間を平均で見ると、速い大多数に引っ張られて遅い少数が隠れるのに、ユーザが怒るのはその遅い方になる。この章では数え方を3つの型として実装し、値を区間ごとに数えるだけで「下から99%目の遅さ」を推定でき、それを台をまたいで足し合わせられることを確かめる。
この章で作るもの
動いているシステムの中で何が起きているかは、外からは見えない。見えるようにするのが計装(instrumentation)で、その出力がメトリクスだ。だが「何を」数えるかで型が変わる。処理したリクエストの総数は増える一方だ。今この瞬間の同時接続数は上がったり下がったりする。応答時間は、速いのも遅いのも混ざった分布になる。この 3 つはそれぞれ別の型で持つ。カウンタ、ゲージ、ヒストグラムだ。
この章の主眼はヒストグラムにある。応答時間を 1 つの数字にまとめるなら、まず平均を考える。だが平均は危ない。1000 件のうち 980 件が 4ms で返り、20 件だけ 800ms かかったとする。平均は約 20ms だ。ダッシュボードの平均は健康に見える。だが 50 人に 1 人は 800ms 待たされている。その人たちが怒る。平均は速い大多数に引っ張られて、遅い少数(テールレイテンシ)を覆い隠す。
件数
│ ██ ← 大多数はここ(速い)
│ ██ 平均≈20ms
│ ██ ┊ ┊ p99≈数百ms
│ ██ ┊ ▁ ┊ ← 少数だが、ここが遅い
└─██──┊───────────────█───────┊──▶ 応答時間
4ms 20ms 800ms順に作る。
- 型を使い分ける: 増える一方はカウンタ、上下はゲージ、分布はヒストグラム。数える対象の性質で決まる
- 平均でなく分位点: p99(下から 99% 目)を見る。値を区間(バケット)ごとに数えて、生の値を保存せずに分位点を推定する
- p99 は合算する、平均しない: 各台の p99 を平均しても全体の p99 にならない。バケットを足してから分位点を出す
① カウンタとゲージ
まず素朴な 2 つ。カウンタは増える一方の値。処理総数やエラー総数のように、時間とともに単調増加する。減らせないのが本質で、減らせてしまうと「これまでの総数」という意味が壊れる:
// Counter は増える一方の値(処理総数、エラー総数など)。減らせない。
type Counter struct{ v float64 }
// Inc は 1 増やす。
func (c *Counter) Inc() { c.v++ }
// Add は d(非負)を足す。負を渡すとパニック(カウンタは減らせない)。
func (c *Counter) Add(d float64) {
if d < 0 {
panic("metrics: counter cannot decrease")
}
c.v += d
}
// Value は現在値を返す。
func (c *Counter) Value() float64 { return c.v }
// Gauge は上下する値(同時接続数、キュー長、メモリ使用量など)。
type Gauge struct{ v float64 }
// Set は値を x にする。
func (g *Gauge) Set(x float64) { g.v = x }
// Add は d 足す(負でもよい)。
func (g *Gauge) Add(d float64) { g.v += d }
// Sub は d 引く。
func (g *Gauge) Sub(d float64) { g.v -= d }
// Value は現在値を返す。
func (g *Gauge) Value() float64 { return g.v }Add に負の値を渡すとパニックさせている。これは厳しすぎるように見えて、実は大事な設計だ。カウンタが減るのはカウンタの誤用で、そこはゲージであるべきだ。ゲージは上下する値。同時接続数、キュー長、メモリ使用量。増えも減りもする。カウンタとゲージを型で分けておくと、監視側は「カウンタは秒間の増加率(レート)を見る」「ゲージは今の値そのものを見る」と扱いを変えられる。
② ヒストグラム: 分布をバケットに数える
応答時間の全体像を保存したい。だが全リクエストの生の値をすべて残すのは高くつく。そこでバケットに数える。上限の並びを決めておき、観測値がどのバケットに入るかを数え上げる:
// Histogram は値の分布を、上限の決まったバケットに数える(Prometheus 方式)。
// bounds は各バケットの上限(昇順)。末尾に暗黙の +Inf バケットが 1 つ付く。
type Histogram struct {
bounds []float64 // バケットの上限(昇順)
counts []uint64 // 各バケットの個数。len == len(bounds)+1(末尾は +Inf)
sum float64 // 全観測値の合計(平均のため)
total uint64 // 全観測数
}
// NewHistogram は上限の並びからヒストグラムを作る。
// 例: {10, 50, 100, 500} なら (-∞,10] (10,50] (50,100] (100,500] (500,+∞) の 5 バケット。
func NewHistogram(bounds []float64) *Histogram {
b := make([]float64, len(bounds))
copy(b, bounds)
return &Histogram{bounds: b, counts: make([]uint64, len(bounds)+1)}
}
// Observe は値 x を該当バケットに 1 つ数える。
func (h *Histogram) Observe(x float64) {
i := 0
for i < len(h.bounds) && x > h.bounds[i] {
i++ // x が上限を超える間だけ次のバケットへ
}
h.counts[i]++
h.sum += x
h.total++
}
// Count は全観測数を返す。
func (h *Histogram) Count() uint64 { return h.total }
// Sum は全観測値の合計を返す。
func (h *Histogram) Sum() float64 { return h.sum }
// Mean は平均を返す(観測なしは 0)。
func (h *Histogram) Mean() float64 {
if h.total == 0 {
return 0
}
return h.sum / float64(h.total)
}
// Buckets は (上限, 累積個数) の並びを返す(観測用。末尾 +Inf は上限を返さない)。
func (h *Histogram) Buckets() ([]float64, []uint64) {
return h.bounds, h.counts
}{10, 50, 100, 500} という上限なら、(-∞,10] (10,50] (50,100] (100,500] (500,+∞) の 5 つのバケットに数える。末尾の +Inf バケットは「上限を超えた全部」を受ける。保存するのはバケットごとの個数と、合計・総数だけだ。生の値は捨てる。1000 万件観測しても、メモリはバケットの数ぶんしか要らない。
③ 分位点: バケットから p99 を推定する
バケットの個数から p99 を出す。厳密な値は生データが無いので分からないが、バケット内を線形補間して推定する。下から 99% 目の順位がどのバケットにあるかを探し、そのバケットの下限と上限の間で位置を按分する:
// Quantile は分位点 q(0..1)を、バケット内を線形補間して推定する。
// 生の値を保存しないので厳密値ではないが、バケットが細かいほど誤差は小さい。
// 例: q=0.99 なら「下から 99% 目」= p99。
func (h *Histogram) Quantile(q float64) float64 {
if h.total == 0 {
return 0
}
if q <= 0 {
return 0
}
if q >= 1 {
q = 1
}
// 下から rank 番目の値を探す。
rank := q * float64(h.total)
var cum uint64
for i := 0; i < len(h.counts); i++ {
next := cum + h.counts[i]
if float64(next) >= rank {
// このバケットに rank 番目がある。
if i == len(h.bounds) {
// +Inf バケット。上限が無いので最大の有限上限で頭打ち。
return h.bounds[len(h.bounds)-1]
}
lower := 0.0
if i > 0 {
lower = h.bounds[i-1]
}
upper := h.bounds[i]
// バケット内で rank の位置を線形補間。
inBucket := rank - float64(cum)
frac := inBucket / float64(h.counts[i])
return lower + (upper-lower)*frac
}
cum = next
}
return h.bounds[len(h.bounds)-1]
}
// Merge は同じ bounds を持つ別のヒストグラムをバケットごとに足し込む。
// これがヒストグラムの肝。各マシンのバケットを足すだけで全台の分布になり、
// そこから全台の p99 を正しく出せる(各台の p99 を平均しても全体の p99 にならない)。
func (h *Histogram) Merge(o *Histogram) {
if len(h.counts) != len(o.counts) {
panic("metrics: histogram bounds mismatch")
}
for i := range h.counts {
h.counts[i] += o.counts[i]
}
h.sum += o.sum
h.total += o.total
}Merge がヒストグラムの核心だ。サーバが 100 台あって、各台がヒストグラムを持つ。全台の p99 を知りたい。ここで各台の p99 を平均してはいけない。p99 は非線形な統計量で、平均や合計と違って足したり割ったりできない。台 A の p99 が 100ms、台 B の p99 が 100ms でも、全台の p99 は 100ms とは限らない。正しいのは、各台のバケットを足し合わせて(同じ上限なら足せる)、合算後のヒストグラムから p99 を出すことだ。バケットが加算的だから、分位点を後から正しく計算できる。これが「生データを捨ててもいい」理由でもある。
テストでこれを固定した。980 件が 4ms、20 件が 800ms のとき、平均は約 20ms だが p99 は数百 ms になる。平均だけ見ているとテールを見逃す。また速い台と遅い台のヒストグラムを Merge すると、合算後の中央値は速い側、p99 は遅い側から正しく出る。
動かす
下のデモは応答時間の分布を作り、平均と p50 / p90 / p99 がどこに来るかを見る。遅いリクエストの割合を変えると、平均はあまり動かないのに p99 が跳ね上がる。テールが平均に埋もれる様子を確かめてほしい。
遅いのは 2% だけ。平均は 17.5ms で大きく動かないのに、p99 は 615ms(平均の 35 倍)。少数の遅さが平均に埋もれ、p99 にだけ現れる
1000 件の応答時間をバケットに数えた分布。速いリクエスト(左の山)と遅いリクエスト(右端)。 遅い割合を上げると、平均はほとんど動かないのに p99 が跳ね上がる。ユーザが体感するのは 遅い方なので、応答時間は平均でなく p99 のような分位点で見る。分位点は各台のバケットを 合算してから出す(各台の p99 を平均しても全体の p99 にはならない)。
設計の観点
- 平均かパーセンタイルか: 応答時間の監視は必ずパーセンタイル(p50/p90/p99)で見る。平均と最大の間に本当の姿がある。SLO も「p99 < 300ms」のように分位点で書く
- バケット境界の設計: バケットが粗いと分位点の誤差が大きい。応答時間なら 5,10,25,50,100,250,500,1000ms のように対数的に取り、関心のある帯域を細かくする
- カウンタは率で見る: カウンタの生値でなく増加率(rate)を見る。秒間リクエスト数やエラー率は、2 時点のカウンタの差を時間で割って出す
- 分位点は合算不能: 各台の p99 を平均・最大しても全体の p99 にならない。バケットを合算してから分位点を出すのが唯一正しい。これはヒストグラムを選ぶ最大の理由
- カーディナリティ: ラベル(method, path, status)の組み合わせが増えるとメトリクス系列が急激に増え、監視基盤を圧迫する。ラベルの次元は絞る
対照と実例
| 型 | 数える対象 | 例 | 見方 |
|---|---|---|---|
| カウンタ | 増える一方 | 総リクエスト数、総エラー数 | 増加率(rate) |
| ゲージ | 上下する | 同時接続数、キュー長、メモリ | 今の値 |
| ヒストグラム | 値の分布 | 応答時間、レスポンスサイズ | 分位点(p50/p99) |
| サマリ | 分布(クライアント側で分位点計算) | 応答時間 | 分位点(合算不可) |
裏どり:
- Prometheus Histogram: この章のバケット方式の元。累積バケットと
histogram_quantileによる分位点推定。バケットが加算的で合算できるのが特徴 - Summary vs Histogram: Prometheus のサマリはクライアント側で分位点を計算するため、複数インスタンス間で合算できない。ヒストグラムはバケットを足せる。だからマルチインスタンスではヒストグラム推奨
- Google SRE Book(Four Golden Signals): レイテンシ・トラフィック・エラー・飽和度。レイテンシは平均でなく分布で見よと説く
- HDR Histogram: 広いレンジで高精度な分位点を出すヒストグラム実装。レイテンシ計測の定番
簡略化したこと
- 並行安全でない: 実物は atomic やロックで保護する。ここでは単一ゴルーチン前提
- ラベルなし: Prometheus の次元(ラベル)は持たない
- 固定バケット: 動的にバケットを変える指数バケット等は扱わない
- エクスポート形式なし: /metrics のテキスト出力やスクレイプは範囲外
参考資料
- Prometheus: Histograms and Summaries — バケット方式と分位点推定、合算の注意点
- Prometheus: Metric Types — カウンタ/ゲージ/ヒストグラム/サマリ
- Google, Site Reliability Engineering — Four Golden Signals とレイテンシ分布
- 実装: observability/metrics