《Go 语言编程实战》10.2 指标与 SLO

给 TaskHub 接上 Prometheus 指标:用 client_golang 定义计数器、延迟直方图与在途请求仪表,通过中间件打点并暴露 /metrics;再用直方图分位算 P95,用 Go 真跑一遍错误预算与燃尽率,最后给出 99.9% 可用性目标下的多窗口告警阈值与基数控制清单。

本节把 TaskHub 推进到「能回答现在好不好」:日志只能事后查,指标才能实时看。我们给每个接口接上计数器与延迟直方图,暴露 /metrics 给 Prometheus 抓取,并给「任务列表」接口定下 99.9% 的可用性 SLO 与对应的燃尽率告警。
适用版本:Go 1.27(实测 go1.27.0)+ github.com/prometheus/client_golang v1.25.0。

10.2 指标与 SLO

上一节的日志解决「出了事怎么查」。但线上最要紧的问题是**「现在到底好不好,要不要叫人起床」**。日志是逐条事件的流水,指标是聚合后的数字——前者贵、细、慢,后者便宜、粗、快。一个成熟的服务两者都要,但分工明确:指标负责报警,日志负责归因。

10.2.1 四类指标,先分清用途

Prometheus 生态里指标只有四种类型,选错类型等于白搭:

类型语义TaskHub 里的例子
Counter只增不减的累计值http_requests_total、tasks_created_total
Gauge可增可减的瞬时值inflight_requests、db_pool_connections
Histogram分桶计数的分布http_request_duration_seconds
Summary客户端算好的分位少用(无法跨实例聚合)

延迟一定要用 Histogram,不要用 Summary。Summary 在客户端算分位,多个实例的分位无法相加(P95 的平均不是 P95),而 Prometheus 的 histogram_quantile 可以在服务端对分桶求和后再算分位,天然支持多副本聚合。

10.2.2 命名与标签的三个约定

指标名是接口契约的一部分,改名等于破坏监控。三条约定:

  1. 名字带单位后缀:_seconds、_bytes、_total。看到 http_request_duration 你永远不确定是秒还是毫秒。
  2. Counter 以 _total 结尾:Prometheus 的查询习惯依赖这个后缀。
  3. 标签只放低基数维度:route、method、status。绝不放 user_id、task_id。

10.2.3 定义 TaskHub 的指标

package obs

import "github.com/prometheus/client_golang/prometheus"

var (
	httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "taskhub_http_requests_total",
		Help: "Total HTTP requests by route, method and status.",
	}, []string{"route", "method", "status"})

	httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Name:    "taskhub_http_request_duration_seconds",
		Help:    "HTTP request latency in seconds.",
		Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5},
	}, []string{"route"})

	inflight = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "taskhub_inflight_requests",
		Help: "Requests currently being served.",
	})
)

func init() {
	prometheus.MustRegister(httpRequests, httpDuration, inflight)
}

桶的选法有讲究:桶的边界要覆盖你的 SLO 阈值,并且阈值附近要密。如果 SLO 是「P95 < 100ms」,那 0.05 和 0.1 两个桶必须有,否则 histogram_quantile 只能线性插值,误差大到没法判断达标与否。上面这组桶在 5ms 到 100ms 之间放了五档,正是为了看清 P95/P99。

10.2.4 中间件里打点

打点写在中间件,业务代码零侵入:

func withMetrics(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		inflight.Inc()
		defer inflight.Dec()
		start := time.Now()

		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)

		httpDuration.WithLabelValues(r.URL.Path).Observe(time.Since(start).Seconds())
		httpRequests.WithLabelValues(r.URL.Path, r.Method, strconv.Itoa(rec.status)).Inc()
	})
}

// statusRecorder 记住状态码,因为 http.ResponseWriter 不暴露它
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

statusRecorder 是 Go 里一个绕不开的小工具:标准库的 http.ResponseWriter 不告诉你写出的状态码是多少,只能自己包一层记下来。注意别把 r.URL.Path 当 route 用——路径里带 ID 会炸基数(见 10.2.9)。

10.2.5 本机实测:300 次请求后的 /metrics

用 promhttp.Handler() 挂上 /metrics,写一个 5–40ms 随机延迟、2% 概率返回 500 的 /tasks,然后打 299 次请求,抓取结果:

$ curl -s http://127.0.0.1:18092/metrics | grep ^taskhub_http_requests_total
taskhub_http_requests_total{method="GET",route="/tasks",status="200"} 296
taskhub_http_requests_total{method="GET",route="/tasks",status="500"} 3

$ curl -s http://127.0.0.1:18092/metrics | grep ^taskhub_http_request_duration_seconds
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="0.005"} 0
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="0.01"} 37
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="0.025"} 161
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="0.05"} 299
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="0.1"} 299
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="0.25"} 299
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="0.5"} 299
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="1"} 299
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="2.5"} 299
taskhub_http_request_duration_seconds_bucket{route="/tasks",le="+Inf"} 299
taskhub_http_request_duration_seconds_sum{route="/tasks"} 7.131285982000004
taskhub_http_request_duration_seconds_count{route="/tasks"} 299

怎么读这堆桶:le="0.025" 处的累计计数是 161,le="0.05" 处是 299。也就是说 299 次请求里有 161 次(约 54%)在 25ms 内完成,其余全部落在 25–50ms 之间——这正是随机延迟 5–40ms 的分布。总耗时 _sum 是 7.13 秒,除以 299 得平均延迟约 23.9ms,与分布吻合。

注意 _count 是 299 而计数器是 296+3=299,两个口径一致,这本身就是一次对账。

