50.7 SLO 与错误预算
一句话
SLO 把「可靠性」从一个形容词变成一笔额度:预算还有,就继续发版、继续冒险;预算烧完,就冻结变更、先修可靠性。争议到此结束——因为后面的动作由算术决定,不由嗓门决定。
三个词:SLI 是测量,SLO 是目标,SLA 是合同
| 词 | 是什么 | 谁定 | 违约后果 | 例子 |
|---|---|---|---|---|
| SLI(Indicator) | 一个可测量的比例:好事件 / 有效事件 | 工程团队 | 无(它只是数字) | 成功且 < 300 ms 的请求 / 全部有效请求 = 99.93% |
| SLO(Objective) | 给 SLI 定的目标值 + 时间窗口 | 工程 + 业务 | 无(它是内部契约) | 30 天内 99.9% |
| SLA(Agreement) | 对客户的赔付承诺 | 法务 + 业务 | 赔钱/解约 | 月度可用性 < 99.5% 赔付 10% 月费 |
三者的关系是必须留缓冲:SLA 99.5% 而 SLO 99.9%,中间那 0.4% 是「内部已经报警、客户还没感觉」的救火窗口。反过来(SLO 低于 SLA)等于把赔付风险埋在系统里。
🔑 SLI 是唯一需要工程严谨性的东西:分母定义错了,后面所有算术都白算。
SLI 怎么写:好事件 / 有效事件
| 服务类型 | 好事件 | 有效事件(分母) | 最常见的错 |
|---|---|---|---|
| 在线请求 | status < 500 且 duration < 300ms | 所有非健康检查、非探针的请求 | 把健康检查算进分母(分母虚高、SLO 永远达标) |
| 异步消费 | 端到端延迟 < 5 min(从生产到消费成功) | 所有投递到该消费组的消息 | 只统计「处理成功」,把积压的延迟漏掉 |
| 存储/查询 | 读成功且 < 100 ms | 所有读请求 | 把客户端主动取消(499)算成坏事件 |
| 批量任务 | 在窗口内成功完成 | 所有调度实例 | 把「没跑」算成成功 |
四条判据,缺一条 SLI 就会失真:
- 分母必须是「用户真的会发起的请求」:排除健康检查、内部探针、压测流量。
- 客户端取消不是服务端的错:
499/context canceled要从分母和分子里一起剔除,否则「用户没耐心」会被记成「服务不可用」。 - 慢也是坏:只统计
status的 SLI 会把「全部 200 但都要 8 秒」判成 100% 可用。 - 窗口内的比例,不是瞬时值:SLI 是一个窗口上的比率,采样率、抓取间隔都会影响它(见 50.4 度量)。
窗口:滚动 30 天,别用日历月
| 窗口 | 怎么做 | 优点 | 缺点 |
|---|---|---|---|
| 日历月 | 每月 1 日清零 | 与账单/汇报对齐 | 月初可以「挥霍」,月末不敢动;一个事故影响整月 |
| 滚动 30 天 | 永远看最近 30 天 | 无法靠「等月初」洗白 | 一个事故在 30 天内持续压制预算(悬崖效应) |
| 滚动 7 天 | 更灵敏 | 感知快 | 太抖,一次大促就能烧完 |
工程上的常见组合:SLO 用滚动 30 天,告警用多窗口短窗口(1 h / 5 m)。这样「是否违约」看长期,「是否现在就要救」看短期——两者的时间尺度不同,本来就不该用同一个窗口。
错误预算:把百分比换算成时间
预算 = (1 − SLO) × 窗口。滚动 30 天时:
| SLO | 30 天预算 | 折合每周 | 折合每年 | 每天可用时长 |
|---|---|---|---|---|
| 99.00% | 7h12m0s | 1h40m48s | 87h36m0s | 14m24s |
| 99.50% | 3h36m0s | 50m24s | 43h48m0s | 7m12s |
| 99.90% | 43m12s | 10m4s | 8h45m36s | 1m26s |
| 99.95% | 21m36s | 5m2s | 4h22m48s | 43s |
| 99.99% | 4m19s | 1m0s | 52m31s | 8s |
几个必须记住的换算:
- 99.9% ≈ 每月 43 分钟,不是「差不多就行」:一次 10 分钟的故障就用掉 23% 的月预算。
- 每多一个 9,可用时间除以 10,成本(人力、冗余、变更速度)大约翻倍:99.9% 靠多副本 + 快速回滚;99.99% 开始要求多活、自动化切换、变更冻结窗口;99.999% 意味着「一年只能挂 5 分钟」,绝大多数业务不该定这个目标。
- SLO 定 100% 是自欺:预算为 0,任何一次抖动都是违约,团队会在两周内学会忽略 SLO。
⚠️ 常见误区:把「可用性」等同于「进程活着」。99.9% 的 SLI 必须是用户视角(请求成功且够快),而不是「探测端口通」。
燃烧率:把「烧得快不快」变成一个数
定义:BR = 实际错误率 / 允许错误率 = error_rate / (1 − SLO)。BR = 1 表示「刚好在窗口内烧完预算」,BR = 2 表示「两倍速」。
SLO 99.9%(预算 43m12s)下的对照表:
| 燃烧率 BR | 对应错误率 | 预算烧完需要 | 1 小时消耗的预算 |
|---|---|---|---|
| 1.0 | 0.10% | 720h(30 天) | 0.1% |
| 2.0 | 0.20% | 360h(15 天) | 0.3% |
| 6.0 | 0.60% | 120h(5 天) | 0.8% |
| 14.4 | 1.44% | 50h(约 2 天) | 2.0% |
| 36.0 | 3.60% | 20h | 5.0% |
| 200.0 | 20.00% | 4h | 27.8% |
| 720.0 | 72.00% | 1h | 100.0% |
这段程序把两张表都算出来(可 go run):
package main
import (
"fmt"
"math"
"time"
)
const windowSec = 30 * 24 * 3600.0 // 滚动 30 天,单位秒
func d(sec float64) string { return (time.Duration(sec) * time.Second).Round(time.Second).String() }
func main() {
fmt.Println("| SLO | 30 天预算 | 折合每周 | 折合每年 | 每天可用时长 |")
fmt.Println("|---|---|---|---|---|")
for _, slo := range []float64{99, 99.5, 99.9, 99.95, 99.99} {
b := math.Round((1 - slo/100) * windowSec)
fmt.Printf("| %.2f%% | %s | %s | %s | %s |\n", slo, d(b), d(b*7/30), d(b*365/30), d(b/30))
}
const slo = 0.999
budget := (1 - slo) * windowSec
fmt.Printf("\nSLO 99.9%%:30 天预算 %.0f 秒 = %s\n\n", budget, d(budget))
fmt.Println("| 燃烧率 BR | 对应错误率 | 预算烧完需要 | 1 小时消耗的预算 |")
fmt.Println("|---|---|---|---|")
for _, br := range []float64{1, 2, 6, 14.4, 36, 200, 720} {
burnHours := windowSec / 3600 / br
burnPerHour := br * (1 - slo) * 3600 / budget * 100
fmt.Printf("| %.1f | %.2f%% | %.0fh | %.1f%% |\n", br, br*(1-slo)*100, burnHours, burnPerHour)
}
bad := 300.0 * 0.20 // 20% 错误率持续 5 分钟
fmt.Printf("\n20%% 错误率持续 5 分钟 → 消耗 %.0f 秒额度,占月预算 %.1f%%\n", bad, bad/budget*100)
fmt.Printf("错误率恒为 1%%(BR=10)→ 烧完整月预算需要 %.0f 小时\n", windowSec/3600/10)
}
输出(本机实测):
| SLO | 30 天预算 | 折合每周 | 折合每年 | 每天可用时长 |
|---|---|---|---|---|
| 99.00% | 7h12m0s | 1h40m48s | 87h36m0s | 14m24s |
| 99.50% | 3h36m0s | 50m24s | 43h48m0s | 7m12s |
| 99.90% | 43m12s | 10m4s | 8h45m36s | 1m26s |
| 99.95% | 21m36s | 5m2s | 4h22m48s | 43s |
| 99.99% | 4m19s | 1m0s | 52m31s | 8s |
SLO 99.9%:30 天预算 2592 秒 = 43m12s
| 燃烧率 BR | 对应错误率 | 预算烧完需要 | 1 小时消耗的预算 |
|---|---|---|---|
| 1.0 | 0.10% | 720h | 0.1% |
| 2.0 | 0.20% | 360h | 0.3% |
| 6.0 | 0.60% | 120h | 0.8% |
| 14.4 | 1.44% | 50h | 2.0% |
| 36.0 | 3.60% | 20h | 5.0% |
| 200.0 | 20.00% | 4h | 27.8% |
| 720.0 | 72.00% | 1h | 100.0% |
20% 错误率持续 5 分钟 → 消耗 60 秒额度,占月预算 2.3%
错误率恒为 1%(BR=10)→ 烧完整月预算需要 72 小时
读数:「5 分钟 20% 错误率」= 2.3% 预算。这个换算让「要不要现在救」不用争论——按这个速率烧 4 小时就烧完整月额度。
多窗口多燃烧率:长窗口判「在烧」,短窗口判「已停」
只用长窗口会有一个毛病:故障恢复了,1 小时错误率还挂在阈值上,告警继续响 50 分钟。只用短窗口则会误报(一个毛刺就触发)。标准做法是两个窗口同时满足才响:
| 消耗预算 | 长窗口 | 短窗口 | 级别 | 语义 |
|---|---|---|---|---|
| 2% / 1 h | 1h,BR > 14.4 | 5m,BR > 14.4 | P1 | 快烧,立刻叫人 |
| 5% / 6 h | 6h,BR > 6 | 30m,BR > 6 | P2 | 慢烧,开单跟进 |
| 10% / 3 d | 3d,BR > 1 | 6h,BR > 1 | P3 | 趋势劣化,进周会 |
短窗口的作用是**「复位」:故障停了,5 分钟窗口的 BR 立刻掉下来,告警自动恢复,不用等人手动关。长窗口的作用是「确认严重性」**:它避免「每 5 分钟一阵抖动」被当成 100 次独立故障。
规则的完整写法(PromQL + Alertmanager 配置)在 50.5 告警;告警触发后的值班响应、升级与交接见 56.3 on-call (值班 / 升级路径 / runbook)。
多窗口燃烧率:一份能上线的规则
groups:
- name: slo-burn
rules:
- alert: ErrorBudgetBurnFast
expr: |
route:http_errors:ratio5m{route="/checkout"} > (14.4 * 0.001)
and
route:http_errors:ratio5m{route="/checkout"} offset 5m > (14.4 * 0.001)
for: 2m
labels: {severity: page}
annotations:
summary: "checkout 错误预算燃烧过快(1h 窗口 14.4x)"
runbook_url: "https://runbook.example.com/checkout-5xx"
- alert: ErrorBudgetBurnSlow
expr: route:http_errors:ratio5m{route="/checkout"} > (6 * 0.001)
for: 30m
labels: {severity: ticket}
读法:SLO 99.9% → 错误预算 0.1%;14.4 × 0.001 = 1.44% 意味着「1 小时烧掉 2% 预算」,for: 2m 保证不是瞬时抖动;offset 5m 要求前后两个窗口都在烧,避免单点尖峰。慢烧 6 × 0.001 对应 6 小时窗口,for: 30m 只开工单、不叫人。
单测文件(promtool test rules 实测通过;rule_files 需同时加载 50.6 的 recording rules):
rule_files: [rules-recording.yml, rules-burn.yml]
evaluation_interval: 1m
tests:
- interval: 1m
input_series:
- series: 'http_requests_total{route="/checkout",code="200"}'
values: '0+800x20'
- series: 'http_requests_total{route="/checkout",code="500"}'
values: '0+200x20'
alert_rule_test:
- eval_time: 10m
alertname: ErrorBudgetBurnFast
exp_alerts:
- exp_labels: {severity: page, route: "/checkout"}
exp_annotations:
summary: "checkout 错误预算燃烧过快(1h 窗口 14.4x)"
runbook_url: "https://runbook.example.com/checkout-5xx"
promtool check rules rules-recording.yml rules-burn.yml
promtool test rules rules_test.yml
阈值不是拍的:14.4 与 6 来自「1h / 6h 窗口消耗 2% / 5% 预算」的换算(SRE Workbook 的多窗口表);换成你的 SLO,所有系数按 x / (1 - SLO) 重算。
预算策略:把「还剩多少」映射成「能做什么」
没有策略的 SLO 只是一张报表。有了策略才叫治理:
| 预算剩余 | 允许的动作 | 谁决定 |
|---|---|---|
| > 50% | 正常发布;可以承担风险、做架构升级、清理技术债 | 团队自主 |
| 20%–50% | 发布需要更慢的灰度(如 1%→5%→25%),变更窗口避开高峰 | 团队 + 值班确认 |
| < 20% | 冻结高风险变更(大重构、依赖升级、DDL),只允许修复类变更 | 技术负责人 |
| 0(已违约) | 只做回滚与稳定性修复,开专项、复盘;恢复后重新评估 SLO 是否合理 | 技术负责人 + 业务 |
两个补充规则,否则策略会被绕过:
- 预算按服务独立计算,不搞「公司总预算」——总额会让团队互相搭便车。
- 「临时豁免」必须留痕:谁批准、为什么、期限多久。没有留痕的豁免,三个月后就变成「SLO 从来没生效过」。
怎么起步:1 个服务 1–3 个 SLO
| 阶段 | 做法 | 不要做 |
|---|---|---|
| 第一周 | 选 1 个用户旅程(下单、支付、登录),定义 1 个可用性 SLI + 1 个延迟 SLI | 一上来给 40 个服务全定 SLO |
| 第一个月 | 只观测不惩罚:看真实达成率,校准目标(多数团队第一版定高了) | 用 SLO 做绩效考核 |
| 第二季度 | 接入预算策略、燃烧率告警,把「发布节奏」和预算挂钩 | 让 SLO 与告警各算一套(图上 99.9%、告警按 99.5%) |
判据:如果一个 SLO 从来没有阻止过任何一次发布,它大概定得太松;如果它每周都在阻止发布,要么目标定太高,要么系统真的该修了。
反模式
| 反模式 | 后果 |
|---|---|
| SLO 定 100% | 预算为 0,任何抖动都违约,团队很快学会无视 |
| 用「进程存活/端口探测」当 SLI | 服务 200 但每次 8 秒,SLO 依然 100% |
| 分母含健康检查、压测流量 | 分母虚高,可用性永远好看,故障被稀释 |
| 把客户端取消(499)算坏事件 | 用户没耐心 = 服务不可用,SLO 无意义地掉 |
| SLO 与告警用两套口径 | 图上达标、告警在响,团队不信任何一个 |
| 用日历月 + 月初冲量 | 「月初先把风险版本发了」,预算变成游戏 |
| 只算预算、不写策略 | 预算烧完没有任何后果,SLO 退化成报表 |
| 一个 SLO 覆盖所有接口 | 核心接口的问题被长尾接口的平均值盖住 |
自检
Q1 SLO 99.9%、滚动 30 天,一次 10 分钟的完全不可用,烧掉多少预算?剩下多少?
▸ 答案:预算 43m12s = 2592 秒;10 分钟 = 600 秒,占 23.1%,剩约 77%。这也是为什么「99.9% 没那么宽松」——一个月只够四次这样的故障。
Q2 为什么 SLO 用滚动 30 天,而告警用 1h/5m 双窗口?
▸ 答案:两者回答的问题不同。SLO 回答「这个季度我们的可靠性达标了吗」,需要长窗口来平滑;告警回答「现在要不要起床」,需要短窗口来快速发现与快速复位。如果把告警绑在 30 天窗口上,一次小故障会让告警连响 30 天;如果把 SLO 绑在 5 分钟窗口上,它会被毛刺反复打穿,失去「额度」的意义。
Q3 BR = 14.4 这个数字是怎么来的?为什么配 1h + 5m 两个窗口?
▸ 答案:BR 14.4 意味着 1 小时烧掉 2% 的月预算(14.4 × 1h × 0.1% ÷ 720h ≈ 2%)——「两小时内烧光」的速率足够紧急,又不至于被 10 分钟的抖动触发。长窗口 1h 确认「真的在持续烧」,短窗口 5m 确认「现在还在烧」,两者同时满足才叫人;故障一停,5m 窗口立刻复位。
Q4 「端口探测通、接口 200,但 P99 是 8 秒」,SLO 应该是多少?
▸ 答案:这个 SLI 抓错了对象:可用性 SLI 应定义为「成功且够快的请求占比」,即 status < 500 且 duration < 阈值。若只统计 status,8 秒的请求算「好事件」,SLO 会是 100%(虚假达标);把延迟并进 SLI 后,同一批流量可能只有 70% 达标,这才是用户感受到的可靠性。延迟本身另有独立的 SLI(如 P99 < 300ms 的请求占比)。
Q5 预算烧完以后,正确的动作是什么?
▸ 答案:按策略冻结高风险变更、只保留修复与回滚,同时做两件事:① 复盘这次烧预算的根因(不是「谁按了发布按钮」);② 重新评估 SLO 是否定得不合理——如果每个季度都烧完,说明要么系统投入不足,要么目标超出业务价值。反过来,如果预算长期 > 80% 剩余,说明 SLO 太松,应该收紧,而不是发奖。