《后端架构设计·演进与治理》13.2 一次带 SLO 门禁与混沌演练的发布

从 arch-lab 周五晚一次被 SLO 燃尽率拦下的发布切入,说明单窗口阈值为什么会放过慢泄漏,给出快慢双窗口、冻结窗口、样本下限与预算余量的 Go 门禁,再用带稳态断言和止损边界的混沌计划编排发布前演练,并用真实测试输出验证门禁与中止路径。

周五 18:10,发布被一条曲线拦下

arch-lab 的 order-service 准备发布 v42,改动只涉及订单查询的缓存键。发布窗口是周五 18:00 到 20:00,负责人看完最近一小时的成功率说没问题,准备点“继续”。SLO 门禁却拒绝了:过去一小时燃尽率 0.5,看起来很健康;过去六小时燃尽率 1.4,超过慢窗口阈值 1.0。

差别来自一次从凌晨开始的慢泄漏。凌晨的批量对账任务让 0.3% 的查询超时,白天流量上来后绝对值不大,一小时窗口被正常请求稀释,六小时窗口仍然记得这笔账。如果只看快窗口,v42 会在预算已经透支的情况下上线;一旦新版本再引入一点抖动,剩余预算不足以撑到下一个发布窗口。

这一节不重复 SLO 的定义,SLO 与错误预算 已经讲过 SLI、目标和预算的关系。这里回答两个更具体的问题:发布门禁应该看哪几个窗口,以及门禁通过之后,为什么还要在发布前跑一次有边界的混沌演练。

单窗口为什么不够

观察方式能抓到抓不到代价
单窗口 1 小时突增、明显故障低频慢泄漏、凌晨累积误放行
单窗口 6 小时至 3 天慢泄漏、趋势刚发生的尖峰被平均反应慢
快慢双窗口尖峰与慢泄漏仍需样本下限防小流量误判两条查询
双窗口加预算余量上述两类加透支状态业务日历与冻结期多一条策略

快窗口负责“现在是否在烧”,慢窗口负责“这个月是否已经烧过”。两个阈值不能相同:快窗口允许短时抖动,阈值通常设得高(例如 2 到 14 倍燃尽);慢窗口更接近预算节奏,阈值通常设 1 左右。两者都通过才放行,任一失败都要给出可读的原因。

样本下限是另一个容易被跳过的条件。凌晨只有 200 个请求,全部成功,快窗口燃尽率为 0,但这不构成“健康”的证据。门禁应当在样本不足时拒绝,而不是把空窗口当成满分。冻结窗口则把业务日历接进来:大促、财务结账和监管窗口内,即使指标健康也不发布,因为故障的代价此时被放大。

门禁输出必须能解释

门禁不是返回一个布尔值,而是返回“决定加所有产生它的数字”。拒绝时,工程师要能直接看到是快窗口、慢窗口、样本数、预算余量还是冻结期导致的,而不是再去翻三个仪表盘。下面的实现把所有违反项收集进 Reasons 并排序,保证同样的输入产生同样的日志顺序。

混沌演练也遵循同一原则:计划必须先写稳态假设和爆炸半径,再写动作。没有假设的“注入故障”只是制造事故;没有爆炸半径的演练无法止损。每个步骤可以带 AbortOnFail,一旦稳态断言失败就停止后续步骤,报告里保留已执行步数、失败原因和是否中止。发布决定把门禁与演练串起来:门禁拒绝时根本不运行混沌,因为往一个已经透支的系统里注入故障没有意义。

这与混沌工程 的完整实验流程一致,只是把范围压到一次发布前的最小闭环。回滚与兼容窗口另见回滚兼容窗口 :门禁放行不代表回滚一定安全,两者要分开验收。

可运行实现

实现只依赖标准库,包含窗口计算、门禁求值、混沌计划与发布决定四部分。EvaluateGate 不访问任何监控后端,输入是已经聚合好的计数窗口,这样测试可以完全确定。

// file: internal/evolve/releasegate.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run 'TestGate|TestChaos|TestDecide'
package evolve

import (
	"fmt"
	"sort"
	"time"
)

// SLIWindow is a count-based availability sample. Total is the number of
// eligible requests, Good the number that met the SLI.
type SLIWindow struct {
	Name  string
	Total int
	Good  int
}

