《互联网产品的服务端架构·规模化与架构演进》2.3 切换、回切与演练

从一次演练通过却回切失败的事故出发,区分切换、回切与演练三件不同的事,给出回切前必须回答的数据新鲜度与版本对齐问题,说明演练只切不回为什么危险,并用一个失败后继续执行、单步 panic 不扩散的 Go 演练器与可回归测试把流程固定下来,最后列出常见边界。

演练通过,回切却失败了

「回声」每季度做一次容灾演练。脚本很完整:停止旧单元写入、把 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 不应该阻止回切验证。
  • 执行器包办超时和重试。不同步骤的预算不同,通用策略会把语义搅乱。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查