《后端架构设计·演进与治理》8.2 演练设计:稳态假设、爆炸半径与止损

把一次“指标看起来还行”的失败演练拆成可证伪的稳态假设:区分业务主张与护栏信号,用连续越界窗口和最小样本数抑制噪声,再给出独立于实验结论的止损决策 Go 实现与固定时钟测试,并解释为什么止损路径必须 fail closed 而不能只靠人工盯盘。

一次“指标看起来还行”的演练

演练开始前,负责人写下的假设是:注入 order → inventory 的 200ms 延迟后,订单成功率不低于 99.1%,P99 不高于 400ms。

注入三分钟后,成功率掉到 98.6%,P99 升到 430ms。值班同学看了一眼大盘:“整体可用性还有 99.5%,均值没怎么动。”于是演练继续。第十一分钟,重试开始堆积,连接池耗尽,成功率跌到 72%,这时才手动停止。

复盘时争论的焦点不是“该不该停”,而是“当时到底算不算已经违反了假设”。如果假设只是两句写在文档里的话,没有采样窗口、没有连续次数、没有护栏和主张的区分,那么每个人都可能得出不同结论。

本节把稳态假设写成可执行的判据,并把止损从“实验结论”里拆出来。演练设计的上游是 混沌工程专题 里的实验闭环,下游则要接到 错误预算与韧性治理 :一次演练最多能烧掉多少预算,应该在开始前就定好。

假设必须可证伪

“系统保持稳定”不是假设,因为它无法被任何观测推翻。可证伪的假设至少包含五个要素:

要素回答的问题本例取值
信号看哪个指标order_success、p99_ms
基线正常时是多少0.995、300ms
容差偏离多少仍算正常±0.004、±100ms
窗口在多长时间内观察10 分钟
最小样本至少几个采样点才下结论4 个

这五个要素缺一个,结论就会漂移。没有基线,无法判断 98.6% 是好是坏;没有容差,任何抖动都会触发误停;没有窗口,采样点可以跨小时拼接;没有最小样本,第一个异常点就能宣告失败。

更重要的是把信号分成两类:

  • 主张信号(claim):实验要验证的业务承诺,越界说明“假设不成立”,应当记录失败,但不必立刻中止。
  • 护栏信号(guardrail):安全底线,越界说明“继续跑会伤人”,必须立即中止。

order_success 是主张:它掉到容差外,说明这次演练证伪了“延迟不影响下单”的假设,这恰恰是演练的价值。p99_ms 是护栏:它可能代表线程池、连接池正在被拖垮,继续跑只会扩大事故。

两类信号混在一起,团队就会在两个错误之间摇摆:要么把业务失败当事故,演练永远不敢做;要么把护栏突破当“再观察一下”,最后真的打出线上故障。

连续越界,而不是单点越界

单点采样必然有噪声。一次 GC、一次慢查询、一次采集抖动,都可能让 P99 短暂冲高。用单点判定,演练会被误停;用均值判定,持续劣化又会被平滑掉。

本文采用“连续 N 个采样点越界”作为触发条件。Breach 是每个信号自己的连续次数要求,本例两条信号都设为 2。规则是:

  1. 每个采样点与基线比较,绝对偏差超过容差记为越界;
  2. 连续越界计数累加,一旦回到带内就清零;
  3. 连续次数达到 Breach 时,该信号触发;
  4. 护栏触发优先于主张触发,直接给出 Abort。

这里有一个容易忽略的实现细节:原因文本要记录历史最大连续次数,而不是触发瞬间的计数。后者在信号恢复后被清零,日志会写成“breached 0 times”,复盘时无法解释为什么停。

止损必须独立于实验结论

实验结论回答“假设是否成立”,止损回答“还要不要继续”。两者混在一个状态机里,就会出现“假设还没结论,所以不停”的死锁。

止损至少要有四个字段:

字段作用缺失后果
Auto是否有带外控制器自动停止依赖人工发现,延迟不可控
OnCall无自动路径时的值班责任人没人敢停或没人能停
MaxDuration实验最长运行时间故障被遗忘在系统里
AbortWithin从决定停止到实际恢复的上限“已停止”只是面板状态

AbortWithin 必须小于 MaxDuration。如果恢复动作本身要花九分钟,而实验只允许跑八分钟,那么止损路径在物理上不成立。这个约束应该在设计时校验,而不是等演练当天发现。

止损还要 fail closed:没有自动路径、没有值班人、截止时间非法、运行超时、护栏越界,任何一条成立都返回 Abort。宁可误停一次演练,也不让一个已经失去停止能力的实验继续跑。