// Availability returns Good/Total. An empty window reports 1; it carries no
// evidence and must be rejected by MinSamples, not silently accepted.
func (w SLIWindow) Availability() float64 {
	if w.Total == 0 {
		return 1
	}
	return float64(w.Good) / float64(w.Total)
}

// BurnRate is the observed error rate divided by the error budget (1-target).
// A burn of 1 means the window consumed exactly its budget.
func BurnRate(w SLIWindow, target float64) float64 {
	if target <= 0 || target >= 1 {
		return 0
	}
	return (1 - w.Availability()) / (1 - target)
}

// FreezeWindow blocks releases in a calendar interval, for example a campaign
// or a finance close.
type FreezeWindow struct {
	Start  time.Time
	End    time.Time
	Reason string
}

// GateConfig is the release policy. Fast and slow windows catch different
// failures: a spike and a slow leak.
type GateConfig struct {
	Target             float64
	FastBurn           float64
	SlowBurn           float64
	MinSamples         int
	MinBudgetRemaining float64
	Freeze             []FreezeWindow
}

// GateResult carries the decision and every number that produced it, so a
// rejection can be explained without re-querying the metrics backend.
type GateResult struct {
	Allow     bool
	FastBurn  float64
	SlowBurn  float64
	Remaining float64
	Reasons   []string
}

// EvaluateGate applies all rules and reports every violation. The order of
// reasons is sorted to keep output stable for tests and logs.
func EvaluateGate(cfg GateConfig, fast, slow SLIWindow, remaining float64, now time.Time) GateResult {
	res := GateResult{
		FastBurn:  BurnRate(fast, cfg.Target),
		SlowBurn:  BurnRate(slow, cfg.Target),
		Remaining: remaining,
	}
	if cfg.Target <= 0 || cfg.Target >= 1 {
		res.Reasons = append(res.Reasons, fmt.Sprintf("invalid SLO target %.4f", cfg.Target))
	}
	if cfg.FastBurn <= 0 || cfg.SlowBurn <= 0 {
		res.Reasons = append(res.Reasons, "burn thresholds must be positive")
	}
	if fast.Total < cfg.MinSamples || slow.Total < cfg.MinSamples {
		res.Reasons = append(res.Reasons, fmt.Sprintf("insufficient samples: fast=%d slow=%d min=%d", fast.Total, slow.Total, cfg.MinSamples))
	}
	if res.FastBurn > cfg.FastBurn {
		res.Reasons = append(res.Reasons, fmt.Sprintf("fast burn %.2f exceeds %.2f", res.FastBurn, cfg.FastBurn))
	}
	if res.SlowBurn > cfg.SlowBurn {
		res.Reasons = append(res.Reasons, fmt.Sprintf("slow burn %.2f exceeds %.2f", res.SlowBurn, cfg.SlowBurn))
	}
	if remaining < cfg.MinBudgetRemaining {
		res.Reasons = append(res.Reasons, fmt.Sprintf("budget remaining %.2f below %.2f", remaining, cfg.MinBudgetRemaining))
	}
	for _, f := range cfg.Freeze {
		if !now.Before(f.Start) && now.Before(f.End) {
			res.Reasons = append(res.Reasons, fmt.Sprintf("freeze window: %s", f.Reason))
			break
		}
	}
	sort.Strings(res.Reasons)
	res.Allow = len(res.Reasons) == 0
	return res
}

// ChaosStep is one fault and the steady-state assertion that must hold while
// the fault is active. AbortOnFail stops the whole plan on violation.
type ChaosStep struct {
	Name        string
	Action      func() error
	Assert      func() error
	AbortOnFail bool
}

// ChaosPlan is a bounded experiment: hypothesis first, blast radius second,
// actions last.
type ChaosPlan struct {
	Name        string
	Hypothesis  string
	BlastRadius string
	Steps       []ChaosStep
}

// ChaosReport is the audit record of one run.
type ChaosReport struct {
	Name     string
	Executed int
	Passed   bool
	Aborted  bool
	Failures []string
}

