预算只剩 8%,还能不能发版
2026 年 10 月 20 日,arch-lab 订单服务本月错误预算只剩 8%,发布负责人仍计划上线一个结算逻辑改动。反对意见是“预算快没了,冻结”;支持意见是“还有 8%,够发一次”。同一天,另一个团队的月度预算还剩 60%,但最近一小时失败率是允许值的 20 倍,他们却在正常发版。
两个团队的直觉都只对了一半。预算剩余回答“这个周期还剩多少额度”,燃尽速率回答“照当前速度还能撑多久”。只看剩余,会在快速恶化时错过止损;只看速率,会在一次短脉冲后过度冻结。发布门禁需要同时看这两个维度,并且明确“没有数据”不是“健康”。
错误预算的概念在错误预算与韧性工程 中已有完整介绍,本节把它落到可执行的判定顺序和代码。
预算是速率,不是整数
Budget.Allowed 等于 (1 - target) * total。99.9% 的目标、100 万个有效事件,允许 1000 个坏事件;如果目标是 99.99%、200 万个事件,允许 200 个。这个数通常不是整数:28 天、共 4.32 万个请求,允许的坏事件是 43.2 个。把它四舍五入成 43,会在边界上产生“看起来没超、实际已超”的争议。
Remaining 允许为负。负预算表示这个周期已经无法达标,不是“差一点”。把它截断到 0 会让看板显示“刚好用完”,掩盖已经超支的事实。RemainingRatio 用剩余除以允许值,1 表示未动,0 表示刚好用完,负数表示超支;门禁阈值都建在这个比例上。
| 状态 | 允许 | 已用 | 剩余 | 剩余比例 | 含义 |
|---|---|---|---|---|---|
| 健康 | 1000 | 100 | 900 | 0.90 | 可正常发布 |
| 偏低 | 1000 | 800 | 200 | 0.20 | 只允许灰度 |
| 耗尽 | 1000 | 980 | 20 | 0.02 | 冻结 |
| 超支 | 1000 | 1300 | -300 | -0.30 | 本月已违约 |
燃尽速率要除以允许比例
燃尽速率的定义是“观测到的坏事件比例”除以“允许的坏事件比例”,即 (bad/total) / (1-target)。1 倍表示按当前速度,预算刚好在周期结束时用完;14.4 倍表示照这个速度,约 1/14.4 个周期就会烧完。直接把 bad/total 当速率是常见错误:0.2% 的失败率在 99.9% 目标下是 2 倍,在 99% 目标下只有 0.2 倍,离开目标谈速率没有意义。
单窗口速率的问题是抖动。一次持续 5 分钟的发布抖动可以把 5 分钟窗口推到 100 倍,但 6 小时后可能已经完全恢复。多窗口规则要求长窗口和短窗口同时超阈值:长窗口证明“不是瞬时脉冲”,短窗口证明“现在还在烧”。常见组合是 1 小时/5 分钟对应 14.4 倍,6 小时/30 分钟对应 6 倍,3 天/6 小时对应 1 倍。
门禁是一个有顺序的判定
判定顺序必须是:先看数据是否足够,再看是否在快速燃烧,再看预算剩余。把顺序反过来,会出现“预算还剩很多,所以忽略 20 倍燃尽”的放行;或者“预算为负,但仍然按惯例发版”的惯性。数据不足时返回 hold,而不是 allow:低流量时段的“0 个坏事件”不能证明系统健康。
三档动作要写清退出条件。allow 是正常发布;canary-only 只允许小流量灰度,并且必须带自动回滚(见回滚与兼容窗口 );freeze 停止所有非修复发布。冻结不是惩罚,而是把剩余预算留给未知故障。退出条件通常是“连续 N 个短窗口回落到阈值以下,且剩余预算高于冻结线”,不能靠负责人拍脑袋解冻。
| 判据 | 条件 | 动作 | 退出条件 |
|---|---|---|---|
| 数据不足 | 有效事件 < 最小值 | hold | 样本恢复后重算 |
| 快速燃烧 | 多窗口 firing 且速率 ≥ 上限 | freeze | 短窗口连续 6 次低于阈值 |
| 预算耗尽 | 剩余比例 < 冻结线 | freeze | 下个周期或预算回补 |
| 预算偏低 | 剩余比例 < 灰度线 | canary-only | 剩余比例回到灰度线以上 |
| 健康 | 其余 | allow | 不适用 |
可运行实现
Budget 负责算术,BurnSample 负责单窗口速率,EvaluateBurn 负责多窗口判定,GatePolicy.Decide 负责把状态翻译成动作。四者都是纯函数,时间由调用方传入,测试可以固定时钟。
// file: internal/platform/slo/budget.go
// tier: L1
// verify: go test ./internal/platform/slo/ -count=1 -run 'TestBudget|TestBurn|TestGate'
package slo
import (
"errors"
"fmt"
"math"
"time"
)
// Budget is the error allowance for one SLO over one period.
type Budget struct {
Target float64 // e.g. 0.999
Total int // valid events in the period
Bad int // events that were not good
}
func (b Budget) Validate() error {
if b.Target <= 0 || b.Target >= 1 {
return fmt.Errorf("slo: target %.6f outside (0,1)", b.Target)
}
if b.Total < 0 || b.Bad < 0 || b.Bad > b.Total {
return fmt.Errorf("slo: bad=%d total=%d is not a valid sample", b.Bad, b.Total)
}
return nil
}
// Allowed stays fractional: 43.2 errors per 28 days is a rate, and rounding it
// to 43 hides 0.2.
func (b Budget) Allowed() float64 { return (1 - b.Target) * float64(b.Total) }
// Remaining may go negative; clamping it would hide overspend.
func (b Budget) Remaining() float64 { return b.Allowed() - float64(b.Bad) }
// RemainingRatio is 1 when untouched, 0 when exactly spent, negative when the
// SLO is already missed for this period.
func (b Budget) RemainingRatio() float64 {
if allowed := b.Allowed(); allowed > 0 {
return b.Remaining() / allowed
}
return 0
}
// BurnSample is one lookback window used for burn-rate evaluation.
type BurnSample struct {
Window time.Duration
Total int
Bad int
}
// Rate is the bad ratio divided by the allowed bad ratio. 1.0 spends the
// period budget exactly over the period. An empty window yields NaN instead of
// a healthy zero.
func (s BurnSample) Rate(target float64) float64 {
if s.Total == 0 || target <= 0 || target >= 1 {
return math.NaN()
}
return (float64(s.Bad) / float64(s.Total)) / (1 - target)
}
// BurnPolicy is a multi-window rule: the long window proves the burn is
// sustained, the short window proves it is still happening.
type BurnPolicy struct {
Long, Short time.Duration
LongThreshold, ShortThreshold float64
MinEvents int
}
func (p BurnPolicy) Validate() error {
if p.Long <= 0 || p.Short <= 0 || p.Short > p.Long {
return fmt.Errorf("slo: burn windows long=%s short=%s are invalid", p.Long, p.Short)
}
if p.LongThreshold <= 0 || p.ShortThreshold <= 0 || p.MinEvents < 0 {
return errors.New("slo: burn thresholds and minimum events must be positive")
}
return nil
}
// BurnResult is one multi-window evaluation at a fixed end time.
type BurnResult struct {
LongRate, ShortRate float64
Long, Short BurnSample
Data, Firing bool
}
// EvaluateBurn counts events in both windows and applies the thresholds.
func EvaluateBurn(events []Event, s SLI, end time.Time, target float64, p BurnPolicy) (BurnResult, error) {
if err := s.Validate(); err != nil {
return BurnResult{}, err
}
if err := p.Validate(); err != nil {
return BurnResult{}, err
}
if target <= 0 || target >= 1 {
return BurnResult{}, fmt.Errorf("slo: target %.6f outside (0,1)", target)
}
long, err := countSample(events, s, Rolling(end, p.Long))
if err != nil {
return BurnResult{}, err
}
short, err := countSample(events, s, Rolling(end, p.Short))
if err != nil {
return BurnResult{}, err
}
r := BurnResult{Long: long, Short: short, LongRate: long.Rate(target), ShortRate: short.Rate(target)}
r.Data = long.Total >= p.MinEvents && short.Total >= p.MinEvents
r.Firing = r.Data && r.LongRate >= p.LongThreshold && r.ShortRate >= p.ShortThreshold
return r, nil
}
func countSample(events []Event, s SLI, w Window) (BurnSample, error) {
m, err := Measure(events, s, w, 0)
if err != nil {
return BurnSample{}, err
}
return BurnSample{Window: w.End.Sub(w.Start), Total: m.Valid, Bad: m.Valid - m.Good}, nil
}
// Verdict is a release decision. Hold means the data cannot support a
// decision, which is not the same as allow.
type Verdict string
const (
VerdictAllow Verdict = "allow"
VerdictCanary Verdict = "canary-only"
VerdictFreeze Verdict = "freeze"
VerdictHold Verdict = "hold-no-data"
)
// GatePolicy converts budget state into a release decision.
type GatePolicy struct {
FreezeBelow float64 // remaining ratio that freezes all releases
CanaryBelow float64 // remaining ratio that limits releases to canary
MaxBurnRate float64 // sustained burn that freezes regardless of budget
MinEvents int
}
func (p GatePolicy) Validate() error {
if p.FreezeBelow < 0 || p.CanaryBelow < p.FreezeBelow || p.MaxBurnRate <= 0 || p.MinEvents < 0 {
return fmt.Errorf("slo: invalid gate policy %+v", p)
}
return nil
}
// Decide returns the verdict and a one-line reason for the release ticket.
func (p GatePolicy) Decide(b Budget, burn BurnResult) (Verdict, string, error) {
if err := b.Validate(); err != nil {
return "", "", err
}
if err := p.Validate(); err != nil {
return "", "", err
}
if b.Total < p.MinEvents {
return VerdictHold, fmt.Sprintf("only %d valid events, need %d", b.Total, p.MinEvents), nil
}
remaining := b.RemainingRatio()
if burn.Firing && burn.LongRate >= p.MaxBurnRate {
return VerdictFreeze, fmt.Sprintf("burn %.2fx over %.2fx limit", burn.LongRate, p.MaxBurnRate), nil
}
if remaining < p.FreezeBelow {
return VerdictFreeze, fmt.Sprintf("remaining %.1f%% below freeze %.1f%%", remaining*100, p.FreezeBelow*100), nil
}
if remaining < p.CanaryBelow {
return VerdictCanary, fmt.Sprintf("remaining %.1f%% below canary %.1f%%", remaining*100, p.CanaryBelow*100), nil
}
if !burn.Data {
return VerdictAllow, fmt.Sprintf("remaining %.1f%%; burn windows below %d events, budget-only", remaining*100, p.MinEvents), nil
}
return VerdictAllow, fmt.Sprintf("remaining %.1f%%, burn %.2fx", remaining*100, burn.LongRate), nil
}
实现里有两个边界值得单独说。第一,空窗口速率返回 NaN 而不是 0,Data 字段单独回答“样本够不够”;这样不会把“没有请求”误判成“没有失败”。第二,门禁先判 burn 再判预算:一个还剩 60% 预算但正在以 20 倍速度燃烧的服务,必须冻结,因为按当前速度它会在周期结束前烧完。
本机实测
测试覆盖预算算术、单窗口速率、多窗口燃尽和三档门禁。环境为 macOS darwin/arm64、Go 1.26.0,完整输出在 .local/reports/be-arch-gov/ch07-full.txt。
=== RUN TestBudgetArithmetic
budget_test.go:25: ten percent left allowed=1000.00 bad=900 remaining=100.00 ratio=0.100
budget_test.go:25: overspent allowed=1000.00 bad=1300 remaining=-300.00 ratio=-0.300
budget_test.go:25: fine grained allowed=200.00 bad=50 remaining=150.00 ratio=0.750
--- PASS: TestBudgetArithmetic (0.00s)
=== RUN TestBurnRate
budget_test.go:47: window=1h0m0s total=10000 bad=10 rate=1.00
budget_test.go:47: window=6h0m0s total=60000 bad=120 rate=2.00
budget_test.go:47: window=1h0m0s total=5000 bad=0 rate=0.00
budget_test.go:47: window=1h0m0s total=0 bad=0 rate=NaN
--- PASS: TestBurnRate (0.00s)
=== RUN TestBurnMultiWindow
budget_test.go:62: during long=25.00x short=200.00x firing=true
budget_test.go:71: after long=25.00x short=0.00x firing=false
--- PASS: TestBurnMultiWindow (0.00s)
=== RUN TestGateDecisions
budget_test.go:98: healthy verdict=allow reason="remaining 90.0%; burn windows below 1000 events, budget-only"
budget_test.go:98: budget low verdict=canary-only reason="remaining 20.0% below canary 25.0%"
budget_test.go:98: budget empty verdict=freeze reason="remaining 2.0% below freeze 5.0%"
budget_test.go:98: burning fast verdict=freeze reason="burn 20.00x over 14.40x limit"
budget_test.go:98: no data verdict=hold-no-data reason="only 500 valid events, need 1000"
--- PASS: TestGateDecisions (0.00s)
PASS
ok archlab/internal/platform/slo 0.238s
读数分四层。预算层,100 万事件、99.9% 目标允许 1000 个坏事件,用了 900 个剩 10%,用了 1300 个则是 -30%;0.9999 目标下 200 万事件只允许 200 个,说明预算对小目标非常敏感。速率层,0.1% 的失败率正好是 1 倍,0.2% 是 2 倍,零失败是 0,空窗口是 NaN。多窗口层,故障小时长窗口 25 倍、短窗口 200 倍,判定 firing;一小时后短窗口回落到 0,长窗口仍是 25 倍,判定不再 firing,这正是长短窗口分工的意义。门禁层,五个场景分别得到 allow、canary-only、freeze、freeze、hold。
这些数字来自固定时钟下的合成事件,不是线上实测。真实系统的燃尽速率会受流量结构、重试和采集延迟影响;上线前应先用历史事件回放,确认阈值不会在正常波动下频繁触发。完整测试代码如下。
// file: internal/platform/slo/budget_test.go
// tier: L1
// verify: go test ./internal/platform/slo/ -count=1 -run 'TestBudget|TestBurn|TestGate'
package slo
import (
"math"
"testing"
"time"
)
func TestBudgetArithmetic(t *testing.T) {
cases := []struct {
name string
b Budget
}{
{"ten percent left", Budget{Target: 0.999, Total: 1_000_000, Bad: 900}},
{"overspent", Budget{Target: 0.999, Total: 1_000_000, Bad: 1300}},
{"fine grained", Budget{Target: 0.9999, Total: 2_000_000, Bad: 50}},
}
for _, c := range cases {
if err := c.b.Validate(); err != nil {
t.Fatalf("%s: %v", c.name, err)
}
t.Logf("%s allowed=%.2f bad=%d remaining=%.2f ratio=%.3f",
c.name, c.b.Allowed(), c.b.Bad, c.b.Remaining(), c.b.RemainingRatio())
}
for _, b := range []Budget{{Target: 1.0, Total: 10}, {Target: 0.999, Total: 10, Bad: 11}} {
if err := b.Validate(); err == nil {
t.Fatalf("invalid budget accepted: %+v", b)
}
}
}
func TestBurnRate(t *testing.T) {
samples := []BurnSample{
{Window: time.Hour, Total: 10000, Bad: 10},
{Window: 6 * time.Hour, Total: 60000, Bad: 120},
{Window: time.Hour, Total: 5000, Bad: 0},
{Window: time.Hour},
}
for _, s := range samples {
rate := s.Rate(0.999)
if s.Total == 0 && !math.IsNaN(rate) {
t.Fatalf("empty window rate = %v, want NaN", rate)
}
t.Logf("window=%s total=%d bad=%d rate=%.2f", s.Window, s.Total, s.Bad, rate)
}
}
func TestBurnMultiWindow(t *testing.T) {
events := sampleEvents()
policy := BurnPolicy{Long: 24 * time.Hour, Short: time.Hour, LongThreshold: 6, ShortThreshold: 6, MinEvents: 100}
during, err := EvaluateBurn(events, SLI{Name: "orders-availability"}, t0.Add(71*time.Hour), 0.999, policy)
if err != nil {
t.Fatalf("during: %v", err)
}
if !during.Data || !during.Firing {
t.Fatalf("expected a sustained firing burn: %+v", during)
}
t.Logf("during long=%.2fx short=%.2fx firing=%v", during.LongRate, during.ShortRate, during.Firing)
after, err := EvaluateBurn(events, SLI{Name: "orders-availability"}, t0.Add(72*time.Hour), 0.999, policy)
if err != nil {
t.Fatalf("after: %v", err)
}
if after.Firing {
t.Fatalf("short window should have stopped firing: %+v", after)
}
t.Logf("after long=%.2fx short=%.2fx firing=%v", after.LongRate, after.ShortRate, after.Firing)
}
func TestGateDecisions(t *testing.T) {
policy := GatePolicy{FreezeBelow: 0.05, CanaryBelow: 0.25, MaxBurnRate: 14.4, MinEvents: 1000}
healthy := BurnResult{}
hot := BurnResult{Data: true, Firing: true, LongRate: 20, ShortRate: 60}
cases := []struct {
name string
b Budget
burn BurnResult
want Verdict
}{
{"healthy", Budget{Target: 0.999, Total: 1_000_000, Bad: 100}, healthy, VerdictAllow},
{"budget low", Budget{Target: 0.999, Total: 1_000_000, Bad: 800}, healthy, VerdictCanary},
{"budget empty", Budget{Target: 0.999, Total: 1_000_000, Bad: 980}, healthy, VerdictFreeze},
{"burning fast", Budget{Target: 0.999, Total: 1_000_000, Bad: 500}, hot, VerdictFreeze},
{"no data", Budget{Target: 0.999, Total: 500}, healthy, VerdictHold},
}
for _, c := range cases {
got, reason, err := policy.Decide(c.b, c.burn)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if got != c.want {
t.Fatalf("%s: got %s want %s (%s)", c.name, got, c.want, reason)
}
t.Logf("%s verdict=%s reason=%q", c.name, got, reason)
}
if _, _, err := (GatePolicy{FreezeBelow: 0.3, CanaryBelow: 0.2, MaxBurnRate: 1}).Decide(cases[0].b, healthy); err == nil {
t.Fatal("inverted thresholds accepted")
}
}
常见错误与边界
- 把
bad/total直接当燃尽速率。忘记除以1-target,会让 99.9% 和 99% 两种目标下的同一个失败率看起来一样严重。 - 用单窗口告警。短窗口抖动大,长窗口反应慢;只用一个窗口,不是误报就是漏报。
- 无数据当成健康。低流量时段的零失败不能证明系统正常,必须返回 hold 并等待样本。
- 把冻结当成永久状态。冻结必须写可观测的退出条件,否则要么无限期停摆,要么被人绕过;退出条件不能靠负责人拍脑袋。
小结
错误预算把“可用性目标”翻译成“这个周期还能犯多少错”。它必须保留小数和负值,才能表达速率和超支;剩余比例回答额度,燃尽速率回答速度,多窗口规则回答“是脉冲还是持续”。门禁的判定顺序应当是数据、速率、额度,任何一步不满足都不能用“还有预算”来覆盖。
本节给出的实现把这三层变成可测试的纯函数。下一节把视角从单个服务拉到依赖链:当订单链路要承诺 99.9%,每个依赖应该分到多少预算,以及为什么“各自 99.95%”往往不够。
阅读导航:上一节:7.1 SLI 定义:指标、窗口与用户视角 · 下一节:7.3 依赖级联:多服务 SLO 的预算分配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。