《互联网产品的服务端架构·产品、流量与基础能力》1.3 可用性、体验与合规的底线

把 99.9%、99.95%、99.99% 换算成每月允许的不可用时长,讨论延迟、错误率与降级后的功能底线,以及数据留存、注销删除与内容安全,并给出一段可测试的 Go 错误预算与 burn rate 实现,文中用第三方支付依赖只有 99.9% 的案例说明预算为何会提前耗尽。

合同里写了 99.99%

假设「回声」与一家企业客户签约,合同写明月度可用性 99.99%。团队觉得“我们平时挺稳的”,直到第一个月结束,客户拿着监控报表要求赔付:支付回调依赖的第三方支付网关当月不可用 37 分钟,而合同把“支付不可用”也算进了整体可用性。

团队争辩说第三方的问题不是自己的问题,客户只回了一句:用户不关心是谁的问题。更麻烦的是,内部 SLO 也定了 99.99%,但从来没有换算成“每月允许停多久”,也没有人负责在预算耗尽时踩刹车。告警每天在响,发布照常进行,直到月底才发现预算早就没了。

这一节把可用性从口号变成三个可计算的东西:允许的不可用时长、错误预算与 burn rate、以及体验和合规的底线。所有比例和时长都是案例假设值,运行输出除外。

先把百分比换算成时间

可用性百分比最容易被误读,因为 99.9% 和 99.99% 看起来只差一个 9,实际差 10 倍。按 30 天、43200 分钟计算:

SLO30 天允许不可用折算每天适用阶段
99.5%216 分钟约 7.2 分钟内部工具、早期验证
99.9%43.2 分钟约 1.44 分钟大多数对外产品的起步线
99.95%21.6 分钟约 43 秒有付费和口碑压力的成长期
99.99%4.32 分钟约 8.6 秒基础设施、金融级,需要多层冗余

表里的数字是 30 天窗口的算术结果,不包含“计划内维护是否计入”的口径差异。定 SLO 之前必须先约定:按请求成功率算还是按时间算、按自然月还是滚动 30 天、计划内发布算不算不可用。口径不统一,99.9% 可以变成任何数字。

定 SLO 前的三个口径问题

  • 分子分母是什么:按请求数、按事务数还是按时间?一次持续 10 分钟、影响 1% 用户的故障,在三种口径下差别很大。
  • 窗口怎么滚动:自然月、滚动 30 天还是按周?自然月会在月初“重置”,容易掩盖跨月故障。
  • 谁负责掏预算:预算耗尽时谁有权冻结发布?没有明确的 owner,预算就只是数字。

这三个问题不解决,后面的换算再精确也没有意义。SLO 的第一版可以粗,但口径必须写下来并被各方签字。

更关键的是依赖的串联。假设一个请求要依次经过网关、应用、数据库和第三方支付,四段可用性分别是 99.99%、99.95%、99.99% 和 99.9%,串联后的可用性约为 99.83%,连 99.9% 都不到。串联是乘法,不是取最小值;想让整体达到 99.99%,要么每一段都远高于 99.99%,要么用冗余、异步和降级把串联改成并联或可容忍失败。

体验底线与合规底线

可用性只回答“能不能用”,体验回答“好不好用”,合规回答“能不能这样做”。三者缺一,SLO 都是不完整的。

  • 延迟底线:假设核心接口 p99 不超过 800 毫秒,超过就算“体验不可用”,即使请求最终成功。延迟 SLO 要在客户端或边缘测量,不能只测服务端处理时间。
  • 错误率底线:假设 5 分钟内错误率超过 1% 触发告警,超过 5% 触发降级。错误率要按接口和端区分,不能用一个全局平均数掩盖支付接口的故障。
  • 降级后的可用功能:明确列出故障时哪些功能保留、哪些关闭、哪些转为异步。降级开关必须定期演练,没演练过的开关等于不存在。
  • 数据留存:假设订单和日志保留 180 天,隐私数据按最小化原则只保留必要字段,到期自动删除。
  • 注销与删除:假设用户提交注销后 30 天内完成删除,包括备份中的副本;只把状态改成 deleted 不算完成。
  • 内容安全:假设发布内容经过机审加人审,违规内容在规定时限内下架,并保留审计记录。