// RunChaos refuses a plan that has no hypothesis or no declared blast radius;
// an unbounded experiment is not an experiment, it is an incident.
func RunChaos(p ChaosPlan) ChaosReport {
	rep := ChaosReport{Name: p.Name}
	if p.Hypothesis == "" {
		rep.Failures = append(rep.Failures, "missing steady-state hypothesis")
	}
	if p.BlastRadius == "" {
		rep.Failures = append(rep.Failures, "missing blast radius")
	}
	if len(p.Steps) == 0 {
		rep.Failures = append(rep.Failures, "no steps")
	}
	if len(rep.Failures) > 0 {
		return rep
	}
	for _, step := range p.Steps {
		rep.Executed++
		var err error
		if step.Action != nil {
			err = step.Action()
		}
		if err == nil && step.Assert != nil {
			err = step.Assert()
		}
		if err != nil {
			rep.Failures = append(rep.Failures, fmt.Sprintf("%s: %v", step.Name, err))
			if step.AbortOnFail {
				rep.Aborted = true
				break
			}
		}
	}
	rep.Passed = len(rep.Failures) == 0
	return rep
}

// ReleaseDecision combines both gates. Chaos is not run when the SLO gate
// already rejects the release: injecting faults cannot fix a bad burn rate.
type ReleaseDecision struct {
	Allow   bool
	Gate    GateResult
	Chaos   ChaosReport
	Reasons []string
}

// DecideRelease evaluates the SLO gate, then the chaos plan, and allows the
// release only when both pass.
func DecideRelease(cfg GateConfig, fast, slow SLIWindow, remaining float64, now time.Time, plan ChaosPlan) ReleaseDecision {
	d := ReleaseDecision{Gate: EvaluateGate(cfg, fast, slow, remaining, now)}
	if !d.Gate.Allow {
		d.Reasons = append(d.Reasons, "slo gate rejected release")
		sort.Strings(d.Reasons)
		return d
	}
	d.Chaos = RunChaos(plan)
	if !d.Chaos.Passed {
		d.Reasons = append(d.Reasons, "chaos experiment did not pass")
	}
	sort.Strings(d.Reasons)
	d.Allow = len(d.Reasons) == 0
	return d
}

测试覆盖放行、快燃烧尽、样本不足、冻结窗口、预算不足、混沌中止、无界计划和两条决定路径。下面贴三个有代表性的用例:

// file: internal/evolve/releasegate_test.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run 'TestGate|TestChaos|TestDecide'
var releaseAt = time.Date(2026, 11, 2, 18, 10, 0, 0, time.UTC)

func gateConfig() GateConfig {
	return GateConfig{
		Target:             0.999,
		FastBurn:           2,
		SlowBurn:           1,
		MinSamples:         1000,
		MinBudgetRemaining: 0.10,
	}
}

func TestGateRejectsFastBurn(t *testing.T) {
	fast := SLIWindow{Name: "fast-1h", Total: 10000, Good: 9900}
	slow := SLIWindow{Name: "slow-6h", Total: 100000, Good: 99950}
	got := EvaluateGate(gateConfig(), fast, slow, 0.75, releaseAt)
	if got.Allow {
		t.Fatal("allowed a 10x fast burn")
	}
	if !contains(got.Reasons, "fast burn 10.00 exceeds 2.00") {
		t.Fatalf("reasons = %v", got.Reasons)
	}
}

func TestChaosAbortsOnSteadyStateViolation(t *testing.T) {
	ranThird := false
	plan := ChaosPlan{
		Name:        "payment-latency",
		Hypothesis:  "order success stays above 99.9% while one payment replica is slow",
		BlastRadius: "payment-service/1 of 4 replicas",
		Steps: []ChaosStep{
			{Name: "baseline", AbortOnFail: true},
			{Name: "inject-3000ms", Assert: func() error { return errors.New("success rate 99.1%") }, AbortOnFail: true},
			{Name: "inject-5000ms", Action: func() error { ranThird = true; return nil }},
		},
	}
	rep := RunChaos(plan)
	if rep.Passed || !rep.Aborted {
		t.Fatalf("report = %+v, want failed and aborted", rep)
	}
	if rep.Executed != 2 || ranThird {
		t.Fatalf("executed = %d, third ran = %v", rep.Executed, ranThird)
	}
	if !strings.Contains(rep.Failures[0], "success rate 99.1%") {
		t.Fatalf("failures = %v", rep.Failures)
	}
}

func TestDecideReleaseSkipsChaosWhenGateFails(t *testing.T) {
	ran := false
	plan := ChaosPlan{
		Name:        "should-not-run",
		Hypothesis:  "h",
		BlastRadius: "b",
		Steps:       []ChaosStep{{Name: "step", Action: func() error { ran = true; return nil }}},
	}
	fast := SLIWindow{Name: "fast-1h", Total: 10, Good: 10}
	d := DecideRelease(gateConfig(), fast, fast, 0.75, releaseAt, plan)
	if d.Allow || ran {
		t.Fatalf("allow = %v, chaos ran = %v", d.Allow, ran)
	}
	if !contains(d.Reasons, "slo gate rejected release") {
		t.Fatalf("reasons = %v", d.Reasons)
	}
}

