Go 基础体系 · 第 91/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Prometheus 与 Grafana:指标设计、埋点和告警

本文以 Go 1.26.4 为基准,示例采用稳定主线 client_golang v1、Prometheus 3.x 与 Grafana 12.x;生产项目应锁定最新稳定补丁版本并验证升级。监控要把请求从产生、抓取、存储、查询直到告警处置变成反馈环。

Prometheus 保存可聚合的数值时间序列,Grafana 负责查询、展示和告警。日志保留事件,Trace 解释单次调用,Profile 定位进程开销;指标回答“何时、多少、影响多大”。

1. 从用户目标反推指标,而不是遍历代码埋点

先写服务级目标:例如文章读取接口 30 天可用性不低于 99.9%,成功请求 P95 小于 300 ms。再选择能证明目标的信号。在线服务通常采用 RED:Rate、Errors、Duration;资源组件采用 USE:Utilization、Saturation、Errors。业务指标只保留能驱动决策的发布数、积压量和状态转换,不把每个字段都计数。

一次 HTTP 请求至少产生总数、耗时直方图和当前并发数,分别回答流量与错误、延迟和饱和。CPU、内存与 GC 由默认 collector 补充,但资源高不等于用户影响,应结合错误率和尾延迟判断。

命名使用基本单位与后缀:持续时间是 _seconds,字节是 _bytes,累计值是 _total。帮助文本解释语义;指标名和 label 一经被查询使用,就成为兼容契约。

2. 时间序列如何从进程走到查询结果

客户端库在进程内维护 collector。业务调用 IncObserve 修改内存状态;Prometheus 按 scrape interval 请求 /metrics,把指标名、label 集合、样本值和抓取时间写入本地 TSDB。查询时 PromQL 选择序列并按时间窗口计算 rate、聚合或分位数,Grafana 再按 dashboard 时间范围和 step 发起查询。

进程退出前未抓取的增量会消失;counter 重启归零,rate 会识别 reset;抓取失败不阻塞业务,但会产生 up == 0 和数据空洞。样本时间通常来自抓取端。远程写入是另一段异步生命周期,远端故障会形成积压,而不是让业务同步失败。

查询结果为空时按顺序检查目标发现、网络、/metrics HTTP 状态、文本解析、relabel、时间范围和 label matcher。先查 up{job="article-api"},再查目标指标;不要一看到 Grafana 空图就改业务埋点。

3. 独立 Registry 让注册和测试可控

默认 registry 带 Go 与进程 collector,方便小程序,却是包级可变状态;测试重复注册会 panic,不同组件也可能重名。应用更适合在组合根创建 registry,显式注入指标对象,再通过 promhttp.HandlerFor 暴露。

registry := prometheus.NewRegistry()
registry.MustRegister(
	collectors.NewGoCollector(),
	collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)

metrics := NewHTTPMetrics(registry)
metricsHandler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{
	EnableOpenMetrics: true,
})

MustRegister 适合启动期固定定义错误:重名或 descriptor 不一致说明程序配置错误,可以让启动失败。动态加载组件时使用 Register 并返回上下文错误。不要在每次请求里创建或注册 collector;对象长期复用,向量通过 label 值找到子指标。

自定义 registry 不会自动包含运行时指标,必须显式注册。使用多个 registry 时也要保证全部端点被抓取。

4. Counter、Gauge、Histogram 与 Summary 的真实语义

Counter 只能增加,适合请求、失败、处理字节等累计事件;展示速率用 rate,不要自己在应用中定时清零。Gauge 可增可减,适合当前连接、队列长度、最后成功时间戳和资源占用。Gauge 记录的是抓取瞬间状态,10 秒内快速升降可能完全不可见。

Histogram 把观测累计到 _bucket{le=...}_sum_count。各实例的桶可以相加,因此适合集群分位数和 SLO。Summary 在客户端滑动窗口内计算 quantile,其 quantile 不能跨实例求和;只有 _sum_count 可聚合。生产延迟通常优先 Histogram,Summary 只用于确实需要客户端分位数且不做横向聚合的场景。

type HTTPMetrics struct {
	requests *prometheus.CounterVec
	duration *prometheus.HistogramVec
	inFlight prometheus.Gauge
}

