《互联网产品的服务端架构·规模化与架构演进》6.1 SLO 与错误预算

「回声」把可用性写进合同后,团队仍靠感觉判断能不能发布。本节把 SLI、SLO 与错误预算拆成可计算的承诺,给出成功率、错误预算与燃烧率的纯函数实现和测试,并复盘一次月底冲刺烧完错误预算却照常上线、最终触发真实客户赔付与合同口径争议的完整故障。

99.9% 写进合同之后,谁来解释那 0.1%

「回声」是一款面向中小商家的订单与会员管理产品。销售在续约合同里写了一行字:核心交易链路月度可用性不低于 99.9%(案例假设值)。这句话听上去很稳,直到第一个月结束,财务问了一个具体问题:上个月有 43 分钟下单接口返回 5xx,算不算违约?

工程团队的回答是“整体还行”,销售的回答是“客户没投诉”,法务的回答是“合同没定义”。三个回答都不算错,但都无法回答“这个月还能不能发布高风险变更”。因为团队从来没有把 99.9% 拆成可观测的分子、分母和窗口,也没有约定预算烧完之后怎么办。

这一节要解决的不是怎么写监控面板,而是先把“可靠性”变成一组可以计算、可以争论、可以执行的数字。SLI 定义观测什么,SLO 定义承诺到什么程度,错误预算定义还剩多少犯错空间。三者缺一,99.9% 就只是一句安慰。

SLI、SLO、SLA 说的不是同一件事

很多团队把三个词混着用,结果在事故复盘会上各说各话。把它们放进同一张表,差异会立刻显出来:

概念回答的问题「回声」的例子谁来定变了会怎样
SLI实际测到什么下单接口成功请求数 / 总请求数工程团队换口径等于换了一把尺子
SLO内部承诺做到多少30 天窗口内成功率不低于 99.9%工程与产品共同定影响发布节奏与预算
SLA对外违约要赔多少低于 99.9% 按比例返还服务费商务与法务直接影响收入与合同

这张表最重要的不是定义,而是“谁来定”。SLI 是测量口径,必须由最懂链路的人定;SLO 是内部目标,必须由工程和产品一起定,因为它直接约束发布;SLA 是对外合同,必须由商务和法务定,工程只能提供可达性评估。三者可以有差距,但不能互相冒充。

一个常见误区是拿 SLA 当 SLO。SLA 通常比 SLO 宽松,因为对外承诺要留赔付缓冲;如果内部也按 SLA 的底线运行,等发现快违约时已经没有调整空间。反过来,把 SLO 直接写进合同也很危险:内部为了推动可靠性定了一个激进目标,法务照抄给客户,最后赔的是公司。

99.9% 到底允许错多少次

假设「回声」每天有 200 万次下单请求(案例假设值),30 天就是 6000 万次。99.9% 的成功率意味着允许 6 万次失败。平均到每天是 2000 次,听上去不少;但如果一次发布事故在 10 分钟内打挂 30% 的流量,就会瞬间用掉 6000 次失败额度,相当于三天的预算。

这个换算必须提前做,而不是等月底再算。它决定了团队对“小故障”的容忍度和对“大变更”的谨慎程度。6 万次失败不是让你随便浪费,而是让你知道一次全挂十分钟和六十次零星超时,在预算上是同一量级。

窗口选择会改变结论

滚动 30 天、自然月、滚动 7 天,三种窗口回答的是不同问题。自然月适合对账和合同,但月初事故会在月底被“洗掉”;滚动 30 天适合持续改进,但一次大事故会压制未来一个月的发布;滚动 7 天更敏感,适合核心链路,但噪声也更大。

「回声」最终选了双窗口:核心交易用滚动 30 天,配合 1 小时和 6 小时的燃烧率告警;非核心的报表导出用自然月。双窗口不是为了好看,而是因为“长期是否达标”和“此刻是否在快速失血”本来就是两个问题。

错误预算是决策工具,不是考核指标

错误预算最常见的误用,是把它变成团队 KPI。一旦“预算剩余”和个人绩效挂钩,所有人都会倾向于把失败请求记成成功,或者把窗口调宽。预算的作用不是评判谁,而是在“继续发布”和“冻结变更”之间提供一条事先约定好的规则。

一个可执行的规则至少包含三档:

  • 预算剩余大于 50%:正常发布,按常规节奏做灰度。
  • 预算剩余在 10% 到 50%:只允许低风险变更,高风险变更需要额外评审。
  • 预算剩余低于 10%:冻结非修复性发布,直到预算恢复或完成专项治理。

