《Go 语言编程实战》18.2 故障演练

「高可用」不能靠假设,只能靠演练验证。本节真的把 TaskHub 的依赖停掉、注入延迟、再恢复,用真实数据检验它的失败行为:docker stop 掉 Postgres 后 30 次查询全部快速失败(1~3ms,无挂起),恢复后 30/30 成功;代码注入 2 秒延迟后 200/200 请求在 500ms 超时处被截断,P99 从 6ms 升到 508ms。

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 无 Linux tc),用代码注入延迟/错误作为等价替代。
  • 演练应常态化(变更前 + 月度 game day + 季度全链路),且必须能一键停止,避免演习变事故。
  • 演练的判据是「系统会怎么坏」,而不是「证明系统不会坏」;假设被推翻才是有价值的发现。
  • 优雅停机的 Shutdown 超时必须小于 kubelet 的 terminationGracePeriodSeconds,否则会被 SIGKILL。

演练暴露的弱点会变成改进项,而改进上线后又要靠观测来验证——这正是最后一节的主题。18.3 上线、观测与迭代 会把 Go/No-Go 清单、观测三支柱与迭代闭环收束成一份可执行的上线手册。

阅读导航:上一节:18.1 架构复盘与容量规划 · 下一节:18.3 上线、观测与迭代 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.1 架构复盘与容量规划