Go 实现:评估与止损分离

下面的实现把“看样本”和“做决定”拆成两个纯函数。Evaluate 只根据样本给出 Verdict,不接触时钟;StopLoss.Check 只根据 Verdict 和已运行时长给出动作,不重新计算指标。这样两者都可以用固定时钟测试,也不会出现“止损逻辑偷偷修改了实验结论”。

// file: internal/platform/chaos/experiment.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos

import (
	"fmt"
	"math"
	"time"
)

// Signal is one measured quantity. Guard signals abort; non-guard signals
// disprove the hypothesis without stopping the run.
type Signal struct {
	Name      string
	Guard     bool
	Baseline  float64
	Tolerance float64
	Breach    int
}

type Sample struct {
	At     time.Time
	Values map[string]float64
}

type Verdict struct {
	Sufficient   bool
	HypothesisOK bool
	Abort        bool
	Reason       string
}

type Hypothesis struct {
	Window     time.Duration
	MinSamples int
	Signals    []Signal
}

func (h Hypothesis) validate() error {
	if h.Window <= 0 || h.MinSamples < 1 || len(h.Signals) == 0 {
		return fmt.Errorf("chaos: hypothesis needs a window, samples and signals")
	}
	seen := map[string]bool{}
	var claim, guard bool
	for _, s := range h.Signals {
		if s.Name == "" || seen[s.Name] || !(s.Tolerance > 0) || s.Breach < 1 {
			return fmt.Errorf("chaos: invalid signal %q", s.Name)
		}
		seen[s.Name] = true
		claim, guard = claim || !s.Guard, guard || s.Guard
	}
	if !claim || !guard {
		return fmt.Errorf("chaos: hypothesis needs a claim and a guardrail")
	}
	return nil
}

// Evaluate refuses to conclude from too few samples.
func Evaluate(h Hypothesis, samples []Sample) (Verdict, error) {
	if err := h.validate(); err != nil {
		return Verdict{}, err
	}
	return evaluate(h, samples)
}

func evaluate(h Hypothesis, samples []Sample) (Verdict, error) {
	v := Verdict{Sufficient: len(samples) >= h.MinSamples, HypothesisOK: true}
	if len(samples) == 0 {
		return v, nil
	}
	start := samples[0].At
	for i, s := range samples {
		if i > 0 && !s.At.After(samples[i-1].At) {
			return Verdict{}, fmt.Errorf("chaos: samples are out of order")
		}
		if s.At.Sub(start) > h.Window {
			return Verdict{}, fmt.Errorf("chaos: sample is outside the window")
		}
		for _, sig := range h.Signals {
			if _, ok := s.Values[sig.Name]; !ok {
				return Verdict{}, fmt.Errorf("chaos: sample is missing %q", sig.Name)
			}
		}
	}
	type state struct{ run, peak int; breach bool }
	states := map[string]*state{}
	for _, sig := range h.Signals {
		states[sig.Name] = &state{}
	}
	for _, s := range samples {
		for _, sig := range h.Signals {
			st := states[sig.Name]
			if math.Abs(s.Values[sig.Name]-sig.Baseline) > sig.Tolerance {
				st.run++
			} else {
				st.run = 0
			}
			if st.run > st.peak {
				st.peak = st.run
			}
			if st.run >= sig.Breach {
				st.breach = true
			}
		}
	}
	for _, sig := range h.Signals {
		st := states[sig.Name]
		if !st.breach {
			continue
		}
		reason := fmt.Sprintf("%s breached %d consecutive samples", sig.Name, st.peak)
		if sig.Guard {
			v.Abort, v.Reason = true, "guardrail "+reason
			break
		}
		v.HypothesisOK, v.Reason = false, "hypothesis "+reason
	}
	return v, nil
}

type Action string

const (
	ContinueAction Action = "continue"
	PassAction     Action = "pass"
	FailAction     Action = "fail"
	AbortAction    Action = "abort"
)

// StopLoss is the independent stop path.
type StopLoss struct {
	Auto        bool
	OnCall      string
	MaxDuration time.Duration
	AbortWithin time.Duration
}