规则必须提前写下来,并且在月度回顾里检查是否真的执行过。如果预算已经见底,团队还在照常发布,那这套数字就只是装饰。反过来,如果一看到预算下降就全面停摆,也会把可靠性变成业务的对立面。关键是阈值和动作要事先对齐,而不是事故发生时临时谈判。

燃烧率比剩余量更早报警

只看“预算还剩多少”有一个致命延迟:当剩余量明显下降时,事故往往已经持续了一段时间。燃烧率解决的是“现在烧得多快”。它等于当前错误率除以错误预算:SLO 是 99.9%,预算就是 0.1%;当前错误率 1%,燃烧率就是 10 倍。

燃烧率 10 倍意味着按这个速度,30 天的预算大约 3 天就会烧完。这个信号比“剩余 80%”有用得多,因为剩余 80% 可能是过去一直很稳,也可能正在快速失血。告警应该同时看短窗口和长窗口:1 小时燃烧率很高但 6 小时正常,可能是一次短暂抖动;两个窗口都高,才值得叫人。

预算也能反过来保护发布

很多人把错误预算理解成“限制工程团队”。其实它也在保护工程团队:当业务要求“这周必须上线”时,预算剩余量提供了一个客观依据,而不是靠谁声音大。预算充足时,团队可以更大胆地做灰度实验;预算紧张时,团队有理由拒绝不合理的赶工。

前提是预算口径可信。口径一旦被业务指标污染,例如把用户主动取消算成失败,或者把下游超时排除在分母外,预算就会失去约束力。SLI 的定义必须写进文档,并且每次变更都要走评审。

一次“月底冲一冲”的发布

事故发生在季度末。销售承诺客户在月底前上线批量导入功能,研发排期很紧。上线前,SLO 面板显示当月错误预算只剩 4%,按规则应该冻结非修复性发布。但项目群里有人说:“功能已经测过了,就差最后一步,不发要等下个季度。”

于是发布照常进行。新功能引入了一个没有限制并发的导入任务,上线后十分钟内,数据库连接池被占满,下单接口开始超时。值班同学先扩容应用实例,没有用,因为瓶颈在数据库;二十分钟后定位到导入任务,回滚又花了十五分钟。整场事故持续 47 分钟,失败请求约 18 万次,把当月剩余预算和下一个月的预算一起烧穿。

更严重的后果不在技术侧。客户按合同主张赔付,销售要求工程出具“不可抗力”说明,法务发现合同里的可用性定义和内部 SLI 口径不一致:合同按自然月、按接口维度算,内部按滚动 30 天、按整条链路算。两边算出来的数字不同,争论持续了两周。

复盘时,团队承认了三件事。第一,预算规则写了但没有执行入口,发布系统不会读预算状态。第二,导入任务没有并发上限,属于典型的新功能缺少资源隔离。第三,SLA 与 SLO 的口径没有对齐,导致对外解释时无法自证。第一件是流程问题,第二件是代码问题,第三件是契约问题,三者都不是“加强监控”能解决的。

这次事故之后,「回声」把预算状态接进了发布流水线:预算低于阈值时,高风险变更的发布按钮直接置灰,绕过需要两位负责人审批。同时把导入任务放进独立的工作队列,限制并发和单次批量大小。这些动作都有 owner 和截止时间,而不是停留在复盘文档里。

用纯函数把承诺算清楚

配套代码 internal/slo/slo.go 只做三件事:计算成功率、计算错误预算、计算燃烧率。它不读监控系统,不查数据库,也不发告警。把这些计算收敛成纯函数,是为了让“这个月到底算不算达标”可以被单元测试固定下来,而不是每次都在面板上手工对口径。

// file: internal/slo/slo.go
// tier: L1
// verify: go test ./internal/slo/

package slo

import "fmt"

// SLI 是一个观测窗口内的成功请求数与总请求数。
// 两个字段都用整数,避免浮点在聚合时引入累计误差;
// 计算比例时再做一次除法,调用方必须保证 Total 来自同一个窗口。
type SLI struct {
	Good  int64
	Total int64
}

// SLO 是一条对外承诺:在 WindowDays 天内,成功比例不低于 Target。
// Target 用 0 到 1 之间的小数表示,例如 0.999 表示 99.9%。
type SLO struct {
	Target     float64
	WindowDays int
}

