周五 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 四卷收口:架构、性能、一致性与治理的路线图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。