这些数字都是案例假设值,真实产品要按行业、地区和合同调整。但它们有一个共同点:都必须写进可执行的检查,而不是停留在 PRD 的形容词里。

底线清单怎么落地

一条底线要能落地,至少要有三样东西:指标、阈值、责任人。指标决定怎么量,阈值决定什么时候算越线,责任人决定越线后谁处理。三者缺一,底线就会在故障时变成争论。

  • 指标:从用户视角定义,例如“下单成功率”而不是“订单服务进程数”。
  • 阈值:写成可计算的规则,例如“连续 5 分钟低于 99%”。
  • 责任人:写到值班表和发布流程里,而不是默认“大家一起看”。

底线之所以叫底线,是因为它可以被验证,而不是因为它写得漂亮。每一条底线都应该对应一个监控项、一个演练或一个审计项;否则它只是产品文档里的一句愿望。

错误预算与 burn rate

SLO 的另一半是错误预算。99.9% 的 SLO 意味着 0.1% 的预算,30 天里允许 43.2 分钟不可用,或者允许 0.1% 的请求失败。预算不是“额度”,而是“风险预算”:花得快,说明系统正在以不可接受的速度变坏。

burn rate 是当前错误率相对预算的消耗速度。预算错误率是 1%,实际错误率是 2%,burn rate 就是 2,表示按这个速度会在窗口一半时耗尽预算;实际错误率低于预算时,burn rate 小于 1。工程上常用多窗口告警:短窗口抓突发,长窗口抓慢性消耗,避免“每天错一点,月底才发现”。

错误预算最重要的用途是发布门槛:预算充足时正常发布,预算消耗过快时冻结非必要变更,预算耗尽时只允许修复。如果预算从来没被用来阻止过一次发布,它就只是仪表盘上的装饰。

多窗口告警怎么设

单窗口的 burn rate 容易在两个方向出错:窗口太短,一次抖动就告警;窗口太长,慢性失血要到月底才发现。常见做法是同时看长短两个窗口,例如 5 分钟窗口 burn rate 超过 14.4 触发紧急告警,1 小时窗口超过 6 触发工单,6 小时窗口超过 1 触发复盘。

这些阈值不是标准答案,而是案例假设值。它们背后的原则是:越严重的消耗,越要用短窗口快速发现;越缓慢的消耗,越要用长窗口避免误报。阈值必须结合发布节奏和值班能力调整,不能从别的团队直接抄。

多窗口的代价是规则更多、解释成本更高。如果团队还没有稳定的错误计数和口径,先从一个窗口加人工复盘开始,比一上来就堆十几条规则更有效。

预算第一天就被第三方耗尽

回到开头的合同。整体 SLO 99.99%,30 天只允许 4.32 分钟不可用;第三方支付依赖只有 99.9%,单独一项每月就允许 43.2 分钟。两者串联后,整体可用性上限被压到 99.89% 左右,无论自己的代码多好,预算都会在第一次第三方故障时耗尽。

接下来的连锁反应很典型:第一个月赔付;第二个月团队把第三方故障从统计里剔除,SLO 报表变绿;第三个月客户发现统计口径变了,信任进一步下降。问题不在数学,而在定 SLO 时没有做依赖分解,也没有为“依赖不可用”设计降级路径。

可行的做法不是把 SLO 降到 99.9% 就结束,而是分层承诺:核心下单链路承诺 99.95%,支付失败时进入“待支付/待确认”状态并异步补偿,把第三方故障从“请求失败”转成“体验降级”;同时对第三方设置熔断、重试上限和幂等键,避免重试风暴。承诺可以略低于依赖的上限,但不能低于用户能感知的底线。

第二个失败场景是把“进程活着”当可用。监控只看进程和端口,进程 100% 在线,但数据库连接池耗尽,5% 的请求返回 500。SLO 看板全绿,用户却在流失。可用性必须按成功请求或成功事务计算,进程存活只能作为辅助信号。

