《后端架构设计·演进与治理》7.2 错误预算:发布门禁与冻结策略

从大促前两周错误预算只剩 8% 却仍要发版的争议切入,说明错误预算的算术、燃尽速率与多窗口判据,给出 allow、canary-only、freeze 三档发布门禁的可运行的 Go 实现、固定时钟的测试与本机真实输出,并讨论冻结的退出条件。

预算只剩 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 表示刚好用完,负数表示超支;门禁阈值都建在这个比例上。

状态允许已用剩余剩余比例含义
健康10001009000.90可正常发布
偏低10008002000.20只允许灰度
耗尽1000980200.02冻结
超支10001300-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 的预算分配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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