跳到主内容

从 3 分钟到 30 秒:成功率与 P99 自定义 Metrics 告警实战 ​

问题背景:只监控 CPU,为什么仍然会漏掉业务故障 ​

很多后端服务上线后已经接入了 CPU、内存、容器重启次数等基础监控,但这类资源指标并不能直接回答两个最关键的业务问题:用户请求是否成功?慢请求是否已经影响核心链路?

例如支付确认接口在 5 分钟内返回大量 500,CPU 可能仍然很低;某个下游依赖偶发超时,平均值也许没有明显变化,但 P99 延迟已经从 120ms 上升到 2s。此时如果只依赖日志检索,故障发现通常依赖人工搜索,平均发现时间可能超过 3 分钟;而基于 Prometheus 自定义 Metrics 的成功率与 P99 告警,可以在 30 秒左右识别异常。

本文使用 Go 演示一个可运行的 HTTP 服务,围绕以下目标建设监控:

  • 业务成功率:按接口、方法、状态码统计成功与失败请求;
  • P99 延迟:使用 Histogram 记录请求耗时,并在服务端计算分位数;
  • 告警规则:同时覆盖错误率升高和尾延迟恶化;
  • 可验证:通过本地压测与 Prometheus 查询确认指标和规则生效。

方案对比:Logs、Traces 与 Metrics 应该怎么选 ​

可观测性通常包含 Logs、Metrics、Traces 三类信号。它们不是替代关系,而是分工不同。

信号优势局限适合场景
Logs上下文完整,便于排查单次请求数据量大,实时聚合成本高错误详情、审计、异常堆栈
Traces能展示跨服务调用链与耗时分布采样策略复杂,全量上报成本高定位慢调用发生在哪一跳
Metrics成本低、聚合快、适合告警标签过多会造成基数爆炸成功率、QPS、P99、队列积压

对于“是否需要立刻通知值班同学”这类问题,Metrics 是第一选择。日志和链路追踪更适合在告警触发后辅助定位根因。例如告警显示 /api/pay 的 P99 升高,再通过 Trace ID 查询具体慢在数据库、缓存还是第三方支付渠道。

指标设计:为什么 P99 必须使用 Histogram ​

成功率通常用 Counter 实现,因为请求数只会递增,Prometheus 通过时间窗口计算增量比例。延迟指标则推荐使用 Histogram,而不是 Gauge 或普通 Counter。

Histogram 会把请求耗时放入不同 bucket,PromQL 通过 histogram_quantile 在服务端计算 P95、P99。它的优势是:

  • 可以跨实例聚合后再计算分位数;
  • 天然适合低基数、持续上报的场景;
  • 可以同时得到 _count、_sum 和 bucket 数据。

需要注意,Prometheus 客户端也提供 Summary 类型,但 Summary 的分位数通常在客户端计算,无法在多个 Pod 或机器之间正确合并。因此在微服务水平扩容场景下,P99 延迟应优先选择 Histogram。

HTTP 请求中间件拦截Counter/Histogram成功率与耗时/metricsPrometheus 抓取PromQL 规则错误率/P99Alertmanager通知值班人员

实操步骤:Go 服务接入 Prometheus 自定义指标 ​

下面示例采用 Go 1.22+ 与 Prometheus 官方客户端,演示一个带中间件的 HTTP 服务。代码包含业务接口、指标注册、/metrics 暴露端点,以及一个可主动制造错误和慢请求的调试接口。

第一步:初始化模块并安装依赖 ​

bash
mkdir demo-metrics-alert && cd demo-metrics-alert
go mod init demo-metrics-alert
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp

第二步:实现 Counter 与 Histogram 埋点 ​

go
package main

import (
	"log"
	"math/rand"
	"net/http"
	"strconv"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	// 请求总数:method、route、status_code 都是低基数标签
	requestsTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "http_requests_total",
			Help: "HTTP 请求总数",
		},
		[]string{"method", "route", "status_code"},
	)

	// 请求耗时直方图:bucket 要覆盖正常与异常延迟区间
	requestDuration = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_seconds",
			Help:    "HTTP 请求处理耗时,单位秒",
			Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2, 5},
		},
		[]string{"method", "route"},
	)
)

func metricsMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		// 使用状态码捕获器,确保业务写入状态码后仍能读取
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)

		route := r.URL.Path
		statusCode := strconv.Itoa(rec.status)
		duration := time.Since(start).Seconds()

		requestsTotal.WithLabelValues(r.Method, route, statusCode).Inc()
		requestDuration.WithLabelValues(r.Method, route).Observe(duration)
	}
}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

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

