一次“指标看起来还行”的演练
演练开始前,负责人写下的假设是:注入 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。规则是:
- 每个采样点与基线比较,绝对偏差超过容差记为越界;
- 连续越界计数累加,一旦回到带内就清零;
- 连续次数达到
Breach时,该信号触发; - 护栏触发优先于主张触发,直接给出
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 从演练到修复:游戏日、回归与证据留存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。