// Ratio 返回 SLI 的成功比例。
// Total 为 0 时无法定义比例,直接报错而不是返回 0 或 1:
// 前者会被误读成“全部失败”,后者会被误读成“全部成功”。
func Ratio(s SLI) (float64, error) {
	if s.Total <= 0 {
		return 0, fmt.Errorf("slo: total must be positive, got %d", s.Total)
	}
	if s.Good < 0 || s.Good > s.Total {
		return 0, fmt.Errorf("slo: good %d out of range [0,%d]", s.Good, s.Total)
	}
	return float64(s.Good) / float64(s.Total), nil
}

// Budget 返回错误预算,即允许失败的请求比例。
// 它是 Target 的补集:Target 为 0.999 时,错误预算为 0.001。
func Budget(s SLO) float64 {
	return 1 - s.Target
}

// BurnRate 返回当前错误率相对错误预算的倍数。
// 1 表示按当前速度刚好在窗口结束时烧完预算;
// 大于 1 表示会提前烧完,小于 1 表示还有余量。
// 预算小于等于 0 的 SLO 无法计算倍数,直接报错。
func BurnRate(s SLI, slo SLO) (float64, error) {
	ratio, err := Ratio(s)
	if err != nil {
		return 0, err
	}
	budget := Budget(slo)
	if budget <= 0 {
		return 0, fmt.Errorf("slo: budget must be positive, got %f", budget)
	}
	return (1 - ratio) / budget, nil
}

几个设计点值得展开。

SLI 用两个整数而不是一个比例,是因为聚合场景很多。按小时、按机房、按接口分别采集后,合并时应该把 Good 和 Total 相加再相除,而不是对比例求平均。对比例求平均会让流量小的时段和流量大的时段权重相同,月底算出来的数字和真实用户体验对不上。

Ratio 拒绝 Total <= 0,也拒绝 Good 越界。零请求窗口在现实中会出现:新接口刚上线、定时任务暂停、某个机房没有流量。此时返回 0 或 1 都会污染预算计算,直接报错能让调用方决定“跳过这个窗口”还是“补数据”,而不是悄悄用一个错误的比例继续算。

Budget 刻意不校验 Target。它是一个简单的补集,调用方可以在 0 到 1 之外传入值,但 BurnRate 会拒绝非正的预算。把校验放在真正使用预算的地方,比在构造函数里堆一堆限制更容易解释。

BurnRate 返回的是倍数,不是百分比。倍数更直观:10 倍就是“按这个速度,窗口内预算会在十分之一的时间烧完”。如果返回百分比,还要再解释“相对于什么”,沟通成本更高。

代码没有解决“SLI 口径怎么定”。比例函数只能保证同样的输入算出同样的结果,不能保证输入本身有意义。把用户主动取消算成失败、把下游超时排除在分母外、把重试请求重复计数,这些都会让一个数学上正确的函数得出业务上错误的结论。口径评审必须在代码之外完成。

测试:正常值、边界值与零预算

这类计算最容易出的错不是公式,而是边界。下面的测试把正常比例、零总量、越界值、零预算和燃烧率的三档都固定下来。

// file: internal/slo/slo_test.go
// tier: L1
// verify: go test ./internal/slo/

package slo

import (
	"math"
	"testing"
)

func TestRatio(t *testing.T) {
	got, err := Ratio(SLI{Good: 9990, Total: 10000})
	if err != nil {
		t.Fatalf("Ratio: %v", err)
	}
	if math.Abs(got-0.999) > 1e-12 {
		t.Fatalf("Ratio = %v, want 0.999", got)
	}

	if _, err := Ratio(SLI{Good: 0, Total: 0}); err == nil {
		t.Fatal("want error for zero total")
	}
	if _, err := Ratio(SLI{Good: -1, Total: 10}); err == nil {
		t.Fatal("want error for negative good")
	}
	if _, err := Ratio(SLI{Good: 11, Total: 10}); err == nil {
		t.Fatal("want error for good greater than total")
	}
}

func TestBudget(t *testing.T) {
	cases := []struct {
		target float64
		want   float64
	}{
		{0.99, 0.01},
		{0.999, 0.001},
		{1, 0},
	}
	for _, c := range cases {
		got := Budget(SLO{Target: c.target})
		if math.Abs(got-c.want) > 1e-12 {
			t.Fatalf("Budget(%v) = %v, want %v", c.target, got, c.want)
		}
	}
}