func NewHTTPMetrics(registerer prometheus.Registerer) *HTTPMetrics {
	m := &HTTPMetrics{
		requests: prometheus.NewCounterVec(prometheus.CounterOpts{
			Namespace: "wrblog",
			Subsystem: "http",
			Name:      "requests_total",
			Help:      "Completed HTTP requests.",
		}, []string{"route", "method", "status_class"}),
		duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
			Namespace: "wrblog",
			Subsystem: "http",
			Name:      "request_duration_seconds",
			Help:      "HTTP request latency in seconds.",
			Buckets:   []float64{0.01, 0.025, 0.05, 0.1, 0.3, 1, 3},
		}, []string{"route", "method"}),
		inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
			Namespace: "wrblog", Subsystem: "http",
			Name: "in_flight_requests", Help: "Requests currently executing.",
		}),
	}
	registerer.MustRegister(m.requests, m.duration, m.inFlight)
	return m
}

5. Label 基数决定监控系统能否活下来

一条独特的指标名加 label 集合就是一条序列。若 route 有 20 种、method 4 种、status_class 5 种,单指标最多 400 条;再加 1000 个租户就变成 40 万条。Histogram 每个 label 集合还会为每个桶以及 sum、count 生成序列,成本会继续放大。

label 只能来自有界枚举。路由使用 /articles/{id} 模板,不用 /articles/93817;状态用 2xx,不放错误文本;绝不放用户 ID、trace ID、邮箱、原始 URL、SQL、消息 ID。需要定位个体时转到日志或 Trace,并通过 exemplar 关联。

值域受控也不等于 label 越多越好。上线前列出维度最大值、实例数、桶数和保留期;监控 prometheus_tsdb_head_series、样本摄入与远程写队列。异常基数应能按 job/metric 定位并降维。

6. HTTP 中间件必须在结束点记录状态

状态码只有请求结束后才确定,默认未调用 WriteHeader 时为 200。包装 ResponseWriter 时还需考虑 FlusherHijackerPusher 等可选接口;完整生产实现优先使用维护良好的 instrumentation,避免破坏流式响应。下面展示生命周期核心:进入时增加并发,退出时一定减少并观测。

func (m *HTTPMetrics) Middleware(route string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		started := time.Now()
		m.inFlight.Inc()
		defer m.inFlight.Dec()

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

		method := r.Method
		statusClass := strconv.Itoa(recorder.status/100) + "xx"
		m.requests.WithLabelValues(route, method, statusClass).Inc()
		m.duration.WithLabelValues(route, method).
			Observe(time.Since(started).Seconds())
	})
}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

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

若 handler panic,普通 defer 只减少 gauge,完成计数不会执行。恢复中间件应在统一入口决定 500 响应并记录,指标中间件的相对顺序要用测试固定。取消请求仍是一次完成事件:可按服务契约归到特定状态或结果 label,但不要把 context.Canceled 文本放 label。

7. 并发、取消与后台采集器

client_golang 指标操作可并发调用,但这不使被观察的业务对象自动并发安全。GaugeFunc/自定义 Collector 在每次抓取时执行;它若持锁、访问网络或等待无界 channel,会让 scrape 超时并影响所有同 registry 指标。Collect 应只读取已有快照,不能临时查询慢数据库。

后台更新 snapshot 的 goroutine 必须有 context.Context、停止路径和等待机制。不要 fire-and-forget,也不要用无界 channel 吸收压力。

func RunQueueSampler(ctx context.Context, gauge prometheus.Gauge, queue Queue) error {
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
			depth, err := queue.Depth(ctx)
			if err != nil {
				return fmt.Errorf("read queue depth: %w", err)
			}
			gauge.Set(float64(depth))
		}
	}
}

若采样失败不应终止整个服务,可以由拥有生命周期的 supervisor 分类错误、记录日志并退避重试;函数本身不要既记录又返回。关闭时先取消后台任务,等待退出,再关闭依赖。指标最后一次值可能变旧,因此额外导出 last_success_timestamp_seconds 或采样错误 counter,而不是假装 gauge 为零。

8. 桶边界必须围绕 SLO 设计

默认桶未必适合业务。若 SLO 是 300 ms,必须有 le="0.3" 桶才能精确计算满足比例;若绝大多数请求 5 ms,最小桶 100 ms 会让分位数失真。桶覆盖正常、临界和故障区间,数量越多序列越多。

