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 就会失真:

  1. 分母必须是「用户真的会发起的请求」:排除健康检查、内部探针、压测流量。
  2. 客户端取消不是服务端的错:499/context canceled 要从分母和分子里一起剔除,否则「用户没耐心」会被记成「服务不可用」。
  3. 慢也是坏:只统计 status 的 SLI 会把「全部 200 但都要 8 秒」判成 100% 可用。
  4. 窗口内的比例,不是瞬时值: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 太松,应该收紧,而不是发奖。

关联