// Check fails closed when the stop path or its deadline is not usable.
func (s StopLoss) Check(v Verdict, elapsed time.Duration) (Action, string) {
	if !s.Auto && s.OnCall == "" {
		return AbortAction, "no out-of-band stop path"
	}
	if s.AbortWithin <= 0 || s.AbortWithin >= s.MaxDuration {
		return AbortAction, "abort deadline is outside the run"
	}
	if elapsed > s.MaxDuration {
		return AbortAction, "max duration exceeded"
	}
	if v.Abort {
		return AbortAction, v.Reason
	}
	if !v.Sufficient {
		return ContinueAction, "need more samples"
	}
	if !v.HypothesisOK {
		return FailAction, v.Reason
	}
	return PassAction, "hypothesis held"
}

三个设计选择值得说明。

第一,Evaluate 在样本时间乱序、超出窗口、缺信号时直接返回错误,而不是跳过坏样本。演练数据的坏点往往意味着采集链路也有问题,静默跳过会让结论建立在不可信的输入上。

第二,Verdict.Sufficient 与 HypothesisOK 分开。样本不足时,即使当前没有越界,也不能算通过;Check 会返回 Continue,而不是 Pass。这是很多自研工具会漏掉的分支。

第三,Check 把“运行超时”放在护栏越界之前。超时是硬边界,即使指标看起来正常,也不能继续占用故障注入资源;顺序固定后,日志里的 abort 原因才可解释。

固定时钟测试:七个止损分支

测试不依赖真实时间,用 at(hour, minute) 构造固定时间戳,用表驱动覆盖健康、样本不足、主张失败、护栏中止、无止损路径、非法截止时间和运行超时。

// file: internal/platform/chaos/experiment_test.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos

import (
	"strings"
	"testing"
	"time"
)

func at(hour, minute int) time.Time {
	return time.Date(2026, 10, 30, hour, minute, 0, 0, time.UTC)
}

func orderHypothesis() Hypothesis {
	return Hypothesis{
		Window: 10 * time.Minute, MinSamples: 4,
		Signals: []Signal{
			{Name: "order_success", Guard: false, Baseline: 0.995, Tolerance: 0.004, Breach: 2},
			{Name: "p99_ms", Guard: true, Baseline: 300, Tolerance: 100, Breach: 2},
		},
	}
}

func sampleAt(minute int, success, p99 float64) Sample {
	return Sample{
		At:     at(10, 0).Add(time.Duration(minute) * time.Minute),
		Values: map[string]float64{"order_success": success, "p99_ms": p99},
	}
}

func TestEvaluateConsecutiveBreach(t *testing.T) {
	h := orderHypothesis()
	one := []Sample{sampleAt(1, .995, 300), sampleAt(2, .995, 450), sampleAt(3, .995, 300), sampleAt(4, .995, 300)}
	v, err := Evaluate(h, one)
	if err != nil || v.Abort || !v.HypothesisOK {
		t.Fatalf("single spike acted: %+v, %v", v, err)
	}
	two := []Sample{sampleAt(1, .995, 300), sampleAt(2, .995, 450), sampleAt(3, .995, 450), sampleAt(4, .995, 300)}
	v, err = Evaluate(h, two)
	if err != nil || !v.Abort || !strings.Contains(v.Reason, "p99_ms") {
		t.Fatalf("two spikes: %+v, %v", v, err)
	}
	t.Logf("one spike: abort=false; two spikes: abort=true reason=%q", v.Reason)
}

func TestStopLossCheck(t *testing.T) {
	h := orderHypothesis()
	healthy, _ := Evaluate(h, []Sample{sampleAt(1, .995, 300), sampleAt(2, .995, 300), sampleAt(3, .995, 300), sampleAt(4, .995, 300)})
	guard, _ := Evaluate(h, []Sample{sampleAt(1, .995, 300), sampleAt(2, .995, 450), sampleAt(3, .995, 450), sampleAt(4, .995, 300)})
	claim, _ := Evaluate(h, []Sample{sampleAt(1, .995, 300), sampleAt(2, .995, 300), sampleAt(3, .980, 300), sampleAt(4, .980, 300)})
	short, _ := Evaluate(h, []Sample{sampleAt(1, .995, 300), sampleAt(2, .995, 300)})
	ok := StopLoss{Auto: true, MaxDuration: 8 * time.Minute, AbortWithin: 30 * time.Second}
	cases := []struct {
		name    string
		stop    StopLoss
		v       Verdict
		elapsed time.Duration
		want    Action
		reason  string
	}{
		{"healthy", ok, healthy, 4 * time.Minute, PassAction, "hypothesis held"},
		{"short", ok, short, time.Minute, ContinueAction, "more samples"},
		{"claim", ok, claim, 4 * time.Minute, FailAction, "order_success"},
		{"guard", ok, guard, 3 * time.Minute, AbortAction, "p99_ms"},
		{"no_path", StopLoss{}, healthy, time.Minute, AbortAction, "stop path"},
		{"bad_deadline", StopLoss{Auto: true, MaxDuration: 8 * time.Minute, AbortWithin: 9 * time.Minute}, healthy, time.Minute, AbortAction, "deadline"},
		{"overrun", ok, healthy, 9 * time.Minute, AbortAction, "max duration"},
	}
	for _, tc := range cases {
		action, reason := tc.stop.Check(tc.v, tc.elapsed)
		if action != tc.want || !strings.Contains(reason, tc.reason) {
			t.Fatalf("%s: %s %q, want %s %q", tc.name, action, reason, tc.want, tc.reason)
		}
		t.Logf("%s: %s (%s)", tc.name, action, reason)
	}
}

