18.2 故障演练
上一节算出了「3 个副本、单副本 3000 QPS」的容量,但那只是稳态下的算术。真正的考验是:依赖挂掉、网络变慢、实例被杀时,这套系统是「优雅降级」还是「雪崩」?这个问题没法靠读代码回答,只能真的制造故障、观察行为。
本节把 TaskHub 推进到「故障行为经过实测」:本机真的
docker stop掉 Postgres、观察应用的失败模式,再恢复;并用代码注入延迟,观察超时是否按预期截断。所有结论都来自真跑的数据,不来自假设。
18.2.1 混沌工程的四条原则
混沌工程(Chaos Engineering)不是「随便搞破坏」,它有明确的方法论:
| 原则 | 含义 |
|---|---|
| 定义稳态 | 先明确「正常长什么样」(如 P99 < 200ms、错误率 < 0.1%) |
| 假设被推翻才算发现 | 提出「注入 X 故障后,系统仍满足稳态」的假设并验证 |
| 控制爆炸半径 | 从小范围开始,出问题能立刻停 |
| 可回滚 | 每个实验都有明确的终止与恢复手段 |
最容易被跳过的是第一条:没有稳态定义,演练就没有判据。你不能在「不知道正常长什么样」的情况下判断「现在是不是坏了」。TaskHub 的稳态定义沿用第 16 章的 SLO:可用性 ≥ 99.9%、P99 < 800ms。
还有一点认知要摆正:演练的目的是「发现系统会怎么坏」,而不是「证明系统不会坏」。如果每次演练都「通过」,要么是故障注入得不够狠,要么是假设定得太弱。一个健康的演练记录里,应该不时出现「假设被推翻」——那才是真正有价值的发现。
18.2.2 演练一:依赖宕机
假设:把 Postgres 停掉后,TaskHub 的查询应当快速失败(毫秒级返回错误),而不是挂起等待,从而不影响其他不依赖 DB 的接口。
准备一个最小客户端,对数据库做 30 次 SELECT 1,每次带 800ms 超时,统计成功/失败与最慢耗时:
db.SetMaxOpenConns(8)
db.SetConnMaxLifetime(time.Minute)
for i := 0; i < n; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
start := time.Now()
var v int
err := db.QueryRowContext(ctx, "SELECT 1").Scan(&v)
el := time.Since(start)
cancel()
// 统计 ok / fail 与最慢失败耗时
}
基线(Postgres 健康),本机真实输出:
结果: ok=30 fail=0 最慢失败耗时=0s
30/30 成功,符合预期。现在真的停掉容器:
docker stop gb16-pg
再跑同一个程序,本机真实输出(节选):
req 0 失败 用时=3ms err=failed to connect to `user=taskhub database=taskhub`: 127.0.0.1:55471 (127.0.0.1): dial error: dial tcp 127.0.0.1:55471: connect: connection refused
req 1 失败 用时=1ms err=... connection refused
req 29 失败 用时=0s err=... connection refused
结果: ok=0 fail=30 最慢失败耗时=5ms
结论:30/30 快速失败,最慢失败耗时仅 5ms。假设成立——连接被拒绝(connection refused)是快速失败,不会挂起。这很重要:如果这里是「挂起 800ms 才超时」,30 个并发请求就会占满连接池,把整个服务拖垮。
18.2.3 演练二:依赖恢复
假设:恢复 Postgres 后,TaskHub 应当自动重连,无需重启应用。
docker start gb16-pg
等 pg_isready 通过(本机约 1 秒)后再跑同一程序:
结果: ok=30 fail=0 最慢失败耗时=0s
30/30 成功。database/sql 的连接池会自动剔除失效连接、建立新连接,不需要应用重启。这是用 database/sql 而不是自己管连接的一个现实好处。
但要注意一个坑:sql.Open 不会立刻连接(它只是校验 DSN),真正的连接是惰性建立的。所以「应用启动时数据库没起来」不会让启动失败——这既是好事(启动顺序解耦),也是坏事(启动成功不代表依赖可用)。因此就绪探针必须真的探一次数据库,而不是只返回 200。
18.2.4 演练三:延迟注入
宕机是「快速失败」,但更阴险的是「慢速失败」:依赖连得上、但很慢。这类故障会占满连接与 goroutine,比宕机更容易引发雪崩。用代码注入延迟来验证超时是否按预期截断:
type dep struct{ delay time.Duration }
func (d dep) Call(ctx context.Context) error {
select {
case <-time.After(d.delay):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
分别测「健康(5ms 延迟)」与「注入 2 秒延迟」,每次调用带 500ms 超时,各跑 200 次:
go run ./latency
本机真实输出:
健康(5ms) timeout=500ms err= 0/200 p50=6ms p99=6ms
注入延迟(2s) timeout=500ms err=200/200 p50=501ms p99=508ms
解读:
- 健康时 p50/p99 都是 6ms,无错误。
- 注入 2 秒延迟后,200/200 全部在约 500ms 处超时失败,p50=501ms、p99=508ms——超时精确地截断了慢调用,没有等到 2 秒。
这正是我们要的行为:慢依赖被超时挡住,调用方在 500ms 内拿到错误,可以快速释放资源、返回降级结果或熔断。反过来,如果这里没有超时,200 个请求会各自挂满 2 秒,在途请求数(Little 定律里的 L)瞬间翻 4 倍,连接池被占满,健康的请求也开始排队——雪崩就是这么来的。
18.2.5 演练四:杀进程与滚动重启
进程被杀是容器环境里最常见的「故障」——OOM Killer、节点驱逐、滚动更新都会杀进程。这类演练的要点是验证「进程死掉时正在处理的请求会怎样」:
- 优雅停机(第 15.3 节):收到
SIGTERM后停止接收新请求、等待在途请求完成、再退出。缺少它,滚动更新时每个被替换的 Pod 都会丢掉正在处理的请求。 - 连接排空:Kubernetes 从 Service 摘除 Pod 与 Pod 收到
SIGTERM之间有延迟,需要preStop钩子或terminationGracePeriodSeconds配合。 - 幂等重试:即使优雅停机,客户端仍可能收到连接中断,需要幂等 + 重试才能安全恢复。
本节没有真的在集群里杀 Pod(本机无 Kubernetes 集群),但可以用本地进程验证优雅停机的逻辑:kill -TERM 一个正在处理请求的本地服务,观察它是否等在途请求完成。这是第 15.3 节已经讲过、可以在本地复现的部分。
优雅停机的骨架长这样——关键是 Shutdown 会阻塞到在途请求完成或超时:
srv := &http.Server{Addr: ":8080", Handler: mux}
// 单独 goroutine 里跑服务
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// 等待 SIGTERM / SIGINT
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
<-ctx.Done()
// 给在途请求最多 20 秒完成
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Println("forced shutdown:", err)
}
两个细节:Shutdown 超时(这里是 20 秒)必须小于 Kubernetes 的 terminationGracePeriodSeconds,否则 kubelet 会先发 SIGKILL,优雅停机就白做了;signal.NotifyContext 让信号处理与 context 取消统一,比手写 channel 更简洁。
18.2.6 网络故障的模拟方式
依赖宕机(docker stop)和延迟(代码注入)都能在本机复现,但网络分区与丢包需要更底层的手段。常见做法:
| 手段 | 模拟的故障 | 本机可用性 |
|---|---|---|
docker stop | 依赖进程死亡 | 可用(本节已用) |
| 代码注入延迟/错误 | 依赖变慢/返回错误 | 可用(本节已用) |
tc netem | 网络延迟、丢包、分区 | 本机未验证(需 root 与 netns) |
| Service Mesh fault injection | 请求级故障注入 | 需集群(本机无) |
| 代理层返回错误 | 上游 5xx | 可用(改本地代理配置) |
本节的 tc netem 未在本机实跑(macOS 无 Linux 的 tc,且需网络命名空间权限)。在没有这些工具时,用「代码里注入延迟/错误」是等价且更可控的替代——它注入的位置更精确(某个具体调用),代价是「只测到了你想到的那条路径」。生产环境的混沌平台通常结合两者:基础设施层用 netem,应用层用故障注入开关。
18.2.7 演练的组织与频率
演练不该是「上线前突击一次」,而应是常态化的:
- 每次重大变更前:跑一遍相关的故障场景。
- 每月一次 game day:团队一起做,轮流当「故障注入者」与「值班者」。
- 季度一次全链路演练:跨服务的故障,验证升级路径与沟通流程。
组织上的关键是让演练有惊无险:先在小流量环境跑,确认恢复手段有效,再逐步扩大。没有「一键停止」能力的演练是危险的——它可能把「演习」变成「真事故」。
18.2.8 失败模式目录
把演练的发现整理成一张表,作为后续设计和排查的依据:
| 故障 | 失败类型 | 实测行为 | 缓解 |
|---|---|---|---|
| Postgres 宕机 | 快速失败 | 1~5ms 返回错误 | 连接池自动重连 |
| 依赖变慢 | 慢速失败 | 500ms 超时截断 | 显式超时 + 熔断 |
| 进程被杀 | 请求中断 | 需优雅停机 | SIGTERM + 排空 |
| 消息积压 | 延迟增长 | 需积压告警 | 消费限流 + 扩容 |
| 网络分区 | 部分失败 | 需超时 + 重试 | 幂等 + 指数退避 |
这张表的核心价值是区分快速失败与慢速失败:前者通常是安全的(资源快速释放),后者才是雪崩的根源。演练时特别要盯住慢速失败。
18.2.9 演练自检清单
- 有明确的稳态定义(SLO、P99、错误率)
- 每个实验先写「假设」,用数据验证或推翻
- 爆炸半径受控,出问题能立刻停止
- 演练过依赖宕机,确认是快速失败
- 演练过延迟注入,确认超时能截断慢调用
- 演练过进程被杀,验证优雅停机与连接排空
- 每次演练后恢复环境(本节的容器已
docker rm -f清理) - 发现整理成失败模式目录,驱动改进
小结
- 混沌工程四原则:定义稳态、假设驱动、控制爆炸半径、可回滚;没有稳态就没有判据。
- 本机真实演练:
docker stop掉 Postgres 后 30/30 快速失败,最慢 5ms,无挂起;恢复后 30/30 成功,应用自动重连无需重启。 sql.Open惰性连接,启动成功不代表依赖可用——就绪探针必须真探一次数据库。- 本机真实演练:注入 2 秒延迟后,200/200 请求在 500ms 超时处被截断,P99 从 6ms 升到 508ms;超时是防雪崩的关键。
- 慢速失败比宕机更危险,因为它占满连接与 goroutine;演练要特别盯住它。
- 本节未在真实 Kubernetes 集群杀 Pod(本机无集群),优雅停机部分为本地可复现的逻辑说明。
tc netem网络分区未在本机验证(macOS 无 Linuxtc),用代码注入延迟/错误作为等价替代。- 演练应常态化(变更前 + 月度 game day + 季度全链路),且必须能一键停止,避免演习变事故。
- 演练的判据是「系统会怎么坏」,而不是「证明系统不会坏」;假设被推翻才是有价值的发现。
- 优雅停机的
Shutdown超时必须小于 kubelet 的terminationGracePeriodSeconds,否则会被 SIGKILL。
演练暴露的弱点会变成改进项,而改进上线后又要靠观测来验证——这正是最后一节的主题。18.3 上线、观测与迭代 会把 Go/No-Go 清单、观测三支柱与迭代闭环收束成一份可执行的上线手册。
阅读导航:上一节:18.1 架构复盘与容量规划 · 下一节:18.3 上线、观测与迭代 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。