外观
从 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。
实操步骤: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"} 820PromQL 告警规则:成功率与 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 秒内发现问题,也能帮助值班同学快速完成从“发现异常”到“定位根因”的闭环。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。