演练通过,回切却失败了
「回声」每季度做一次容灾演练。脚本很完整:停止旧单元写入、把 DNS 切到新单元、验证登录、验证下单、验证支付回调,最后在群里发一句“演练通过”。连续三次,全部通过。
第四次是真的故障。华东单元不可用,团队按演练脚本切到备用单元,业务恢复。两天后机房修复,团队决定回切。回切脚本沿用了演练里的步骤,只是把方向反过来:停新单元写入、切 DNS、验证登录、验证下单。前两步很顺利,第三步开始报错:大量用户登录后看不到自己的订单。
排查发现,备用单元在这两天里产生了 18 万条新订单和 4 万条用户资料修改,这些数据只存在于备用单元。旧单元的数据库停留在故障发生的那一刻,回切脚本没有做反向同步,DNS 一切,用户就被送回了一个“两天前”的世界。
更隐蔽的是版本号。旧单元的资料版本还停留在 6,备用单元已经到 9。用户回切后修改资料,版本从 6 自增到 7,再次同步时被备用单元的 9 覆盖。用户看到自己刚改的内容消失,客服再次收到投诉。
这次事故的根因不是脚本写错了某一行,而是团队把“切换”“回切”“演练”当成了同一个流程的正反方向。它们的目标、前置条件和成功标准都不同,必须分开设计。
切换、回切、演练是三件事
| 动作 | 目标 | 前置条件 | 成功标准 | 主要风险 |
|---|---|---|---|---|
| 切换 | 把流量从故障单元移走 | 新单元容量足够、数据可读 | 核心链路恢复、错误率回落 | 容量不足、数据不完整 |
| 回切 | 把流量移回原单元 | 双向数据已追平、版本已对齐 | 数据不丢、不回退、可继续写入 | 旧数据覆盖新数据 |
| 演练 | 验证切换与回切都可执行 | 有可回退的流量、有观测手段 | 每一步有真实输出、失败可定位 | 只演切换不演回切 |
三件事的区别可以用一句话概括:切换是“逃出去”,回切是“搬回来”,演练是“两条路都走一遍并留下证据”。逃出去只需要新单元能接住流量;搬回来还要求旧单元知道离开期间发生了什么。
很多团队的演练只覆盖切换,因为切换是故障时最紧急的动作,也最容易演示。但回切才是数据风险最高的阶段:切换时用户已经接受“可能有损”,回切时用户期待“一切恢复原样”,任何数据回退都会被感知为事故。
演练的目标也不是“脚本跑通”。脚本跑通只能证明命令能执行,不能证明数据正确。有效的演练必须包含可验证的断言:切换后新写入是否落到新单元、回切前反向同步是否完成、回切后旧单元能否读到这些写入、版本号是否单调。
回切前必须回答的问题
回切不是把 DNS 改回去那么简单。动手之前,至少要让四个问题有明确答案。
- 离开期间产生了哪些写入?订单、支付、资料、消息,每一类都要有清单和数量。
- 这些写入是否已经同步回旧单元?同步完成的判据是什么,是行数、校验和,还是抽样比对?
- 旧单元的版本号、序列号、ID 生成器是否还领先?如果落后,回切后如何避免覆盖或重复?
- 如果同步不完整,回切能不能只回一部分用户?不能的话,是否应该推迟回切?
四个问题里只要有一个没有答案,回切就应该暂停。暂停的代价是继续维护两套环境,回切失败的代价是用户数据回退,后者通常更贵。
案例假设:上面那次事故里,18 万条订单的反向同步按每秒 2000 条计算需要 90 秒,加上校验大约 5 分钟。团队并不是没有时间,而是没有把反向同步写进回切脚本。问题出在流程设计,不出在性能。
一次只切不回的事故
把上一节的事故拆开看,会发现三个独立失误叠加在一起。
第一个失误是演练范围不完整。演练脚本只验证了“切过去”,没有验证“切回来”。团队因此误以为回切只是把方向反过来,忽略了反向同步这个新增步骤。
第二个失误是数据归属没有在切换时切换。切到备用单元后,系统仍然把备用单元当成“临时副本”,写入路径没有更新家单元映射。结果备用单元产生了新数据,但没有任何组件认为这些数据需要回流。
第三个失误是版本分配没有跟着切。旧单元的版本分配器在故障期间停止工作,备用单元从旧值继续分配,两边各自产生了重叠的版本号。回切后,同一份记录出现两个“版本 7”,合并逻辑只能按时间戳裁决。
三个失误里,第二个最致命。只要数据归属明确,反向同步就有明确的源头和目的地;归属不明确,同步脚本即使写了,也不知道该同步哪些表、哪些用户、按什么顺序。
修复方案也对应三层:演练脚本增加回切阶段和断言;切换时同步更新家单元映射,让备用单元成为临时家单元;版本分配器跟随家单元迁移,或者在回切前统一重放并重新分配版本。
事故之后,团队把回切拆成了一个独立的演练科目,和切换分开排期。切换演练每月一次,回切演练每季度一次,两者都要求留下真实的命令输出和数据比对结果。这个调整看起来增加了工作量,但它把“回切失败”从故障时的惊喜变成了演练时的已知问题。
用 Go 实现演练执行器
下面的代码来自本书配套的 webapp-lab。Validate 负责拒绝没有步骤、Action 为空或步骤名重复的定义;Run 逐步执行,某一步失败后继续执行剩余步骤并记录失败名,单步 panic 也会被转换为失败。
// file: internal/cell/drill.go
// tier: L1
// verify: go test ./internal/cell/
package cell
import (
"errors"
"fmt"
)
// Step 是演练中的一个可执行动作。Expected 只用于生成报告,不参与判定。
type Step struct {
Name string
Action string
Expected string
}
// Drill 是一组有序步骤。
type Drill struct {
Name string
Steps []Step
}
// Result 记录通过数量与失败步骤名,失败步骤按执行顺序排列。
type Result struct {
Passed int
Failed []string
}
// Validate 拒绝没有步骤、Action 为空或步骤名重复的演练定义。
func Validate(d Drill) error {
if len(d.Steps) == 0 {
return errors.New("cell: drill has no steps")
}
seen := make(map[string]bool, len(d.Steps))
var errs []error
for i, s := range d.Steps {
if s.Name == "" {
errs = append(errs, fmt.Errorf("cell: step %d has empty name", i))
}
if s.Action == "" {
errs = append(errs, fmt.Errorf("cell: step %q has empty action", s.Name))
}
if s.Name != "" {
if seen[s.Name] {
errs = append(errs, fmt.Errorf("cell: duplicate step name %q", s.Name))
}
seen[s.Name] = true
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
// Run 逐步执行演练。某一步失败后继续执行剩余步骤并记录失败,
// 单步 panic 也会被转换为失败,而不是中断整场演练。
func Run(d Drill, exec func(Step) error) (Result, error) {
if exec == nil {
return Result{}, errors.New("cell: nil exec")
}
if err := Validate(d); err != nil {
return Result{}, err
}
var r Result
for _, s := range d.Steps {
if err := runStep(exec, s); err != nil {
r.Failed = append(r.Failed, s.Name)
continue
}
r.Passed++
}
return r, nil
}
func runStep(exec func(Step) error, s Step) (err error) {
defer func() {
if v := recover(); v != nil {
err = fmt.Errorf("cell: step %q panicked: %v", s.Name, v)
}
}()
return exec(s)
}
几个设计选择值得说明。
Validate把多个问题用errors.Join合并返回,而不是遇到第一个就退出。演练定义通常在评审阶段编写,一次列出所有问题比修一个再跑一次更快。Run先校验再执行。定义不合法时直接返回错误,避免执行到一半才发现步骤名重复,留下半完成的副作用。- 失败步骤只记录名字,不记录错误内容。
Result是给报告和告警用的摘要,详细错误应该由exec自己写日志,避免同一个错误在两层重复记录。 runStep用recover把 panic 转成普通错误。演练里经常调用外部脚本和网络接口,空指针或越界 panic 不应该中断整场演练,更不应该让值班同学以为“程序挂了”。exec为 nil 时返回错误。静默执行一个空函数会得到“全部通过”的假结果,这比失败更危险。
代码没有实现超时、重试和并发。这些应该由调用方在 exec 里控制:每一步的超时预算不同,重试策略也不同,塞进通用执行器只会让语义变模糊。执行器只负责“按顺序走完、失败不中断、结果可汇总”。
测试:失败继续与 panic 兜底
演练执行器最重要的契约不是“全部成功时返回成功”,而是“失败时仍然走完剩余步骤”。下面的测试覆盖空演练、空 Action、重复名、正常定义、中途失败、单步 panic 和非法输入。
// file: internal/cell/drill_test.go
// tier: L1
// verify: go test ./internal/cell/
package cell
import (
"errors"
"strings"
"testing"
)
func TestValidateRejectsEmptyDrill(t *testing.T) {
if err := Validate(Drill{Name: "empty"}); err == nil {
t.Fatal("want error for drill without steps")
}
}
func TestValidateRejectsEmptyAction(t *testing.T) {
d := Drill{Name: "d", Steps: []Step{{Name: "a", Action: ""}}}
err := Validate(d)
if err == nil || !strings.Contains(err.Error(), "empty action") {
t.Fatalf("err = %v, want empty action", err)
}
}
func TestValidateRejectsDuplicateName(t *testing.T) {
d := Drill{Name: "d", Steps: []Step{
{Name: "switch", Action: "cut"},
{Name: "switch", Action: "back"},
}}
err := Validate(d)
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("err = %v, want duplicate", err)
}
}
func TestValidateAcceptsWellFormedDrill(t *testing.T) {
d := Drill{Name: "d", Steps: []Step{
{Name: "switch", Action: "cut", Expected: "traffic moved"},
{Name: "verify", Action: "read", Expected: "data present"},
}}
if err := Validate(d); err != nil {
t.Fatal(err)
}
}
func TestRunContinuesAfterFailure(t *testing.T) {
d := Drill{Name: "failback", Steps: []Step{
{Name: "switch", Action: "cut"},
{Name: "verify", Action: "read"},
{Name: "back", Action: "return"},
}}
fail := errors.New("boom")
got, err := Run(d, func(s Step) error {
if s.Name == "verify" {
return fail
}
return nil
})
if err != nil {
t.Fatal(err)
}
if got.Passed != 2 {
t.Fatalf("passed = %d, want 2", got.Passed)
}
if len(got.Failed) != 1 || got.Failed[0] != "verify" {
t.Fatalf("failed = %v, want [verify]", got.Failed)
}
}
func TestRunRecoversStepPanic(t *testing.T) {
d := Drill{Name: "panic", Steps: []Step{
{Name: "first", Action: "noop"},
{Name: "boom", Action: "panic"},
{Name: "last", Action: "noop"},
}}
got, err := Run(d, func(s Step) error {
if s.Name == "boom" {
panic("unexpected")
}
return nil
})
if err != nil {
t.Fatal(err)
}
if got.Passed != 2 || len(got.Failed) != 1 || got.Failed[0] != "boom" {
t.Fatalf("result = %+v, want passed=2 failed=[boom]", got)
}
}
func TestRunRejectsInvalidInput(t *testing.T) {
if _, err := Run(Drill{}, func(Step) error { return nil }); err == nil {
t.Fatal("want validation error")
}
d := Drill{Name: "d", Steps: []Step{{Name: "a", Action: "x"}}}
if _, err := Run(d, nil); err == nil {
t.Fatal("want nil exec error")
}
}
TestRunContinuesAfterFailure 固定了“失败不中断”的语义。三步里第二步失败,期望通过 2 步、失败列表只有 verify。这个用例能挡住“遇到错误就 return”的改动,也能防止有人把失败步骤从结果里漏掉。
TestRunRecoversStepPanic 验证 panic 被转换为失败。它同时检查 panic 之后的步骤仍然执行,确保 recover 没有把整个循环一起终止。真实演练里,一个探针 panic 不应该让后面的回切步骤全部跳过。
TestValidateRejectsDuplicateName 看起来只是参数校验,但它防止的是一个很实际的问题:报告里出现两个同名步骤时,值班同学无法判断失败的是哪一步。步骤名是报告的主键,必须在入口处保证唯一。
测试没有覆盖步骤之间的依赖。比如“验证登录”依赖“切换 DNS”已经生效,执行器不知道这个关系,只会按顺序调用。依赖关系应该由步骤顺序和 exec 内部的等待逻辑表达,而不是让执行器去理解业务。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/cell/ -count=1
一次本机运行的真实输出如下,耗时随机器变化:
ok webapp/internal/cell 0.233s
ok 表示包内所有测试通过。输出没有列出用例数量,是因为没有加 -v;需要逐个确认时可以加 -v 再跑一次。判定标准仍然是退出码为 0 且没有 FAIL。
小结
切换、回切、演练是三件不同的事。切换的目标是尽快把流量移出故障单元;回切的目标是把流量移回原单元且数据不回退;演练的目标是证明两条路都能走、每一步都有可验证的输出。把三者当成同一个流程的正反方向,是回切事故最常见的来源。
回切前必须回答四个问题:离开期间产生了哪些写入、是否已经同步回旧单元、版本和序列号是否对齐、同步不完整时能否部分回切。任何一个问题没有答案,回切都应该暂停。
演练执行器要把“失败不中断”和“panic 不扩散”做成硬约束,同时把详细错误留给步骤自己记录。执行器不负责超时、重试和业务依赖,这些属于步骤的实现,不属于流程框架。
下一章会把视角转到流量本身:热点事件到来时,请求量、数据倾斜和成本会同时变化,单元化只能隔离故障,不能自动消化热点。
阅读导航:上一节:2.2 数据同步与冲突 · 下一节:3.1 热点事件的流量特征 。
常见错误与边界
- 把回切当成切换的反向操作。回切多了反向同步和版本对齐,步骤和目标都不同。
- 演练只演切换。只切不回会掩盖回切阶段的数据风险。
- 演练以“脚本跑通”为成功标准。没有数据断言,跑通不代表数据正确。
- 切换后不更新家单元映射。备用单元产生的写入没有归属,回切时无人负责回流。
- 版本分配器不跟随迁移。两个单元产生重叠版本,回切后只能靠时间戳猜。
- 失败就中断演练。后面的步骤没有执行,报告无法反映完整风险。
- 让 panic 终止整场演练。外部探针的 bug 不应该阻止回切验证。
- 执行器包办超时和重试。不同步骤的预算不同,通用策略会把语义搅乱。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。