本节把 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 命名与标签的三个约定
指标名是接口契约的一部分,改名等于破坏监控。三条约定:
- 名字带单位后缀:
_seconds、_bytes、_total。看到http_request_duration你永远不确定是秒还是毫秒。 - Counter 以
_total结尾:Prometheus 的查询习惯依赖这个后缀。 - 标签只放低基数维度:
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 burn | 1h | 5m | 14.4x | 2 天烧完预算,立刻叫人 |
| Slow burn | 6h | 30m | 6x | 5 天烧完,工单处理 |
| Ticket | 3d | 6h | 1x | 按部就班 |
对应的 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) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。