第三个失败场景是合规承诺没有落地。产品页写“注销后 30 天内删除全部数据”,实现只改了用户表的状态字段,备份保留 180 天,日志里的手机号还在。平时没人发现,直到审计或用户投诉。合规底线和可用性一样,需要可验证的检查,而不是一句声明。

第四个失败场景是延迟只在服务端测。服务端处理 50 毫秒,但客户端要等 2 秒,因为 DNS、TLS、CDN 回源和排队都不在服务端的计时里。延迟 SLO 如果只覆盖应用内部,就会在用户体验变差时保持绿色。

还有一个组织层面的失败:SLO 由架构师单方面定,产品和运维不认。故障时架构师说“这是依赖问题”,运维说“告警太多已经静音”,产品说“用户只看到打不开”。SLO 如果没有和产品、运维、法务一起评审,就只是一份技术文档,不会变成发布门槛。

用一段 Go 代码把预算算清楚

错误预算和 burn rate 都适合写成纯函数:输入是 SLI、窗口和错误数,输出是允许的不可用时长与消耗速度。这样 SLO 评审时可以现场算,告警规则也可以复用同一套逻辑。

下面的 ErrorBudget 把 SLO 换算成窗口内允许的失败时长和失败比例;BurnRate 用观测到的成功率计算相对预算的消耗速度。代码保持最小,不包含多窗口、分位延迟和依赖分解,这些留给监控系统。

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

package slo

import "fmt"

// Budget 把 SLO 翻译成一段时间内允许的失败时长与失败请求比例。
type Budget struct {
	WindowMinutes float64
	AllowedDown   float64
	AllowedRatio  float64
}

// ErrorBudget 按日历窗口计算错误预算。windowDays 允许小数,便于按周复盘。
func ErrorBudget(sli float64, windowDays float64) (Budget, error) {
	if sli <= 0 || sli >= 1 {
		return Budget{}, fmt.Errorf("slo: sli must be in (0,1)")
	}
	if windowDays <= 0 {
		return Budget{}, fmt.Errorf("slo: window must be positive")
	}
	minutes := windowDays * 24 * 60
	return Budget{
		WindowMinutes: minutes,
		AllowedDown:   minutes * (1 - sli),
		AllowedRatio:  1 - sli,
	}, nil
}

// BurnRate 返回当前错误率相对预算的消耗速度。1 表示按预算节奏消耗,
// 大于 1 表示会在窗口结束前耗尽。
func BurnRate(errors, total int64, sli float64) (float64, error) {
	if sli <= 0 || sli >= 1 {
		return 0, fmt.Errorf("slo: sli must be in (0,1)")
	}
	if total <= 0 {
		return 0, fmt.Errorf("slo: total must be positive")
	}
	if errors < 0 || errors > total {
		return 0, fmt.Errorf("slo: errors must be in [0,total]")
	}
	observed := 1 - float64(errors)/float64(total)
	if observed >= sli {
		return 0, nil
	}
	return (1 - observed) / (1 - sli), nil
}

几个实现细节需要说明。

  • sli 必须在 (0,1) 之间。99.99% 要写成 0.9999,写成 99.99 会得到负数预算,这类输入错误必须在入口拦住。
  • windowDays 允许小数,便于按周或按滚动窗口复盘;内部统一换算成分钟,避免“按月”到底是 28、30 还是 31 天的争议。
  • BurnRate 在观测成功率不低于 SLO 时返回 0,而不是返回一个小于 1 的精确值。这是一个刻意的简化:它适合做“是否在消耗预算”的粗信号,不适合做精确的耗尽时间预测。
  • 错误数必须在 [0,total] 之间。超过总数的输入通常意味着统计口径错了,应该报错而不是截断。

还要强调,代码只计算“请求成功率”这一种 SLI。延迟 SLI、饱和度 SLI 和依赖可用性需要另外的模型;把所有东西塞进一个 burn rate 会让告警失去指向性。真实系统通常按用户旅程拆多个 SLI,每个 SLI 有自己的预算和告警阈值。

测试:换算和边界都要可回归