func TestBurnRate(t *testing.T) {
	slo := SLO{Target: 0.999, WindowDays: 30}

	// 错误率 0.1%,正好等于预算,燃烧率为 1。
	got, err := BurnRate(SLI{Good: 9990, Total: 10000}, slo)
	if err != nil {
		t.Fatalf("BurnRate: %v", err)
	}
	if math.Abs(got-1) > 1e-9 {
		t.Fatalf("BurnRate = %v, want 1", got)
	}

	// 错误率 1%,是预算的 10 倍。
	got, err = BurnRate(SLI{Good: 9900, Total: 10000}, slo)
	if err != nil {
		t.Fatalf("BurnRate: %v", err)
	}
	if math.Abs(got-10) > 1e-9 {
		t.Fatalf("BurnRate = %v, want 10", got)
	}

	// 完全没有错误,燃烧率为 0。
	got, err = BurnRate(SLI{Good: 10000, Total: 10000}, slo)
	if err != nil {
		t.Fatalf("BurnRate: %v", err)
	}
	if got != 0 {
		t.Fatalf("BurnRate = %v, want 0", got)
	}

	if _, err := BurnRate(SLI{Good: 1, Total: 0}, slo); err == nil {
		t.Fatal("want error for zero total")
	}
	if _, err := BurnRate(SLI{Good: 1, Total: 2}, SLO{Target: 1}); err == nil {
		t.Fatal("want error for zero budget")
	}
}

TestRatio 用 math.Abs 和 1e-12 比较浮点,而不是直接 ==。这不是洁癖:float64(9990)/float64(10000) 在二进制里未必精确等于十进制的 0.999,直接比较会偶发失败。误差阈值要足够小,能抓住真实公式错误;也要足够大,能容纳浮点表示误差。

TestBudget 覆盖了 Target = 1 的退化情况。此时预算为 0,Budget 本身不报错,因为“没有犯错空间”是一个合法状态;真正使用预算的 BurnRate 才负责报错。这个分工让 Budget 可以安全地用于展示,而不会在面板上突然抛异常。

TestBurnRate 固定了三档:刚好烧完、十倍速烧完、完全不烧。最后两个错误分支分别覆盖零总量和零预算。测试没有覆盖“负成功率”或“目标大于 1”,因为这些属于配置错误,应该在加载配置时拒绝,而不是在计算函数里兜底。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/slo/ -run 'TestRatio|TestBudget|TestBurnRate' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestRatio
--- PASS: TestRatio (0.00s)
=== RUN   TestBudget
--- PASS: TestBudget (0.00s)
=== RUN   TestBurnRate
--- PASS: TestBurnRate (0.00s)
PASS
ok  	webapp/internal/slo	0.286s

三个用例全部通过,退出码为 0。-run 只筛选了与 SLO 计算相关的用例;同包还有故障分级与复盘动作的测试,需要全量确认时去掉 -run 再跑一次。

小结

SLO 不是一句口号,而是一组可以计算的承诺:SLI 定义看什么,SLO 定义看到多少算达标,错误预算定义还剩多少犯错空间。把三者分开,团队才能在“继续发布”和“冻结变更”之间做出可解释的决定。

错误预算的价值不在于数字本身,而在于它事先约定的行动。预算烧完却照常发布,等于没有预算;预算稍微下降就全面停摆,也会让可靠性变成业务的敌人。关键是阈值、动作和审批入口都要提前写清楚。

下一节讨论故障分级:当预算真的被烧穿,团队需要一套自动执行的呼人规则,而不是在群里问“这个要不要叫人”。

阅读导航:上一节:目录与学习路径 · 下一节:6.2 故障分级与应急 。

常见错误与边界

  • 把 SLA 当 SLO 用。对外合同通常比内部目标宽松,按合同底线运行会失去调整空间。
  • 用比例的平均值代替总量相除。流量小的时段会被过度加权,月底数字与真实体验不符。
  • 零请求窗口返回 0 或 1。两种默认值都会污染预算,应该显式报错或跳过。
  • 只看预算剩余量,不看燃烧率。剩余量下降时事故往往已经持续很久。
  • 预算规则没有执行入口。写在文档里但发布系统不读,等于没有规则。
  • 把预算和绩效挂钩。一旦挂钩,口径就会被污染,数字不再可信。
  • SLI 口径变更不走评审。换分母、换失败定义,等于换了一把尺子。
  • 只测正常值,不测零总量、越界值和零预算。边界才是事故发生的角落。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查