告警两百条没人认领,问题出在告警上游
不少团队的值班群长这样:Alertmanager 每天推几十条告警——CPU 高的、GC 频繁的、错误率抬了 0.2% 的。真出事时没人记得哪条值得看,没出事时也没人敢把它们关掉,于是所有告警一起变成背景噪音。
另一场争吵更熟悉。复盘会上有人说「我们可用性 99.9%」,立刻被三个问题问住:
- 按自然月算,还是滚动 30 天算?
- 分母是全部请求,还是只算下单这类关键请求?
- 延迟从客户端发起算,还是从网关收到请求那一刻算?
这三个问题没有放之四海的答案,但必须有本团队的书面答案。SLO 的价值不在于挑一个漂亮的数字,而在于把这类争吵变成一次定义、长期复用。至于怎么把 SLO 接到 Prometheus 告警上,四月那篇 SLI/SLO 告警已经写过配置细节;这篇只讲配置之前的功课:怎么定义,以及定义之后团队怎么用它做决策。
从用户旅程挑 SLI:三条路,不是五十个接口
给每个接口定 SLO 是最常见的弯路:五十个目标等于没有目标,每个接口负责人都能证明「我的接口达标」,用户照样流失。换个切入方式:只给用户付费所依赖的两三条旅程定目标,比如电商——
- 下单旅程:提交订单、锁定库存、发起支付,任何一步失败或超时,这次旅程就是坏的
- 搜索旅程:输入关键词、拿到结果,降级返回或超过 300ms,用户直接走人
旅程之外的接口不是不重要,而是不进 SLO 契约——它们留在普通监控和告警里。SLO 只覆盖「坏了用户就走」的少数路径,这也是它能被认真对待的前提。
SLI 是「好事件 / 总事件」,定义「好」比选数字难
SLI 的标准形态是一个比例:窗口内好事件数 / 总事件数。99.9% 这个目标好选,难的是分子:
- HTTP 200 但结果是空列表,算好吗?
- 阈值取 300ms 还是 800ms——按机器能力的 p95 定,还是按用户开始流失的位置定?
- 搜索降级到兜底索引,结果可用但不全,算成功吗?
这些答案没有对错,但必须写下来。下面的中间件把「好」的定义收拢在一处,而不是散落在各个 handler 里:
package sli
import (
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
)
// 不给每个接口建指标,只给进 SLO 契约的旅程建:journey x outcome 两个 label,基数可控
var journeyEvents = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "journey_events_total",
Help: "用户旅程事件计数",
},
[]string{"journey", "outcome"},
)
type respWriter struct {
http.ResponseWriter
status int
degraded bool // handler 主动标记:这次返回的是降级结果
}
type rule struct {
journey string
latencyOK time.Duration // 阈值取用户开始流失的位置,不是机器能跑多快
}
// 只有 SLO 覆盖的路由才计数,无关请求混进分母只会稀释信号
var rules = map[string]rule{
"/api/checkout": {journey: "checkout", latencyOK: 800 * time.Millisecond},
"/api/search": {journey: "search", latencyOK: 300 * time.Millisecond},
}
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rl, ok := rules[r.URL.Path]
if !ok {
next.ServeHTTP(w, r)
return
}
start := time.Now()
rw := &respWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
outcome := "good"
switch {
case rw.status >= 500 || rw.status == http.StatusTooManyRequests:
outcome = "bad" // 5xx 是失败;429 是我们自己拒绝的,同样是用户视角的失败
case time.Since(start) > rl.latencyOK:
outcome = "bad" // 慢过阈值,对用户来说和失败没有区别
case rw.degraded:
outcome = "bad" // 降级先从严算坏;要放宽就改 SLO 文档,别靠代码里的即兴判断
}
journeyEvents.WithLabelValues(rl.journey, outcome).Inc()
})
}
两个刻意的取舍:429 计入坏事件,因为对用户来说「被拒绝」就是失败,把它从统计里剔出去等于美化报表;降级先从严算坏,等数据证明某类降级确实无损,再在 SLO 文档里把它移到好事件一边。
测量点:同一秒,客户端、网关、服务端看到三个世界
同一个请求,在三层数据里是三个样子:
- 客户端(RUM):含 DNS、TLS、弱网、CDN,能看到服务端永远看不到的失败;代价是采样偏差、埋点丢失、难以归因
- 网关/边缘:看到进入集群的全部流量,包括 LB 超时和 upstream 503;看不到 DNS 失败,也看不到用户等不及主动放弃
- 服务端:归因最准、最好排障;但看不到在它之前被拒绝的流量,重试还会让按尝试计数的比例失真——一次用户操作重试两次、第三次才成功,服务端记到三个事件、一个好两个坏
原则:SLO 用最靠近用户的那层,排障用里面几层。契约一旦定在哪层就不要换,换了,历史数据就断了。
错误预算的算术:99.9% 大约是每月 43 分钟
SLO 定 99.9%、30 天窗口,预算就是 43200 分钟 × 0.1% ≈ 43.2 分钟的「坏服务时间」;按请求算,每 100 万个请求允许 1000 个坏事件。
窗口用滚动还是日历,各有代价:日历窗口每月 1 号自动满血,容易催生「月底预算花光、月初摆烂」的节奏;滚动 30 天跟着流量走、信号稳定,但「这个月还剩多少预算」的答案不直观。团队更在意和业务月报对齐就用日历,更在意平滑治理就用滚动——选哪个不重要,别中途换。
预算要能回答「还剩多少、按现在速度几号烧完」,这比一个百分比有用得多:
package budget
import "time"
// Budget 把 SLO 目标换算成可执行的量:剩余比例、燃烧率、预计耗尽时间
type Budget struct {
Target float64 // 目标,如 0.999
Window time.Duration // 窗口,如 30 * 24 * time.Hour
Total float64 // 窗口内已发生的总事件数
Good float64 // 其中好事件数
Elapsed time.Duration // 窗口已走过的时间
}
// BurnRate 当前坏事件率相对预算坏事件率的倍数,1.0 表示正好踩线
func (b Budget) BurnRate() float64 {
if b.Total == 0 {
return 0
}
return (1 - b.Good/b.Total) / (1 - b.Target)
}
// RemainingFraction 预算剩余比例,负数表示已烧穿
func (b Budget) RemainingFraction() float64 {
if b.Total == 0 {
return 1
}
// 按当前流量外推整个窗口的总事件数,再算允许的坏事件总量
projected := b.Total * b.Window.Seconds() / b.Elapsed.Seconds()
allowedBad := (1 - b.Target) * projected
return 1 - (b.Total-b.Good)/allowedBad
}
// ExhaustionAt 按当前坏事件速度线性外推耗尽时刻;烧不完返回零值
func (b Budget) ExhaustionAt(now time.Time) time.Time {
if b.Elapsed <= 0 {
return time.Time{}
}
badPerSec := (b.Total - b.Good) / b.Elapsed.Seconds()
remaining := (1 - b.Target) * b.Total * b.Window.Seconds() / b.Elapsed.Seconds() * b.RemainingFraction()
switch {
case remaining <= 0:
return now // 已经烧穿
case badPerSec <= 0:
return time.Time{} // 没有坏事件在发生
}
return now.Add(time.Duration(remaining / badPerSec * float64(time.Second)))
}
用一组数字过一遍:
b := Budget{Target: 0.999, Window: 30 * 24 * time.Hour,
Total: 8.2e6, Good: 8180320, Elapsed: 10 * 24 * time.Hour}
fmt.Printf("burn=%.1f remaining=%.0f%% exhaust_in=%.1fd\n",
b.BurnRate(), b.RemainingFraction()*100,
time.Until(b.ExhaustionAt(time.Now())).Hours()/24)
// burn=2.4 remaining=20% exhaust_in=2.5d:窗口才过 1/3,预算只剩两成
燃烧率:给预算装一个速度表
对 SLI 直接设单阈值告警两头不讨好:阈值低了天天响,高了预算烧完才响。燃烧率把「错误率多高」换算成「预算消耗多快」,再配双窗口防抖:
- 快烧:1 小时窗口燃烧率 ≥ 14.4,即 1 小时烧掉 2% 的月预算,且 5 分钟窗口同超 14.4 才触发——电话叫醒
- 中速:6 小时 ≥ 6(6 小时烧 5%),同样配短窗口确认——电话叫醒
- 慢漏:3 天 ≥ 1(3 天烧 10%)——开工单,不叫醒人
这组倍率是 Google SRE Workbook 给出的常用起点,可以按团队的预算节奏调。把燃烧率做成 recording rule,告警规则只引用它:
groups:
- name: sli-journey
interval: 30s
rules:
# 5 分钟 / 6 小时两个燃烧率;clamp_min 防止无流量时段除零
- record: sli_journey_burn_5m
expr: |
(1 - sum by (journey) (rate(journey_events_total{outcome="good"}[5m]))
/ clamp_min(sum by (journey) (rate(journey_events_total[5m])), 1e-12)) / (1 - 0.999)
- record: sli_journey_burn_6h
expr: |
(1 - sum by (journey) (rate(journey_events_total{outcome="good"}[6h]))
/ clamp_min(sum by (journey) (rate(journey_events_total[6h])), 1e-12)) / (1 - 0.999)
预算策略是团队契约,不是口头默契
错误预算最大的价值不是告警,是它给了工程团队一个和业务讨价还价的依据。但「预算烧完就停发」如果只存在于会议纪要里,第一个赶发布的人就会把它冲掉。把它写成和代码同仓库的契约文件,让发布流程读它:
# error-budget-policy.yaml:变更走 review,和代码同等对待
journey: checkout
slo:
target: 0.999
window: 720h # 滚动 30 天
policy:
- trigger: remaining < 0.5 && window_elapsed < 0.3 # 慢性泄漏
action: freeze_feature_launch # 冻结功能发布,只做可靠性工作
owner: checkout-team
exit: remaining > 0.65 持续 48h # 留缓冲再解冻,防止贴线反复横跳
- trigger: remaining <= 0
action: reliability_only # 只允许 bug 修复与降风险变更
escalate: 24h 未恢复升级到工程负责人
exceptions: [security_patches, legal_compliance] # 安全与合规变更不受冻结限制,事后复盘
关键是 exit 条件:解冻标准要高于触发标准,否则预算在阈值附近抖动,发布开关跟着反复横跳,契约很快就没人信了。
常见坑点
1. 分母漏掉了被拒绝的请求
过载时最先牺牲的就是被限流、被熔断、被 LB 丢弃的请求。如果 SLI 只统计「进入了 handler 的请求」,系统越过载、报表越好看。429、503、网关层丢弃都要进分母并且算坏事件。
2. 重试放大:按请求数还是按操作数
重试让一次用户操作变成多次服务端请求。按请求算 SLI,重试风暴期间指标失真——要么坏事件被重复计数,要么重试成功掩盖了首次失败。事件计数尽量按操作去重,至少要意识到这个偏差的存在。
3. 缓存命中的延迟双峰
命中 20ms、未命中 400ms 的分布是双峰的,一个 300ms 阈值会让好事件比例跟着命中率摆动:缓存效率的变化被误读成服务质量的变化。要么给 hit/miss 分别定阈值,要么把命中率单独拆出来看,别让它混进 SLI。
4. 低流量服务的除零与日历窗口
夜间无流量,比率类 SLI 分母为零;日历窗口月初预算自动回满,前一周怎么烧都无所谓。前者用 clamp_min 兜底并明确「无流量 = 预算未消耗」的语义,后者干脆换滚动窗口。
总结
SLO 的难点从来不在 Prometheus 表达式,而在它逼你回答那些一直没人拍板的问题:谁的体验、哪条旅程、什么算好、从哪测、窗口多长、烧完了怎么办。
这些答案写下来,告警才有资格分级,发布才有节奏可依,复盘才有共同语言。定义的过程比搭工具链贵得多,也值钱得多。
SLO 不是监控的升级版,是把「服务好不好」从争吵素材变成团队契约的那份文书。