一次被批准的“8%”故障
周三下午,订单团队提交了一次演练申请:杀掉 order 服务的一个实例,预计影响面 8%。审批表上只有一句“实例占比低于 10%”,负责人签了字。
执行后,订单成功率从 99.7% 掉到 61%,持续了四分钟。事后复盘发现两件事。第一,被杀的实例恰好持有大量长连接,重启期间客户端重试把流量压到剩余实例。第二,同一时段另一个团队的网络演练正在切断 order 到 inventory 的调用,两个故障叠加,CREATED → INVENTORY_RESERVED 这个状态转换整体失效。
审批时看到的 8% 是“请求杀几个实例”,不是“实际会坏掉多少”。这就是本节要解决的问题:故障注入的层次决定了它能证明什么,而爆炸半径必须由拓扑和关键状态转换算出,不能由申请人的百分比代替。
如果你还不熟悉混沌工程的整体闭环,可以先看 混沌工程专题 ;本节只讨论其中最容易做错的一步——故障从哪一层进入,以及如何提前算清它会带走什么。
四层注入,各证明一半
同样叫“制造故障”,注入点不同,结论完全不同。把四层混在一起,演练就会变成“我们杀过进程,所以系统是韧性的”这种无效结论。
| 层次 | 注入方式 | 能证明 | 不能证明 | 常见误用 |
|---|---|---|---|---|
| 进程 | 杀实例、停进程、返回错误 | 单实例失效后的摘流与接管 | 依赖变慢、网络分区 | 用重启代替故障 |
| 网络 | 加延迟、丢包、断链 | 超时、重试、熔断是否收敛 | 进程崩溃后的状态恢复 | 把延迟故障当进程故障统计 |
| 依赖 | 让下游整体不可用 | 降级、缓存、排队是否有效 | 单实例或单可用区问题 | 只测一个下游调用 |
| 区域 | 整区实例同时失效 | 多可用区容量与切流 | 跨地域切换 | 假设每个区实例数相同 |
四层不是难度递进,而是证伪能力不同。进程故障证明“少一个实例还行”;网络故障证明“链路变慢时调用方能自己停下来”;依赖故障证明“下游全挂时上游还有退路”;区域故障证明“容量没有押在一个故障域上”。任何一层缺失,都不能用另一层的通过来补。
这也解释了为什么“实例占比”单独作为预算不够。占比只描述资源损失,不描述业务状态转换损失。一个只占 27% 的区域故障,可能恰好包含支付服务的唯一实例,把 INVENTORY_RESERVED → PAID 整条边打断;而占 36% 的区域故障可能只影响无状态查询,业务上几乎无感。预算必须同时约束实例占比和关键转换损失。
故障域本身的划分可以参照 部署单元与故障域 :先定义“一起坏”的最小集合,再谈注入比例,否则统计口径会随部署方式漂移。
用拓扑算真实半径
arch-lab 的订单主链路只有三个服务:
order六个实例,分布在 a、b、c 三个区;inventory四个实例,只在 a、b 两个区;payment只有一个实例pay-01,位于 c 区。
关键状态转换只登记两条:CREATED → INVENTORY_RESERVED 依赖 order 与 inventory,INVENTORY_RESERVED → PAID 依赖 order 与 payment。判定规则很朴素:只要转换任意一端的服务所有实例都受影响,这条转换就视为丢失。
这个模型故意不追求完整,而追求可审计。拓扑是演练前声明的数据,不是运行时向被演练系统发现出来的;系统正在被破坏时,它的自述不可信。
先看几个结果:
- 杀
order-03:1/11 实例,9.1%,没有转换丢失; - 切断
order → inventory:order 全部 6 个实例受影响,54.5%,两条转换都丢失; - 让
inventory整体不可用:4/11,36.4%,丢失CREATED → INVENTORY_RESERVED; - 整区 b 失效:4/11,36.4%,没有关键转换丢失;
- 整区 c 失效:3/11,27.3%,但
payment全灭,丢失INVENTORY_RESERVED → PAID。
最后一行是本节的核心:27% 比 36% 更危险。如果预算只写“实例占比不超过 30%”,区 c 会被放行;只有把“丢失转换数”设为 0,它才会被拒绝。
Go 实现:先验层语义,再算半径
下面的实现把两件事分开:impact 负责把故障翻译成受影响实例集合,Assess 负责校验该层允许的故障形状、计算占比与转换损失、最后执行预算。任何一步不成立都返回错误,调用方拿不到“带警告的通过”。
// file: internal/platform/chaos/inject.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos
import (
"fmt"
"sort"
"time"
)
type Layer string
const (
ProcessLayer Layer = "process"
NetworkLayer Layer = "network"
DependencyLayer Layer = "dependency"
ZoneLayer Layer = "zone"
)
type Transition struct {
From, To, State string
}
type Topology struct {
Instances map[string][]string
Zones map[string]string
Critical []Transition
}
type Target struct {
Service string
Instances []string
}
type Fault struct {
ID string
Layer Layer
Target Target
Zone string
Downstream string
StartAt time.Time
Duration time.Duration
Delay time.Duration
}
type BlastRadius struct {
Instances int
Share float64
Transitions []string
}
type Budget struct {
MaxInstanceShare float64
MaxLostTransitions int
}
func (f Fault) impact(t Topology) (map[string]bool, error) {
out := map[string]bool{}
switch f.Layer {
case ZoneLayer:
for svc, ids := range t.Instances {
for _, id := range ids {
if t.Zones[id] == f.Zone {
out[svc+"/"+id] = true
}
}
}
case DependencyLayer:
ids, ok := t.Instances[f.Downstream]
if !ok {
return nil, fmt.Errorf("chaos: unknown dependency %q", f.Downstream)
}
for _, id := range ids {
out[f.Downstream+"/"+id] = true
}
case ProcessLayer, NetworkLayer:
ids, ok := t.Instances[f.Target.Service]
if !ok {
return nil, fmt.Errorf("chaos: unknown service %q", f.Target.Service)
}
if len(f.Target.Instances) > 0 {
known := map[string]bool{}
for _, id := range ids {
known[id] = true
}
for _, id := range f.Target.Instances {
if !known[id] {
return nil, fmt.Errorf("chaos: unknown instance %q in %s", id, f.Target.Service)
}
}
ids = f.Target.Instances
}
for _, id := range ids {
out[f.Target.Service+"/"+id] = true
}
default:
return nil, fmt.Errorf("chaos: unknown layer %q", f.Layer)
}
if len(out) == 0 {
return nil, fmt.Errorf("chaos: fault %s selects no instance", f.ID)
}
return out, nil
}
func Assess(f Fault, t Topology, b Budget) (BlastRadius, error) {
if f.ID == "" || f.StartAt.IsZero() || f.Duration <= 0 {
return BlastRadius{}, fmt.Errorf("chaos: fault needs an id, start and positive duration")
}
switch f.Layer {
case ProcessLayer:
if f.Delay != 0 || f.Downstream != "" || f.Zone != "" {
return BlastRadius{}, fmt.Errorf("chaos: process fault %s cannot delay or target a link", f.ID)
}
case NetworkLayer:
if f.Downstream == "" || f.Delay <= 0 {
return BlastRadius{}, fmt.Errorf("chaos: network fault %s needs a downstream and a delay", f.ID)
}
case DependencyLayer:
if f.Downstream == "" || f.Delay != 0 {
return BlastRadius{}, fmt.Errorf("chaos: dependency fault %s needs a downstream and no delay", f.ID)
}
case ZoneLayer:
if f.Zone == "" || len(f.Target.Instances) > 0 || f.Delay != 0 {
return BlastRadius{}, fmt.Errorf("chaos: zone fault %s must be an all-or-nothing zone loss", f.ID)
}
default:
return BlastRadius{}, fmt.Errorf("chaos: unknown layer %q", f.Layer)
}
if b.MaxInstanceShare <= 0 || b.MaxInstanceShare > 1 || b.MaxLostTransitions < 0 {
return BlastRadius{}, fmt.Errorf("chaos: invalid budget")
}
affected, err := f.impact(t)
if err != nil {
return BlastRadius{}, err
}
br := BlastRadius{Instances: len(affected)}
total := 0
for _, ids := range t.Instances {
total += len(ids)
}
if total > 0 {
br.Share = float64(len(affected)) / float64(total)
}
seen := map[string]bool{}
for _, c := range t.Critical {
lost := allAffected(t, affected, c.From) || allAffected(t, affected, c.To)
if lost && !seen[c.State] {
seen[c.State] = true
br.Transitions = append(br.Transitions, c.State)
}
}
sort.Strings(br.Transitions)
if br.Share > b.MaxInstanceShare+1e-9 {
return br, fmt.Errorf("chaos: fault %s affects %.1f%%, budget %.1f%%",
f.ID, br.Share*100, b.MaxInstanceShare*100)
}
if len(br.Transitions) > b.MaxLostTransitions {
return br, fmt.Errorf("chaos: fault %s loses %v, budget %d",
f.ID, br.Transitions, b.MaxLostTransitions)
}
return br, nil
}
func allAffected(t Topology, affected map[string]bool, svc string) bool {
ids, ok := t.Instances[svc]
if !ok || len(ids) == 0 {
return false
}
for _, id := range ids {
if !affected[svc+"/"+id] {
return false
}
}
return true
}
注意 Assess 返回半径的时机:即使超预算,也把算出的 BlastRadius 一起返回。这样日志记录的是“实际会坏多少”,而不是申请人填写的数字;审批被拒绝时,拒绝理由里也能直接看到丢失了哪条转换。
表驱动测试:把五个边界钉住
测试不需要真实集群,只需要一张声明好的拓扑和固定时钟。重点不是覆盖率,而是把“层语义”和“预算顺序”固定成可回归的判据。
// file: internal/platform/chaos/inject_test.go
// tier: L1
// verify: go test ./internal/platform/chaos/ -count=1
package chaos
import (
"math"
"strings"
"testing"
"time"
)
func orderTopology() Topology {
return Topology{
Instances: map[string][]string{
"order": {"order-01", "order-02", "order-03", "order-04", "order-05", "order-06"},
"inventory": {"inv-01", "inv-02", "inv-03", "inv-04"},
"payment": {"pay-01"},
},
Zones: map[string]string{
"order-01": "a", "order-02": "b", "order-03": "c",
"order-04": "a", "order-05": "b", "order-06": "c",
"inv-01": "a", "inv-02": "b", "inv-03": "a", "inv-04": "b", "pay-01": "c",
},
Critical: []Transition{
{From: "order", To: "inventory", State: "CREATED->INVENTORY_RESERVED"},
{From: "order", To: "payment", State: "INVENTORY_RESERVED->PAID"},
},
}
}
func at(hour, minute int) time.Time {
return time.Date(2026, 10, 30, hour, minute, 0, 0, time.UTC)
}
func TestAssessByLayer(t *testing.T) {
start := at(10, 0)
loose := Budget{MaxInstanceShare: 0.6, MaxLostTransitions: 2}
cases := []struct {
name string
f Fault
b Budget
want BlastRadius
wantErr string
}{
{"process", Fault{ID: "p", Layer: ProcessLayer, Target: Target{Service: "order", Instances: []string{"order-03"}}, StartAt: start, Duration: time.Minute},
loose, BlastRadius{Instances: 1, Share: 1.0 / 11}, ""},
{"network", Fault{ID: "n", Layer: NetworkLayer, Target: Target{Service: "order"}, Downstream: "inventory", Delay: 200 * time.Millisecond, StartAt: start, Duration: time.Minute},
loose, BlastRadius{Instances: 6, Share: 6.0 / 11, Transitions: []string{"CREATED->INVENTORY_RESERVED", "INVENTORY_RESERVED->PAID"}}, ""},
{"dependency", Fault{ID: "d", Layer: DependencyLayer, Downstream: "inventory", StartAt: start, Duration: time.Minute},
loose, BlastRadius{Instances: 4, Share: 4.0 / 11, Transitions: []string{"CREATED->INVENTORY_RESERVED"}}, ""},
{"zone-b", Fault{ID: "z", Layer: ZoneLayer, Zone: "b", StartAt: start, Duration: time.Minute},
loose, BlastRadius{Instances: 4, Share: 4.0 / 11}, ""},
{"zone-c", Fault{ID: "zc", Layer: ZoneLayer, Zone: "c", StartAt: start, Duration: time.Minute},
Budget{MaxInstanceShare: 0.4}, BlastRadius{}, "loses"},
{"process-delay", Fault{ID: "pd", Layer: ProcessLayer, Target: Target{Service: "order"}, Delay: time.Second, StartAt: start, Duration: time.Minute},
loose, BlastRadius{}, "cannot delay"},
}
for _, tc := range cases {
br, err := Assess(tc.f, orderTopology(), tc.b)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("%s: err = %v, want %q", tc.name, err, tc.wantErr)
}
t.Logf("%s rejected: %v", tc.name, err)
continue
}
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if br.Instances != tc.want.Instances || math.Abs(br.Share-tc.want.Share) > 1e-9 ||
strings.Join(br.Transitions, ",") != strings.Join(tc.want.Transitions, ",") {
t.Fatalf("%s: got %+v, want %+v", tc.name, br, tc.want)
}
t.Logf("%s instances=%d share=%.4f transitions=%v", tc.name, br.Instances, br.Share, br.Transitions)
}
}
本机运行 go test ./internal/platform/chaos/ -count=1 -v,与本篇相关的原始输出如下(示例值来自固定拓扑,不代表线上容量):
=== RUN TestAssessByLayer
inject_test.go:75: process instances=1 share=0.0909 transitions=[]
inject_test.go:75: network instances=6 share=0.5455 transitions=[CREATED->INVENTORY_RESERVED INVENTORY_RESERVED->PAID]
inject_test.go:75: dependency instances=4 share=0.3636 transitions=[CREATED->INVENTORY_RESERVED]
inject_test.go:75: zone-b instances=4 share=0.3636 transitions=[]
inject_test.go:65: zone-c rejected: chaos: fault zc loses [INVENTORY_RESERVED->PAID], budget 0
inject_test.go:65: process-delay rejected: chaos: process fault pd cannot delay or target a link
--- PASS: TestAssessByLayer (0.00s)
PASS
ok archlab/internal/platform/chaos 0.003s
判据表:一次注入申请该问什么
| 检查项 | 不合格的表现 | 本文的判据 |
|---|---|---|
| 层次语义 | 进程故障填了延迟参数 | 层与故障形状不匹配直接拒绝 |
| 影响集合 | 只写“杀 1 个 Pod” | 由拓扑解析出实例 ID,未知实例报错 |
| 占比口径 | 按服务内占比计算 | 统一用全拓扑实例数作分母 |
| 状态转换 | 只看请求量和错误率 | 任一端全灭即记一条转换损失 |
| 预算顺序 | 先执行再观察 | 实例占比与转换数都在执行前检查 |
| 拒绝行为 | 打印警告后继续 | 返回错误,调用方无法拿到通过 |
这张表可以直接放进演练申请模板。最关键的是最后一行:混沌工具最常见的坏味道,是“预算超了但已经注入了”。校验必须在注入之前完成,而且失败要向上返回,而不是降级成日志。
常见错误与边界
- 把 Pod 数当实例数。 一个服务可能有多个进程共享同一节点,杀 Pod 与杀节点的故障域不同;拓扑里必须写清楚实例的物理边界。
- 只用服务内占比。 “order 的 1/6”看起来很小,但换成全局分母后可能与他人故障叠加超预算;预算应统一口径,并在演练编排层做全局合并。
- 假设区域对称。 本例中 a、b、c 的实例数并不相同,区 c 的占比最低却最危险。不要用“三个区各三分之一”的心算替代拓扑数据。
- 把依赖故障当网络故障。 依赖层是“下游整体不可用”,不需要延迟参数;网络层必须指明下游和延迟。两者混用会让结论无法归因。
- 忽略转换的方向。
From与To任一端全灭都会丢转换,但修复责任方不同;记录时要保留方向,不能只记一个服务名。 - 把校验写成建议。 如果
Assess只在超标时打日志,预算就退化成仪表盘。本文的实现选择 fail closed:宁可拒绝一次演练,也不制造一次无法解释的事故。
边界也要说清楚:这个模型只适合声明式拓扑,不覆盖动态扩缩容、跨集群流量调度和灰度中的部分实例。它算的是静态上界,不是运行时的精确影响。真实演练仍需要独立的止损与观测,这部分留到 8.2。
小结
故障注入的层次不是分类学,而是证伪能力的边界。进程、网络、依赖、区域四层各自只能回答一部分问题,不能用一层的通过替代另一层。爆炸半径也不能由申请人填写的百分比决定,必须由拓扑算出实例集合,再叠加关键状态转换的损失。
本节给出的 Assess 把三件事固定下来:层语义不匹配直接拒绝、占比统一用全局分母、转换损失与实例占比同时受预算约束。它输出的不是“批准/拒绝”两个词,而是一个可复核的 BlastRadius,让审批记录能够解释“为什么 27% 比 36% 更危险”。
阅读导航:上一节:7.3 依赖级联:多服务 SLO 的预算分配 · 下一节:8.2 演练设计:稳态假设、爆炸半径与止损 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。