本机实测

环境为 macOS darwin/arm64、Go 1.26.0。命令与原始输出如下,完整 23 个测试的结果在报告文件中:

$ go test ./internal/evolve/ -count=1 -v -run 'TestGate|TestChaos|TestDecide'
=== RUN   TestGateAllowsHealthyWindows
--- PASS: TestGateAllowsHealthyWindows (0.00s)
=== RUN   TestGateRejectsFastBurn
--- PASS: TestGateRejectsFastBurn (0.00s)
=== RUN   TestGateRejectsInsufficientSamples
--- PASS: TestGateRejectsInsufficientSamples (0.00s)
=== RUN   TestGateRejectsFreezeWindow
--- PASS: TestGateRejectsFreezeWindow (0.00s)
=== RUN   TestGateRejectsExhaustedBudget
--- PASS: TestGateRejectsExhaustedBudget (0.00s)
=== RUN   TestChaosAbortsOnSteadyStateViolation
--- PASS: TestChaosAbortsOnSteadyStateViolation (0.00s)
=== RUN   TestChaosRejectsUnboundedPlan
--- PASS: TestChaosRejectsUnboundedPlan (0.00s)
=== RUN   TestChaosPassesBoundedPlan
--- PASS: TestChaosPassesBoundedPlan (0.00s)
=== RUN   TestDecideReleaseSkipsChaosWhenGateFails
--- PASS: TestDecideReleaseSkipsChaosWhenGateFails (0.00s)
=== RUN   TestDecideReleaseRequiresBothGates
--- PASS: TestDecideReleaseRequiresBothGates (0.00s)
PASS
ok  	archlab/internal/evolve	0.241s

快燃烧尽用例里,快窗口 1% 错误率、目标 99.9%,燃尽率正好是 10,门禁给出的原因精确到“fast burn 10.00 exceeds 2.00”,而不是笼统的“SLO 不达标”。混沌中止用例执行到第二步就停止,第三步的标记保持 false,报告同时保留已执行步数 2 和中止标志;这说明止损是编排器执行的,不是靠操作者看见告警再手动停。最后一个用例证明门禁拒绝时混沌根本没有启动:计划里的 Action 从未被调用。

需要说明边界:这些窗口是固定输入,不含监控后端的查询延迟、数据迟到和标签错配;本机耗时 0.004 秒只说明计算逻辑,不代表线上门禁的端到端延迟。真实部署还要把窗口查询的超时和降级策略写进门禁,否则监控不可用时门禁会变成单点。

常见错误与边界

第一,只配置一个窗口。单窗口必然在“抓尖峰”和“抓慢泄漏”之间二选一,另一个方向会漏。快慢双窗口的成本只是多一条聚合查询,收益是两类故障都能被看见。

第二,把空窗口当成健康。零样本时可用率按 1 处理只是避免除零,必须由样本下限拒绝;否则凌晨低流量会放行所有发布。

第三,混沌计划没有稳态假设或爆炸半径。没有假设就无法判断实验是否成功,没有半径就无法在异常时止损。两者都应当是计划结构里的必填字段,而不是文档里的口头约定。

第四,门禁和演练各自为政。门禁通过不代表系统能承受故障,演练通过也不代表预算健康;发布决定必须同时消费两个结果,并且门禁失败时不要运行演练。

小结

SLO 门禁的核心不是“有没有超过阈值”,而是“用哪几个窗口、在多小的样本上、对什么样的业务日历做判断”。快慢双窗口覆盖尖峰与慢泄漏,样本下限防止小流量误判,预算余量与冻结窗口把财务和组织约束接进来。

混沌演练则把“发布前验证”从一句口号变成有边界的计划:先写稳态假设和爆炸半径,再执行注入,失败时自动中止。门禁与演练一起构成一次可解释的发布决定,而不是两个各自通过的勾选框。下一节把这一章和前三卷收口成一张可校验的覆盖矩阵。

阅读导航:上一节:13.1 arch-lab 从模块化单体到两服务 · 下一节:13.3 四卷收口:架构、性能、一致性与治理的路线图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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