集群 P95 查询如下,le 必须保留到聚合维度中:

histogram_quantile(
  0.95,
  sum by (le, route) (
    rate(wrblog_http_request_duration_seconds_bucket[5m])
  )
)

分位数是桶内插值,不是原始事件精确排序;流量很低时短窗口结果跳动。若只判断 SLO,直接计算好桶比例更稳定:

sum(rate(wrblog_http_request_duration_seconds_bucket{le="0.3"}[30m]))
/
sum(rate(wrblog_http_request_duration_seconds_count[30m]))

修改桶边界会改变时间序列契约,旧数据不能凭空转换。可发布新指标名并让 dashboard 过渡,或接受变更点前后不可直接比较;不要悄悄改相同名称。

9. PromQL 中 rate、increase 与聚合顺序

Counter 看原值通常没有意义。rate(x[5m]) 估算每秒速率并处理 reset,increase 表示窗口增量,更适合面向人的数量展示。先对每条 counter 做 rate,再 sum;先 sum 后 rate 可能掩盖不同实例的重启。

sum by (route) (
  rate(wrblog_http_requests_total{status_class=~"5.."}[5m])
)
/
clamp_min(
  sum by (route) (rate(wrblog_http_requests_total[5m])),
  0.001
)

Grafana 的变量和 $__rate_interval 能随视图范围选择窗口,但告警规则必须使用明确、经过评估的窗口。缺数据与零不同:没有序列可能是目标宕机、label 改名或从未发生事件。用 or vector(0) 前先确认业务语义,否则会把采集故障显示成“零错误”。

昂贵、反复使用的表达式写 recording rule,把结果存成新序列。命名体现 level:metric:operations,同时记录规则计算失败和耗时。recording rule 减轻 dashboard 查询,不会降低原始高基数摄入成本。

10. 抓取端点是受保护的管理面

/metrics 会泄露版本、路由、实例拓扑和业务规模,也可能被高频抓取消耗 CPU。它应监听独立管理地址,仅允许 Prometheus 网络访问;公网服务不要仅靠难猜路径。若必须鉴权,通过反向代理或 Prometheus 支持的认证配置实现,并监控证书轮换。

scrape_configs:
  - job_name: article-api
    scrape_interval: 15s
    scrape_timeout: 5s
    scheme: https
    static_configs:
      - targets: ["article-api.monitoring.svc:9443"]
    tls_config:
      ca_file: /etc/prometheus/ca.pem
      cert_file: /etc/prometheus/client.pem
      key_file: /etc/prometheus/client-key.pem

scrape timeout 必须短于 interval。大量目标应使用服务发现,而非手工静态列表;relabel 处理目标身份,metric_relabel 只在摄入前丢弃样本,无法收回应用生成和网络传输成本。Prometheus 自身也要监控磁盘、WAL、规则、抓取和远程写队列。

11. Grafana Dashboard 要支持判断而非装饰

首页按用户影响组织:顶部是 SLO、流量、错误率和 P95/P99,其次是实例饱和、依赖、队列,再下钻到 Go runtime。每个 panel 标明单位、查询窗口、聚合维度和数据源;统一 route、cluster、environment 变量。柱状颜色不能替代阈值语义,图例需要能识别实例或路由。

部署 annotation 让操作者看到错误率与变更的关系。dashboard JSON 放版本控制,通过 provisioning 或 API 发布,评审 PromQL 而不只评审截图。环境变量必须限制数据源。

刷新间隔小于 scrape interval 只会重复查询。长时间范围要增大 query step,必要时使用 recording rule。dashboard 不应同时请求数百条高基数序列。

12. 告警从错误预算和持续影响出发

告警应表示需要人采取行动的症状,而非任何异常数字。高 CPU 若自动扩容能处理,可做容量信号;持续高错误预算消耗才应叫醒值班。每条告警包含摘要、影响、当前值、dashboard 和 runbook,label 负责路由,annotation 承载可读信息。

多窗口多燃烧率能兼顾快速严重故障与慢性消耗。简化规则如下:

groups:
  - name: article-api-slo
    rules:
      - alert: ArticleAPIHighErrorBudgetBurn
        expr: |
          (
            sum(rate(wrblog_http_requests_total{status_class="5xx"}[5m]))
            / sum(rate(wrblog_http_requests_total[5m]))
          ) > 0.0144
          and
          (
            sum(rate(wrblog_http_requests_total{status_class="5xx"}[1h]))
            / sum(rate(wrblog_http_requests_total[1h]))
          ) > 0.0144
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "article-api 正在快速消耗错误预算"
          runbook_url: "https://runbooks.example/article-api-errors"

阈值必须由实际 SLO 推导,示例数字不能直接照搬。for 抑制短抖动,但故障恢复会重置计时;Alertmanager 再做分组、抑制和通知。不要用静默长期掩盖坏规则,应修查询或路由。告警发布前用历史数据回放,并演练通知、确认、升级与恢复消息。

13. 失败诊断:从应用到 TSDB 分层排查

重复注册 panic:检查是否使用默认 registry、测试是否多次构造,改为注入独立 registry。inconsistent label cardinalityWithLabelValues 数量与定义不同,优先改为 With(prometheus.Labels{...}) 提升可读性,但仍须测试所有 label。抓取慢:检查自定义 Collector、指标序列量、压缩和网络。

counter 看似下降通常是进程重启或查询了原值;用 resetsrate 和部署 annotation 验证。P99 大于最大桶时会显示上界或 +Inf 影响,扩展桶并评估序列成本。Grafana 图和告警不一致时,对比数据源、时间区、step、变量 matcher、即时查询与范围查询。

远程写积压时先保护本地监控与磁盘,检查队列 shard、带宽、远端限流和样本年龄。不能用无限队列承诺不丢数据。监控系统故障本身必须有独立路径通知,否则业务与告警同时失明。

14. 测试指标的可观察契约

测试不读取 collector 私有字段,而是收集公开 exposition 或用 prometheus/testutil 比较值。每个测试创建新 registry,覆盖成功、错误、取消和并发归零。时间依赖通过注入时钟或只断言 count,避免休眠造成不稳定。

func TestHTTPMetricsRecordsCompletion(t *testing.T) {
	registry := prometheus.NewRegistry()
	metrics := NewHTTPMetrics(registry)
	handler := metrics.Middleware("/articles/{id}", http.HandlerFunc(
		func(w http.ResponseWriter, _ *http.Request) {
			w.WriteHeader(http.StatusNoContent)
		},
	))

	request := httptest.NewRequest(http.MethodGet, "/articles/42", nil)
	response := httptest.NewRecorder()
	handler.ServeHTTP(response, request)

	got := testutil.ToFloat64(metrics.requests.WithLabelValues(
		"/articles/{id}", http.MethodGet, "2xx",
	))
	if got != 1 {
		t.Errorf("requests = %v, want 1", got)
	}
}

再运行 go test -race ./...,因为业务状态快照和自定义 collector 常在 scrape 与更新 goroutine 间竞争。测试 exposition 时固定需要的行,不比较 Go runtime 全部输出。对 PromQL 和规则使用 promtool test rules 构造时间序列,验证 firing、pending、恢复和无数据。

15. 性能、容量与生产边界

指标调用不是零成本。热路径避免动态拼 label、重复 map 分配和高桶数,先 benchmark 再优化。CounterVec.GetMetricWithLabelValues 可在有界 label 固定时缓存 child,但如果动态生命周期对象不断创建并 DeleteLabelValues,要确认并发和清理语义。绝不因为指标写入失败让业务失败;本地内存更新通常无 error,暴露与传输故障由监控链路处理。

容量估算至少包含:目标数 × 每目标序列数 × 每秒样本率 × 保留期,再留 WAL、索引、压缩和故障余量。HA Prometheus 会重复抓取,远端需要去重标签。单机 TSDB 有明确磁盘和查询边界;长期、多集群保留可用兼容远程存储,但引入网络、租户隔离和费用治理。

发布前执行以下检查,并保存版本与配置:

go test ./...
go test -race ./...
go vet ./...
promtool check config prometheus.yml
promtool check rules rules/*.yml
curl -fsS http://127.0.0.1:9090/-/ready

完整生产闭环是:先用 SLO 定义信号,控制 label 与桶,验证埋点并安全抓取,用 PromQL 形成可复用记录,以 dashboard 提供上下文,以告警驱动行动,最后通过 runbook 和复盘改进指标。数字存在不等于可观测;只有它能稳定支持判断、定位和处置,才是一套可用的监控系统。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。