Skip to content

可靠性度量:先写清分母,再谈几个 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[告警 发布决策与可靠性工作]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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.50

SLI 是 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%两个 97 小时 12 分钟3 天 15 小时 36 分钟
99.9%三个 943 分钟 12 秒8 小时 45 分钟 36 秒
99.99%四个 94 分钟 19.2 秒52 分钟 33.6 秒
99.999%五个 925.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;这不等于可以用工具限制来替业务决定目标。程序没有连接监控平台,没有验证真实埋点完整性,也没有执行任何合同计算。

依据 ​

  1. Google SRE:Service Level Objectives,Terminology、Aggregation:SLI/SLO/SLA 与聚合边界。
  2. Google SRE Workbook:Implementing SLOs:用户旅程、好事件比例、窗口和目标设计。
  3. Prometheus:Histograms and summaries:阈值比例、histogram 聚合和 summary 分位数的差异。
  4. Google SRE Workbook:Alerting on SLOs:burn rate、多窗口告警与低流量限制。
  5. Google SRE:Availability Table:时间可用性换算背景;本文表格按明示 30 天/365 天自行计算,并保留精确秒数。
  6. Google SRE Workbook:Example Error Budget Policy:把预算用于发布与可靠性工作的政策示例。
  7. 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.0

main.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")
	}
}

保留各书来源与许可 · 示例运行条件以正文为准