指标与 SLI
这一课你将能做到
- 区分 Counter / Gauge / Histogram / Summary 四种指标类型,说清各自适合测什么、用错会怎样
- 为判题接口写出 3 条 SLI(可用率、延迟、错误率),每条都有明确的分子和分母
- 用 PromQL 写出 QPS、错误率、P99 延迟查询,并解释高基数标签为什么会拖垮时序库
为什么要学
没有指标,告警和 SLO 都无从谈起;选错指标类型,数字会「看起来有、算起来错」。典型翻车现场:把 Counter 的累计值当瞬时 QPS 直接配阈值告警,结果服务重启归零后告警狂叫又自动恢复;只看平均延迟觉得「系统很健康」,实际 5% 的用户判题结果等了 60 秒。
指标(Metrics) 把系统行为压缩成可聚合的数值时序;SLI(Service Level Indicator,服务水平指标) 是其中「对用户真正重要」的那几条。面试里「你们系统怎么度量健康」「P99 和平均值的区别」「标签基数怎么回事」都属于本课射程,是必须背到脱口而出的程度。
先建立图像
| 类型 | 行为(人话) | 典型用途 | 常见坑 |
|---|
| Counter | 只增不减的累计计数器 | 请求总数、错误次数、判题提交量 | 拿裸累计值当「当前速率」 |
| Gauge | 可升可降的瞬时值 | 队列长度、连接池占用、在线 worker 数 | 对它用 rate()(通常不对) |
| Histogram | 把观测值丢进一组桶 | 延迟、响应体大小 → 算 P95/P99 | 桶边界和业务延迟量级不符 |
| Summary | 客户端算好分位数 | 少见;一般改用 Histogram | 分位数无法跨实例再聚合 |
一次判题提交(原始事件)
├─ Counter: judge_submissions_total{result="accepted"} += 1
├─ Gauge: judge_queue_depth = 42(Kafka 待判题消息数,瞬时值)
└─ Histogram: judge_duration_seconds 观察本次判题耗时
SLI 不是「再采一套新指标」,而是从已有信号里选出用户最在意的定义:
可用率 = 非 5xx 请求数 / 总请求数
错误率 = 5xx 请求数 / 总请求数
延迟 = P99 是否低于约定阈值(如提交接口 < 500ms)
跟着做
步骤 1:给判题接口选定三条 SLI
针对 POST /api/judge/submissions,在记事本写下:
SLI-1 可用率 = status!~"5.." 的请求数 / 总请求数(滚动 5 分钟窗口)
SLI-2 延迟 = http_server_requests_seconds 的 P99 < 500ms
SLI-3 错误率 = status=~"5.." 的请求数 / 总请求数
关键要求:每条 SLI 都有明确的分子和分母。写不出分子分母的「指标」只是图表,不是 SLI。
步骤 2:用 Micrometer 埋一个业务 Counter 和 Timer
在判题提交服务里:
@Service
public class SubmissionService {
private final MeterRegistry registry;
public SubmissionService(MeterRegistry registry) {
this.registry = registry;
}
public SubmissionDto submit(SubmitRequest req) {
// Counter:判题提交总量,标签只用低基数的 language
registry.counter("judge_submissions_total",
"language", req.getLanguage()) // java/python/cpp,种类有限
.increment();
// Timer:记录判题创建耗时(Timer 底层就是 Histogram)
return Timer.builder("judge_submission_create")
.register(registry)
.record(() -> doSubmit(req));
}
}
启动后访问 http://localhost:8080/actuator/prometheus,搜索 judge_submissions_total 应能看到按 language 分开的计数行。
步骤 3:在 Prometheus 里写三条核心 PromQL
-- QPS(注意:对 Counter 必须用 rate,不能用裸值)
sum(rate(http_server_requests_seconds_count{uri="/api/judge/submissions"}[5m]))
-- 错误率(5xx 占比)
sum(rate(http_server_requests_seconds_count{uri="/api/judge/submissions", status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{uri="/api/judge/submissions"}[5m]))
-- P99 延迟(对 Histogram 用 histogram_quantile)
histogram_quantile(0.99,
sum(rate(http_server_requests_seconds_bucket{uri="/api/judge/submissions"}[5m])) by (le)
)
把三条都跑一遍,对照 Grafana 图确认数值合理。rate() 的 [5m] 是回看窗口:窗口太短曲线毛刺大,太长反应迟钝,5m 是常用起点。
步骤 4:做一次「高基数」体检
列出你想加的标签:method、status、uri、language、userId、submissionId、rawUrl、exceptionMessage。
language:3~5 种 → 安全
uri:必须用模板 /api/judge/submissions,不能是 /api/judge/submissions/12345
userId / submissionId:几万到几百万种 → 禁止
exceptionMessage:文本千变万化 → 禁止(这类细节放日志字段)
应得到:一张标签白名单。每个标签组合都会生成一条独立时序,基数爆炸 = 时序数量爆炸 = 内存、存储、查询全崩。
步骤 5:把 SLI 挂到 SLO 入口
写一句话:「若 SLI-1 在 30 天滚动窗口低于 99.9%,则开始消耗错误预算」。错误预算的算法和告警在第 9 课展开,本课只要建立 SLI → SLO 的挂钩意识:先测得准,才谈得上承诺。
讲清楚要点
- Counter 是累计值,所以看速率必须用
rate() / increase()——裸值直接比大小是初学者最常见的错误。
- Gauge 表示瞬时状态,适合连接池、队列深度——不适合回答「过去 5 分钟错了几次」这类累计问题。
- 延迟是分布而不是单个数字,所以要看 P95/P99 而不是平均值——平均值会掩盖长尾:100 个请求 95 个 10ms、5 个 10s,平均值约 510ms 看起来还行,但那 5 个用户已经流失了。
- Histogram 的分位数在服务端聚合算,Summary 在客户端算死——跨多实例聚合分位数必须用 Histogram,这是两者选型的唯一硬标准。
- SLI 服务用户体验,所以优先选「请求成功与否、花了多久」——JVM 堆、CPU 是因果线索,不是用户承诺本身。
- 每个标签组合生成一条新时序,所以标签设计首先是基数设计——白名单制度,细节交给日志和链路。
- 多副本各自暴露指标,聚合时按
application 等公共标签 sum——否则你看到的是「某个 Pod 的 QPS」而不是服务的 QPS。
你可能会懵的地方
-
Counter 重启归零,是不是指标坏了?
不是。rate() 内部会检测重置并正确处理。你要关心的是「单位时间增量」,不是裸计数本身。这也是不能直接对 Counter 配「> 10000 告警」的原因之一。
-
Histogram 和 Summary 到底怎么选?
需要在 Prometheus 里跨实例聚合 P99 → Histogram。Summary 的分位数在应用内算好,两个实例的 P99 无法合并成整体 P99。Spring/Micrometer 默认走 Timer → Histogram 语义,跟着默认走就对。
-
rate() 窗口怎么选?
至少取 scrape 间隔的 4 倍(如 15s 间隔用 ≥1m),告警常用 5m。窗口小于间隔会得到锯齿状假数据。
-
SLI 和「有用的指标」有什么区别?
SLI 是你愿意写进 SLO、用来做发布决策的那几条。磁盘 inode 很重要,但用户感知不到,通常不是面向用户的 SLI,而是排障线索。
-
为什么 uri 标签必须用模板?
/api/judge/submissions/12345 每个 ID 一条时序,基数随业务量爆炸。Spring 的 MVC 插桩默认已用模板化 URI;如果你看到原始 URL 出现在标签里,多半是自定义了错误的 uri 标签。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 用平均延迟当唯一健康标准 | 长尾用户被平均值掩盖 | 至少看 P95/P99 + 错误率 |
userId、完整 URL 做指标标签 | 时序爆炸,拖垮 Prometheus | 低基数标签白名单,细节进日志 |
| Counter 裸值配阈值告警 | 重启归零、语义错误 | 先 rate()/increase() 再比阈值 |
| 只采机器 CPU,不采 HTTP 指标 | 资源正常但接口已挂 | 服务 RED 指标优先,资源作辅助 |
| 给每个内部方法建 Timer | 指标爆炸、维护噩梦 | 框架自动插桩打底,手动只埋业务决策点 |
卡点急救
histogram_quantile 查出来是 NaN 或平直不动:检查是否忘了 by (le) 聚合——桶标签 le 必须保留;再确认 Prometheus 版本 ≥ 2.x 且指标确实是 _bucket 结尾的 Histogram。
- rate() 曲线出现莫名尖刺/断裂:多半是窗口内发生了 Counter 重置(服务重启、Pod 重建)。属正常现象,告警加
for 持续时间消化。
- 指标在
/actuator/prometheus 有,Prometheus 里没有:先看 Status → Targets 是否 UP;再查标签是否写错——Micrometer 的 judge_submissions_total 导出后可能带 _total 后缀,别搜错名。
面试常问
-
P99 和平均延迟有什么区别?为什么看 P99?
背这个版本:平均值把所有请求拉平,长尾被多数快请求稀释;P99 表示 99% 的请求都慢于这个值的反面——即最慢的 1% 用户等待多久。大流量下 1% 就是每天成千上万次坏体验。工程实践:接口延迟看 P95/P99,配合 P50 看整体水位;告警用 P99 + 错误率,不用平均值。
-
Prometheus 四种指标类型区别?
Counter 只增累计(请求数),用 rate 看速率;Gauge 瞬时可升降(队列深度、连接数);Histogram 服务端分桶,可跨实例聚合出 P95/P99;Summary 客户端算分位数,不可跨实例聚合,一般优先 Histogram。
-
什么是高基数问题?怎么避免?
每个标签组合生成一条独立时序。userId 这种百万级取值的标签会让时序数量爆炸,Prometheus 内存和查询都被拖垮。避免:标签白名单制度,取值必须是有界枚举;uri 用路由模板;高基数字段(userId、订单号、异常文本)只进日志和 Trace 属性,不进指标标签。
自测三问
- Kafka 判题队列的堆积深度该用 Counter 还是 Gauge?为什么?
- 写出你为判题提交接口选的三条 SLI,各自的分子分母是什么?
- 为什么不能把
submissionId 加为指标标签?正确的承载位置是哪里?
小结一句话
先选对指标类型,再定义用户视角的 SLI——数字才会服务于告警、SLO 与排障,而不是制造错觉。
延伸