主题
可靠性度量:先写清分母,再谈几个 9
服务监控显示成功率 99.99%,用户却说登录一直失败。可能是登录请求被海量健康检查稀释,也可能是服务端只统计到达它的请求,网关前的失败根本没进入分母。一个漂亮的百分比,只有在衡量了用户真正关心的操作后才有意义。
这篇文章说明怎样定义 SLI、设置 SLO、区分 SLA,再把错误预算和“几个 9”算对。文末程序使用有理数计算事件比例,避免把浮点舍入误差当成刚好达标或刚好超标;时间换算明确使用 30 天和 365 天。
SLI、SLO、SLA 分别负责测量、目标与约定
| 概念 | 含义 | 一个明确的例子 |
|---|---|---|
| SLI,服务水平指标 | 按固定规则测得的用户体验指标 | 最近 30 天,合格登录尝试中在 1 秒内成功完成的比例 |
| SLO,服务水平目标 | 指标在给定窗口内需要达到的目标 | 上述比例至少 99.9% |
| SLA,服务水平协议 | 对服务水平、范围与未达标后果的约定 | 合同约定的计量窗口、排除条件和补偿规则 |
SLA 是协议,内部 SLO 是工程目标,二者可以不同。内部目标可留出余量,以降低违反外部约定的风险,但具体数值需要结合用户需要与成本确定。不要把一条内部告警阈值随口称为对客户的 SLA,也不要从某个供应商营销页的百分比推断自己应用的端到端保证。[1]
SLO 也不只衡量可用性。交互 API 关心成功与时延,异步流水线关心数据是否按时处理,存储关心数据正确性、可恢复性与持久性。CPU 和内存可以帮助解释故障,但不是所有用户旅程都能由资源利用率代表。
一条可执行的 SLI 定义,至少包含这些字段
| 字段 | 必须回答的问题 | 示例 |
|---|---|---|
| 用户旅程 | 用户要完成什么? | 提交合法登录信息并拿到可用会话 |
| 合格事件 | 什么进入分母? | 指定产品入口的真实登录尝试,排除独立标记的探针 |
| 好事件 | 什么进入分子? | 按业务规则成功,且端到端耗时不超过 1 秒 |
| 观测位置 | 从哪里看用户结果? | 边缘网关与客户端联合校验,而非只看内部 handler |
| 窗口 | 哪段时间?按什么时区或滚动规则? | 连续滚动 30×24 小时 |
| 排除和未知 | 客户端误用、监控丢数据怎么算? | 预先定义;缺测单列,不自动当成功 |
| 切片 | 哪些子人群需单独观察? | 区域、重要租户、接口、客户端版本 |
“好事件 / 合格事件”是一个实用的比例型 SLI。定义必须能落到可采集字段和可复核查询,不能只写“服务正常”。Google SRE Workbook 特别强调由用户需要选择指标与数据来源,而非从现有仪表盘挑几个容易看的数。[2]
HTTP 状态码只是线索。200 也可能返回错误业务数据;4xx 可能是用户输入不合法,也可能是服务端错误拒绝了本应成功的请求。是否排除某类错误,应该在 SLI 规范里预先确定并保留原始计数,不能出事故后临时删掉难看的样本。
把重试放进分母也会改变含义。若用户点一次登录,内部请求重试三次才成功,用户旅程 SLI 可以记一次成功,但还应单独记录延迟和依赖尝试失败。每次内部尝试各算一个事件则是在衡量依赖调用质量。两者都有价值,混在一起却无法解释。
正在绘制图示…
查看图源代码
flowchart LR
A[用户操作及观测事件] --> B[按规范判断是否合格]
B --> C[合格事件总数]
B --> D[成功且符合时延或正确性条件的好事件]
C --> E[同一窗口计算 SLI]
D --> E
E --> F[与 SLO 比较]
F --> G[计算预算及消耗速度]
G --> H[告警 发布决策与可靠性工作]
没有流量不等于百分之百成功
本地函数先校验 0 ≤ good ≤ total,再计算精确比例。如果窗口没有合格事件,它返回一个明确的无数据状态:
go
_, err := SLI(0, 0)
fmt.Println("empty window:", err)实际输出:
text
empty window: no eligible events把 0/0 改成 100% 会掩盖流量中断或指标采集失败;改成 0% 又可能把正常无访问时段当事故。无流量和监控故障需要额外信号区分,例如入口总流量、指标心跳或合成探测。合成探测可以补充低流量服务观测,但它的样本、网络路径和权重也要公开。
多个实例的成功率也不能直接平均。假设 A 有 1000 次请求、999 次好事件,B 有 10 次请求、5 次好事件:
| 算法 | 结果 | 解释 |
|---|---|---|
| 简单平均 99.9% 与 50% | 74.95% | 给两个实例同样权重,回答的是另一问题 |
| 先加好事件和总事件 | 1004 / 1010,约 99.4059% | 每个请求权重相同的总体请求 SLI |
程序输出 weighted SLI: 0.994059。如果真正关心每租户公平性,可以另外设计按租户加权的指标,但要明确权重,不能把它误写成总请求成功率。总体值仍可能掩盖 B 的严重失败,所以需要有意义的切片,而非只保留一个全局数字。[1][2]
延迟也有类似聚合问题。不能简单平均各实例 p99,得到全局 p99。Prometheus histogram 可以聚合桶计数再估计分位数;summary 的客户端分位数不具备同样的可加性。若 SLO 是“99% 请求小于 300ms”,直接统计阈值内的好事件比例通常比每天盯一个 p99 更容易跟错误预算接上。[3]
错误预算是容许的坏事件量
对事件比例 SLO,在一个已经确定的窗口中:
- 容许坏比例 = 1 − SLO
- 容许坏事件数 = 合格事件数 × 容许坏比例
- 实际坏事件数 = 合格事件数 − 好事件数
- 剩余预算 = 容许坏事件数 − 实际坏事件数
假设 30 天累计一百万次合格请求,SLO 为 99.9%,其中 999500 次是好事件。文末程序实际输出:
text
SLI: 0.999500 allowed: 1000 remaining: 500 burn: 0.50SLI 是 99.95%,最多允许 1000 次坏事件,实际用了 500 次,剩余 500 次。burn: 0.50 表示这一窗口的坏事件比例是允许比例的一半;它不表示“剩余 50 分钟”,也不表示某个未来短窗口一定继续以同样速率消耗。
程序用 big.Rat 保持精确分数:
go
fraction := new(big.Rat).Sub(big.NewRat(1, 1), slo)
allowed := new(big.Rat).Mul(new(big.Rat).SetInt64(total), fraction)
remaining := new(big.Rat).Sub(allowed, new(big.Rat).SetInt64(total-good))如果总请求数是 500、SLO 是 99.9%,容许量是 0.5 个坏事件。业务事件不能发生半次,所以一次失败就会超出目标;不能四舍五入成“允许 1 次”却继续声称完全相同的 SLO。低流量服务尤其需要结合窗口长度和用户重要性设计目标。
滚动窗口的分母会随着新请求加入、旧请求移出而变化,剩余预算也会变化。它不是预先固定的一张错误次数券。日历月窗口则有明确起止点,但月长不同;报表必须区分这两种方式。[2]
Burn rate 把严重程度和持续时间连起来
燃烧率通常定义为:观察窗口内坏事件比例 / (1 − SLO)。SLO 为 99.9% 时,允许坏比例是 0.1%;若短窗口坏比例为 1%,burn rate 是 10。
| 坏比例 | 对 99.9% SLO 的 burn rate | 意义 |
|---|---|---|
| 0.05% | 0.5 | 低于允许的平均坏比例 |
| 0.1% | 1 | 正好等于目标允许比例 |
| 1% | 10 | 同口径下以十倍比例出现坏事件 |
| 100% | 1000 | 完全失败,预算可能快速消耗 |
对固定 30 天的时间预算,14.4 倍速度持续 1 小时,消耗 14.4 × 1 / 720 = 2% 的总预算。程序实际打印:
text
14.4x for 1h consumes 2.0% of 30d time budget对请求型预算,不能不看流量就把这个时间换算照搬。只有短窗口流量相对长期窗口具有相应代表性、未来速率假设成立时,才可以用时间比例近似预测。突发高峰的一小时可能占一个月大量请求,低谷的一小时也可能几乎没有;精确消耗要用坏事件数和长期分母计算。
Google SRE Workbook 讨论多窗口、多 burn-rate 告警:较长窗口确认显著预算消耗,较短窗口确认问题仍在持续;更慢的消耗可以走较低紧急程度的通知。示例阈值如 14.4 倍并非所有团队通用默认值,窗口、响应时间和低流量限制需要一起设计。[4]
告警也不该只在“预算已经耗尽”才响。接近目标上限时才通知,可能来不及止损;另一方面,单个短暂错误就叫醒值班人员也会造成噪声。提前确定哪些速度需要立即处理、哪些进入工作队列,并验证指标缺失和零分母时的行为。
几个 9 的时间换算,先固定窗口
只有在定义为“好时间 / 合格时间”的时间可用性口径下,才能直接换算允许不可用时长:
允许不可用时长 = 窗口总时长 × (1 − 可用性目标)
下表假设全天 24 小时、窗口没有任何排除项,分别取整整 30 天和 365 天。365 天不是所有自然年的长度,30 天也不是每个月的长度。[5]
| 目标 | 通称 | 30 天允许不可用 | 365 天允许不可用 |
|---|---|---|---|
| 99% | 两个 9 | 7 小时 12 分钟 | 3 天 15 小时 36 分钟 |
| 99.9% | 三个 9 | 43 分钟 12 秒 | 8 小时 45 分钟 36 秒 |
| 99.99% | 四个 9 | 4 分钟 19.2 秒 | 52 分钟 33.6 秒 |
| 99.999% | 五个 9 | 25.92 秒 | 5 分钟 15.36 秒 |
对应程序的原始输出:
text
2 nines: 30d=7h12m0s 365d=87h36m0s
3 nines: 30d=43m12s 365d=8h45m36s
4 nines: 30d=4m19.2s 365d=52m33.6s
5 nines: 30d=25.92s 365d=5m15.36s请求成功率 99.9% 则允许 0.1% 的合格请求失败,不能自动换成 43 分钟停机。高峰时一分钟失败可能损失很多请求,夜间一分钟可能没有请求;部分地区或部分接口故障也很难被一个二元“整站停机”时长表达。
时间型口径本身也要定义:是每秒都要全站可用,还是每一分钟超过某成功阈值算好窗口?后者的统计对象是时间窗,会把窗内细节压缩掉。计划维护是否排除、区域是否分别计量、部分功能是否纳入,须按内部 SLO 规范或具体 SLA 条款执行。这里的换算表不解释任何实际合同。
预算用来安排工作,不是替故障开脱
预算未耗尽,不代表可以主动制造用户损失;预算耗尽,也不意味着永远禁止任何变更。团队可以预先约定:消耗过快时暂停高风险发布,优先修复主要错误来源;紧急修复和安全变更走专门评估;恢复到合适水平后再恢复正常节奏。Google 的错误预算策略示例展示的是这种决策协议,而不是全行业必须照抄的规则。[6]
一份有用的政策应说明负责人、窗口、数据源、触发条件、可暂停哪些工作、例外如何审批,以及事故后如何重新评估。不能为了让预算好看,临时改坏事件定义、删区域或排除刚发生的故障;口径变更要保留版本和可对比历史。
不同 SLI 的预算也不能随意相互抵扣。成功率达标,但所有响应都慢到用户无法使用,仍可能违背延迟目标;数据丢失更不能靠多返回几次 200 抵消。针对关键用户旅程保留少量有解释力的指标,再用内部诊断指标查原因,比收集几十个互相稀释的“总体健康分”更容易行动。
本次验证与边界
Go 1.27.2 / Linux amd64 实测了精确 SLI、预算、超预算负数、加权合并以及时间换算;go test、go test -race、go vet 通过。无事件、非法计数、零时长和 100% SLO 的零 burn-rate 分母均有负测试。
100% 可以作为某类严格业务约束的目标,但在本文的错误预算除法中分母为零,所以计算器拒绝计算有限 burn rate;这不等于可以用工具限制来替业务决定目标。程序没有连接监控平台,没有验证真实埋点完整性,也没有执行任何合同计算。
依据
- Google SRE:Service Level Objectives,Terminology、Aggregation:SLI/SLO/SLA 与聚合边界。
- Google SRE Workbook:Implementing SLOs:用户旅程、好事件比例、窗口和目标设计。
- Prometheus:Histograms and summaries:阈值比例、histogram 聚合和 summary 分位数的差异。
- Google SRE Workbook:Alerting on SLOs:burn rate、多窗口告警与低流量限制。
- Google SRE:Availability Table:时间可用性换算背景;本文表格按明示 30 天/365 天自行计算,并保留精确秒数。
- Google SRE Workbook:Example Error Budget Policy:把预算用于发布与可靠性工作的政策示例。
- Go 1.27.2 math/big.Rat、time.Duration:完整示例使用的有理数和纳秒时长。
完整可复现计算器与测试
只有标准库,不读取生产数据。时间程序支持 1 至 9 个 9;对不能整除的任意纳秒窗口,整数时长结果向下截断到纳秒,本文表格中的窗口与目标均可精确表示。
sh
go run .
go test -v ./...
go test -race ./...
go vet ./...go.mod
go
module example.com/measurement
go 1.27.0main.go
代码块 · 63 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
"math/big"
"time"
)
var ErrNoData = errors.New("no eligible events")
func SLI(good, total int64) (*big.Rat, error) {
if total == 0 && good == 0 {
return nil, ErrNoData
}
if total <= 0 || good < 0 || good > total {
return nil, errors.New("invalid counts")
}
return new(big.Rat).SetFrac64(good, total), nil
}
func Budget(good, total int64, slo *big.Rat) (allowed, remaining, burn *big.Rat, err error) {
sli, e := SLI(good, total)
if e != nil {
return nil, nil, nil, e
}
if slo == nil || slo.Sign() <= 0 || slo.Cmp(big.NewRat(1, 1)) >= 0 {
return nil, nil, nil, errors.New("SLO must be between 0 and 1")
}
fraction := new(big.Rat).Sub(big.NewRat(1, 1), slo)
allowed = new(big.Rat).Mul(new(big.Rat).SetInt64(total), fraction)
remaining = new(big.Rat).Sub(allowed, new(big.Rat).SetInt64(total-good))
burn = new(big.Rat).Quo(new(big.Rat).Sub(big.NewRat(1, 1), sli), fraction)
return allowed, remaining, burn, nil
}
func Downtime(window time.Duration, nines int) (time.Duration, error) {
if window <= 0 || nines < 1 || nines > 9 {
return 0, errors.New("invalid window or nines")
}
denom := int64(1)
for range nines {
denom *= 10
}
return window / time.Duration(denom), nil
}
func main() {
for _, n := range []int{2, 3, 4, 5} {
d30, _ := Downtime(30*24*time.Hour, n)
d365, _ := Downtime(365*24*time.Hour, n)
fmt.Printf("%d nines: 30d=%s 365d=%s\n", n, d30, d365)
}
slo := big.NewRat(999, 1000)
a, r, b, err := Budget(999500, 1000000, slo)
if err != nil {
panic(err)
}
sli, _ := SLI(999500, 1000000)
fmt.Println("SLI:", sli.FloatString(6), "allowed:", a.RatString(), "remaining:", r.RatString(), "burn:", b.FloatString(2))
_, err = SLI(0, 0)
fmt.Println("empty window:", err)
combined, _ := SLI(999+5, 1000+10)
fmt.Println("weighted SLI:", combined.FloatString(6))
fmt.Printf("14.4x for 1h consumes %.1f%% of 30d time budget\n", 14.4/720*100)
}main_test.go
代码块 · 37 行 · 点击收起 / 展开
go
package main
import (
"errors"
"math/big"
"testing"
"time"
)
func TestCountsAndNoData(t *testing.T) {
if _, err := SLI(0, 0); !errors.Is(err, ErrNoData) {
t.Fatal(err)
}
for _, p := range [][2]int64{{2, 1}, {-1, 10}, {1, 0}} {
if _, err := SLI(p[0], p[1]); err == nil {
t.Fatal(p)
}
}
}
func TestExactBudgetAndExhaustion(t *testing.T) {
a, r, b, err := Budget(998000, 1000000, big.NewRat(999, 1000))
if err != nil || a.Cmp(big.NewRat(1000, 1)) != 0 || r.Cmp(big.NewRat(-1000, 1)) != 0 || b.Cmp(big.NewRat(2, 1)) != 0 {
t.Fatal(a, r, b, err)
}
if _, _, _, err = Budget(1, 1, big.NewRat(1, 1)); err == nil {
t.Fatal("100 percent has no finite burn denominator")
}
}
func TestTimeConversion(t *testing.T) {
d, err := Downtime(365*24*time.Hour, 5)
if err != nil || d != 315360*time.Millisecond {
t.Fatal(d, err)
}
if _, err = Downtime(0, 3); err == nil {
t.Fatal("zero window")
}
}