func healthHandler(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("ok"))
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	// 模拟约 10% 的服务端错误
	if rand.Intn(10) == 0 {
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte("internal error"))
		return
	}

	// 模拟约 10% 的慢请求,用于验证 P99 告警
	if rand.Intn(10) == 0 {
		time.Sleep(1200 * time.Millisecond)
	}
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("order success"))
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/health", metricsMiddleware(healthHandler))
	mux.HandleFunc("/api/orders", metricsMiddleware(orderHandler))
	mux.Handle("/metrics", promhttp.Handler())

	log.Println("server listen on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

启动服务:

bash
go run main.go

持续制造流量:

bash
while true; do
  curl -s http://localhost:8080/api/orders > /dev/null
  curl -s http://localhost:8080/health > /dev/null
  sleep 0.1
done

访问 http://localhost:8080/metrics,可以看到类似下面的指标:

text
http_requests_total{method="GET",route="/api/orders",status_code="200"} 820
http_request_duration_seconds_bucket{route="/api/orders",le="1"} 790
http_request_duration_seconds_bucket{route="/api/orders",le="2"} 820

PromQL 告警规则:成功率与 P99 分开治理 ​

新建 alert.rules.yml:

yaml
groups:
  - name: business-http-rules
    rules:
      # 5 分钟窗口内,5xx 错误率超过 5%,且请求量不少于 20,避免低流量误报
      - alert: HighHTTPErrorRate
        expr: |
          (
            sum by (route) (
              increase(http_requests_total{status_code=~"5.."}[5m])
            )
            /
            sum by (route) (
              increase(http_requests_total[5m])
            )
          ) > 0.05
          and
          sum by (route) (increase(http_requests_total[5m])) > 20
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "接口 5xx 错误率过高"
          description: "路由 {{ $labels.route }} 最近 5 分钟错误率超过 5%。"

      # 5 分钟窗口内,接口 P99 延迟超过 800ms
      - alert: HighHTTPP99Latency
        expr: |
          histogram_quantile(
            0.99,
            sum by (le, route) (
              rate(http_request_duration_seconds_bucket[5m])
            )
          ) > 0.8
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "接口 P99 延迟过高"
          description: "路由 {{ $labels.route }} 当前 P99 为 {{ $value }} 秒。"

Prometheus 最小配置 prometheus.yml:

yaml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - alert.rules.yml

scrape_configs:
  - job_name: demo-service
    static_configs:
      - targets: ["host.docker.internal:8080"]

如果直接在本机运行 Prometheus,将 target 改成 localhost:8080 即可。启动后在 Prometheus 页面的 Alerts 中可以看到规则状态。当慢请求持续出现且 P99 超过阈值,HighHTTPP99Latency 会从 pending 进入 firing。

成功率阈值为什么不能只写 0 ​

错误率阈值不建议直接设置为 > 0。服务在滚动发布、网络抖动、少量客户端异常请求下,可能出现零星 5xx。更合理的做法是结合业务等级设置阈值:

  • 核心支付、登录接口:5 分钟错误率超过 1%~3% 即告警;
  • 普通查询接口:5 分钟错误率超过 5% 告警;
  • 批处理或低频管理接口:使用最小请求量或绝对失败数辅助判断。

避坑总结:自定义 Metrics 告警最容易踩的 4 个坑 ​

1. 标签基数爆炸:不要把 user_id、trace_id 放进标签 ​

错误现象:Prometheus 内存持续上涨,TSDB block 压缩变慢,查询 /api/v1/query 超时,甚至出现 too many series。常见原因是写出如下代码:

go
// 错误示范:user_id、request_id 会产生海量时间序列
requestsTotal.WithLabelValues(r.Header.Get("X-User-Id"), traceID).Inc()

修正方法:Metrics 标签只保留有限枚举值,例如 method、route、status_code、region。用户 ID、订单号、Trace ID 应放入日志或链路追踪系统,通过 Exemplar 或日志关联,而不是作为 Prometheus 标签。

2. 路由未归一化:把 /orders/123 当成无限个接口 ​

错误现象:指标中出现 /orders/1001、/orders/1002、/orders/1003,最终形成高基数标签,告警规则无法按接口聚合。

修正方法:在中间件中使用路由模板,而不是原始 URL Path。例如 Go 标准库可维护映射,Gin、Chi、Echo 等框架应读取匹配后的路由模板:

go
// 正确思路:记录 /api/orders/{id},而不是 /api/orders/12345
route := "/api/orders/{id}"
requestsTotal.WithLabelValues(r.Method, route, statusCode).Inc()

同时不要对 Query 参数直接打标签,否则 ?page=1&size=20 的任意组合也会制造大量序列。

3. P99 使用客户端 Summary,扩容后无法聚合 ​

错误现象:单实例测试时 P99 看起来准确,服务扩容到 10 个 Pod 后,Dashboard 上出现多条独立分位数曲线;如果直接 avg,得到的也不是整体 P99,而是“各实例 P99 的平均值”。

修正方法:跨实例统一使用 Histogram,并通过 sum by (le, route) 聚合 bucket 后再执行 histogram_quantile。另外 bucket 边界要贴近业务 SLO,例如大部分接口在 200ms 内,就不能只配置 1, 2, 5 秒这种粗粒度桶,否则 P99 误差会很大。

4. 告警窗口与抖动处理不当,导致夜间频繁误报 ​

错误现象:低流量接口每隔几分钟出现一次 500,错误率瞬间变成 100%,告警立即触发又恢复;或者 P99 只在一个抓取周期升高,值班同学被唤醒后发现服务已经正常。

修正方法:使用 increase 或 rate 配合 5 分钟窗口,并设置 for: 2m~5m 持续时间;增加最小请求量门槛。对于真正要求秒级发现的核心链路,可以缩短窗口,但应配合熔断、重试、自动扩缩容等信号共同判断,避免单点毛刺触发高级别告警。

落地建议:从告警规则走向 SLO 治理 ​

成功率和 P99 延迟不是配置完规则就结束,建议进一步按接口等级定义 SLO。例如支付接口可以设定“7 天内 99.9% 请求成功,99% 请求延迟低于 300ms,99.9% 请求延迟低于 1s”。随后用多窗口多燃尽率规则控制告警数量,让红色告警真正对应用户可感知故障。

完整落地顺序建议是:先用 Histogram 和 Counter 统一指标命名,再通过 PromQL 建立成功率与 P99 看板,然后为核心接口配置分级告警,最后在告警通知中附上日志查询链接和 Trace 查询入口。这样监控系统不仅能在 30 秒内发现问题,也能帮助值班同学快速完成从“发现异常”到“定位根因”的闭环。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。