10.2.6 从直方图算 P95:PromQL

服务端不给你现成的 P95,要用 histogram_quantile 从桶里插值:

# 5 分钟窗口内 /tasks 的 P95 延迟(秒)
histogram_quantile(
  0.95,
  sum by (le) (rate(taskhub_http_request_duration_seconds_bucket{route="/tasks"}[5m]))
)

三个容易写错的点:

  • 必须 sum by (le):把各副本的桶先加起来,再算分位,否则每个副本各出一个 P95,没法合并。
  • 必须套 rate(...[5m]):桶是累计值,直接喂给 histogram_quantile 得到的是「从进程启动至今」的分位,不是当前窗口的。
  • le 是唯一的保留标签:sum by 里除了 le 别留别的标签,否则插值会串。

10.2.7 SLO 与错误预算:用 Go 真算一遍

SLO(Service Level Objective)是把「好不好」变成可判定的数字。给 /tasks 定:30 天窗口,可用性 99.9%。这个数字直接决定错误预算(error budget)——你被允许失败多少:

package main

import "fmt"

func main() {
	const (
		sloTarget  = 0.999 // 30 天可用性目标 99.9%
		windowDays = 30
		windowMins = windowDays * 24 * 60
		totalReq   = 299 // 本机实测样本
		failedReq  = 3
	)
	budget := 1 - sloTarget
	fmt.Printf("SLO=%.3f%%  窗口=%d 天  错误预算=%.4f%%\n", sloTarget*100, windowDays, budget*100)
	fmt.Printf("允许失败请求数 = %.0f 次 / %d 分钟\n", budget*float64(windowMins), windowMins)

	errRate := float64(failedReq) / float64(totalReq)
	burn := errRate / budget
	fmt.Printf("实测错误率 = %d/%d = %.4f%%\n", failedReq, totalReq, errRate*100)
	fmt.Printf("燃尽率 burn rate = %.1fx\n", burn)
	if burn > 14.4 {
		fmt.Println("=> 触发 fast burn 告警(14.4x,1 小时窗口)")
	}
	consume := burn * 60 / float64(windowMins) * 100
	fmt.Printf("按此速率持续 1 小时,消耗 %.4f%% 的月度预算\n", consume)
}

本机实测输出:

SLO=99.900%  窗口=30 天  错误预算=0.1000%
允许失败请求数 = 43 次 / 43200 分钟
实测错误率 = 3/299 = 1.0033%
燃尽率 burn rate = 10.0x
按此速率持续 1 小时,消耗 1.3935% 的月度预算

解读:30 天里只允许失败 0.1% 的请求。刚才的样本错误率 1.0033%,是允许值的 10 倍——燃尽率 10x。按这个速率烧,一个月(43200 分钟)的预算会在 4320 分钟(3 天)内烧光。燃尽率是比「当前错误率」更好的告警信号,因为它把错误率归一化到了「离违约还有多远」。

10.2.8 多窗口燃尽率告警

Google SRE 推荐用长短两个窗口组合,兼顾「快报警」和「少误报」:

告警长窗口短窗口阈值含义
Fast burn1h5m14.4x2 天烧完预算,立刻叫人
Slow burn6h30m6x5 天烧完,工单处理
Ticket3d6h1x按部就班

对应的 PromQL(以 Fast burn 为例):

(
  sum(rate(taskhub_http_requests_total{status=~"5.."}[1h]))
  /
  sum(rate(taskhub_http_requests_total[1h]))
) > (14.4 * 0.001)
and
(
  sum(rate(taskhub_http_requests_total{status=~"5.."}[5m]))
  /
  sum(rate(taskhub_http_requests_total[5m]))
) > (14.4 * 0.001)

and 连接两个条件,表示长短窗口同时超过阈值才告警。长窗口保证「不是瞬时抖动」,短窗口保证「报警够快」。只用一个长窗口会迟钝,只用一个短窗口会被一次毛刺吵醒。

10.2.9 基数(cardinality)陷阱

Prometheus 的存储成本约等于「时间序列条数 × 采样点数」。每条不同的标签组合就是一条独立序列,基数失控能把监控系统打爆:

写法序列数后果
route="/tasks", method, status约 3×2×5=30健康
path="/tasks/12345"每个任务 ID 一条灾难
加 user_id 标签每用户一条灾难
加 trace_id 标签每请求一条进程 OOM

规则很简单:标签的值必须来自一个有限、封闭的集合。路径里的 ID 要先用路由模板归一化成 route="/tasks/:id"(Go 1.23+ 的 ServeMux 可以用 r.Pattern 拿到模板)。关联 ID、任务 ID 这类高基数标识只能进日志和链路,永远不能进指标标签。

小结

  • 计数器只增、仪表可增减、延迟用直方图;分位一定用 Histogram 而非 Summary。
  • 命名带单位后缀,Counter 以 _total 结尾,标签只放低基数维度。
  • 打点放中间件,用 statusRecorder 记录状态码。
  • P95 用 histogram_quantile(0.95, sum by (le) (rate(...[5m]))),别漏 sum by (le) 和 rate。
  • SLO 决定错误预算;告警看燃尽率而非原始错误率,用长短双窗口降误报。
  • 高基数标签是监控杀手,路径必须归一化成路由模板。

日志说「发生了什么」,指标说「现在好不好」,但两者都回答不了「这一次请求慢在哪一跳」。下一节用 OpenTelemetry 把一次请求内部的调用链画出来。

阅读导航:上一节:10.1 结构化日志与关联 ID · 下一节:10.3 链路追踪(OTel) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练