本机运行测试后,相关原始输出如下(固定时钟与固定样本,示例值不用于推断线上容量):

=== RUN   TestEvaluateConsecutiveBreach
    experiment_test.go:41: one spike: abort=false; two spikes: abort=true reason="guardrail p99_ms breached 2 consecutive samples"
--- PASS: TestEvaluateConsecutiveBreach (0.00s)
=== RUN   TestStopLossCheck
    experiment_test.go:90: healthy: pass (hypothesis held)
    experiment_test.go:90: short: continue (need more samples)
    experiment_test.go:90: claim: fail (hypothesis order_success breached 2 consecutive samples)
    experiment_test.go:90: guard: abort (guardrail p99_ms breached 2 consecutive samples)
    experiment_test.go:90: no_path: abort (no out-of-band stop path)
    experiment_test.go:90: bad_deadline: abort (abort deadline is outside the run)
    experiment_test.go:90: overrun: abort (max duration exceeded)
--- PASS: TestStopLossCheck (0.00s)

判据表:假设与止损各自成立吗

检查项不合格的表现本文的判据
可证伪性只写“系统稳定”信号、基线、容差、窗口、样本数齐全
信号分类业务指标和资源指标混用主张与护栏必须同时存在
噪声抑制单点越界即下结论连续 Breach 次才触发
样本不足两点数据就宣布通过少于 MinSamples 返回 Continue
止损路径只有人工盯盘Auto 或具名 OnCall 至少一个
恢复时限未定义停止后多久恢复AbortWithin < MaxDuration
失败姿态配置缺失时继续运行任一缺失返回 Abort

这张表和 8.1 的预算表是互补的:8.1 管“最多能坏多少”,本节管“坏到什么程度必须停”。两张表都通过,才允许一次演练进入执行。

常见错误与边界

  • 把结论和止损写成一个状态机。 实验还没积累够样本时,状态机会停在“观察中”,止损也被一起挂起;两件事必须能独立推进。
  • 护栏用业务指标充当。 成功率下降可能只是演练的预期结果,不能作为“立即停止”的唯一依据;护栏应选择那些继续恶化就会造成不可逆损害的资源或依赖指标。
  • 容差按相对值随手写。 0.4% 的成功率容差和 100ms 的延迟容差量纲不同,必须逐信号定义,不能共用一个百分比。
  • 忘记乱序与窗口校验。 多采集器合并时时间戳可能回退,跨窗口拼接会制造虚假的“持续越界”;这类输入应报错,而不是继续评估。
  • 止损动作没有验证。 AbortWithin 只是声明,恢复动作是否真的在 30 秒内完成,需要单独演练;否则止损只是一句口号。
  • 把示例阈值当生产阈值。 本节的 0.995、300ms、10 分钟都是 arch-lab 的演示参数,真实系统应从中位数与错误预算反推。

边界同样要说明:连续越界只能抑制短时噪声,不能替代异常检测;对于周期性尖峰或缓慢漂移,可能需要窗口分位数或趋势判据。本文选择简单规则,是为了让判据可以被审批人复核,而不是追求统计最优。

小结

稳态假设的价值不在于“证明系统没问题”,而在于提前说清楚“出现什么现象就算被证伪”。把信号分成主张与护栏,用连续越界和最小样本抑制噪声,再把止损拆成独立决策,演练才从一次表演变成一次可解释的实验。

本节实现的 Evaluate 与 StopLoss.Check 都是纯函数:前者只读样本,后者只读结论与时长。它们不依赖真实时钟,因此可以在 CI 里固定复现;也因为职责分离,止损不会为了“让实验通过”而修改判据。

阅读导航:上一节:8.1 故障注入的层次:进程、网络、依赖与区域 · 下一节:8.3 从演练到修复:游戏日、回归与证据留存 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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