测试固定两类行为:99.9% 在 30 天窗口下允许 43.2 分钟;预算 1%、实际错误率 2% 时 burn rate 为 2,实际错误率低于预算时为 0。这样 SLO 口径变更时,至少会先看到这些数字变化。

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

package slo

import (
	"math"
	"testing"
)

func TestErrorBudget(t *testing.T) {
	got, err := ErrorBudget(0.999, 30)
	if err != nil {
		t.Fatal(err)
	}
	if got.WindowMinutes != 43200 {
		t.Fatalf("window = %f", got.WindowMinutes)
	}
	if math.Abs(got.AllowedDown-43.2) > 1e-9 {
		t.Fatalf("allowed down = %f, want 43.2", got.AllowedDown)
	}
	if _, err := ErrorBudget(1, 30); err == nil {
		t.Fatal("want error for sli=1")
	}
	if _, err := ErrorBudget(0.99, 0); err == nil {
		t.Fatal("want error for zero window")
	}
}

func TestBurnRate(t *testing.T) {
	// 预算 1%,实际错误率 2%,消耗速度应为 2 倍。
	got, err := BurnRate(20, 1000, 0.99)
	if err != nil {
		t.Fatal(err)
	}
	if math.Abs(got-2) > 1e-9 {
		t.Fatalf("burn = %f, want 2", got)
	}
	// 实际错误率低于预算时,不再消耗。
	got, err = BurnRate(5, 1000, 0.99)
	if err != nil {
		t.Fatal(err)
	}
	if got != 0 {
		t.Fatalf("burn = %f, want 0", got)
	}
	if _, err := BurnRate(1001, 1000, 0.99); err == nil {
		t.Fatal("want error when errors exceed total")
	}
}

第一个测试同时覆盖了 sli=1 和窗口为 0 的非法输入;第二个测试覆盖了错误数超过总数的非法输入。边界测试看起来琐碎,但 SLO 代码一旦在生产告警链路里跑,错误输入造成的误报比漏报更难排查。

测试没有覆盖多窗口告警、依赖串联和降级演练。这些属于系统和组织问题,不是单元测试能回答的;但它们可以用同一套预算语言来描述,例如“第三方故障消耗了本月 60% 的预算”。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/platform/slo/ -count=1

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

ok  	webapp/internal/platform/slo	0.330s

ok 表示换算与 burn rate 的测试全部通过。真实告警系统还需要把窗口、错误计数和 SLO 配置接进去,并定期用历史故障回放,确认告警不会太吵也不会太迟。

小结

可用性不是越高越好,而是越贵越好。99.9% 每月允许 43.2 分钟,99.99% 只允许 4.32 分钟;当依赖只有 99.9% 时,串联后的整体连 99.9% 都保不住。定 SLO 的第一步不是写数字,而是拆依赖、定口径、约定降级。

体验底线和合规底线要一起写进 SLO:延迟、错误率、降级功能、数据留存、注销删除、内容安全。错误预算的作用是让“预算没花完”成为发布门槛,而不是月底复盘时的一句感慨。

下一节进入第二章,从用例、用户旅程和系统边界开始,把产品事实进一步落到可评审的边界上。

阅读导航:上一节:1.2 用户量、请求量与成本的三条曲线 · 下一节:2.1 用例、用户旅程与系统边界 。

常见错误与边界

  • 只写百分比,不换算时间。团队对 99.99% 没有体感,就不会为 4.32 分钟做取舍。
  • 忽略依赖串联。串联可用性是乘法,99.9% 的依赖会让 99.99% 的承诺失效。
  • 统计口径不统一。按请求、按时间、按自然月还是滚动窗口,会得到完全不同的结果。
  • 把进程存活当可用。进程在线但请求失败,对用户就是不可用。
  • 降级开关从不演练。故障时第一次打开开关,通常会发现它依赖的组件也挂了。
  • 注销只改状态。备份、日志和下游副本没有删除,合规承诺就没有兑现。
  • 错误预算只做报表。预算不阻止发布,就不会改变任何行为。
  • 把 burn rate 当精确预测。它适合做粗信号,精确预测需要多窗口和更细的 SLI。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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