Skip to content

メトリクスとヒストグラム

実装: 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)を反映しない

順に作る。

  1. 型を使い分ける: 増える一方はカウンタ、上下はゲージ、分布はヒストグラム。数える対象の性質で決まる
  2. 平均でなく分位点: p99(下から 99% 目)を見る。値を区間(バケット)ごとに数えて、生の値を保存せずに分位点を推定する
  3. p99 は合算する、平均しない: 各台の p99 を平均しても全体の p99 にならない。バケットを足してから分位点を出す

① カウンタとゲージ

まず素朴な 2 つ。カウンタは増える一方の値。処理総数やエラー総数のように、時間とともに単調増加する。減らせないのが本質で、減らせてしまうと「これまでの総数」という意味が壊れる:

go

// 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 に負の値を渡すとパニックさせている。これは厳しすぎるように見えて、実は大事な設計だ。カウンタが減るのはカウンタの誤用で、そこはゲージであるべきだ。ゲージは上下する値。同時接続数、キュー長、メモリ使用量。増えも減りもする。カウンタとゲージを型で分けておくと、監視側は「カウンタは秒間の増加率(レート)を見る」「ゲージは今の値そのものを見る」と扱いを変えられる。

② ヒストグラム: 分布をバケットに数える

応答時間の全体像を保存したい。だが全リクエストの生の値をすべて残すのは高くつく。そこでバケットに数える。上限の並びを決めておき、観測値がどのバケットに入るかを数え上げる:

go

// 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% 目の順位がどのバケットにあるかを探し、そのバケットの下限と上限の間で位置を按分する:

go

// 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 が跳ね上がる。テールが平均に埋もれる様子を確かめてほしい。

デモメトリクスとヒストグラムp99 615ms
遅い 0%遅い 2%遅い 5%遅い 10%
122≤2
366≤5
490≤10
≤25
≤50
≤100
≤250
9≤500
13≤1000
+∞
応答時間(ms)のバケット →
平均17.5ms
p505.1ms
p909.2ms
p99615ms

遅いのは 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 のテキスト出力やスクレイプは範囲外

参考資料