拆分进行到第三步时,数据库开始双写
「回声」把订单服务从单体里拆出来,计划分五步:第一步启动影子读,把新服务的查询结果和旧链路对比;第二步开启双写,新服务写订单库的同时也写旧库;第三步把读流量切到新服务;第四步把写流量切到新服务;第五步下线旧代码和旧表。
前两步很顺利。影子读的差异率从 3% 降到 0.2%,双写的延迟增加在可接受范围内。第三步切读进行到 30% 时,值班同学发现新服务的订单详情偶尔缺少优惠信息。排查发现,新服务读取的是自己的读模型,而优惠券的同步事件在高峰期有延迟。问题本身不复杂,但它发生在切读过程中:一部分用户走新服务,一部分走旧链路,两边的数据新鲜度不同。
团队决定回退。有人主张“把第三步反过来执行”:把读流量切回旧链路,然后关掉双写、停掉影子读。听起来对称,执行时却出了问题。切读回退只用了四分钟,但关双写时,新服务仍在处理一部分写请求;旧链路认为双写已经关闭,开始清理旧库的同步标记。两边对“双写是否仍然有效”的判断不一致,一批订单在旧库被标记成已同步,在新库却没有写入。回退没有把系统带回第二步,而是带到了一个两边都不完整的状态。
这次事故的教训不是“不要拆”,而是“回退方案必须和拆分方案同时设计”。每一步向前动作都要有对应的回退动作,回退动作还要说明执行顺序、观测信号和停止条件。只写“出问题就回滚”,等于把最危险的操作留到最紧张的时刻才设计。
回退不是把步骤反过来念
下面这张表是一个五阶段拆分计划的回退设计,动作和信号都是案例假设,用来说明每个字段应该写到什么程度。
| 阶段 | 向前动作 | 回退动作 | 观测信号 | 观察窗口 |
|---|---|---|---|---|
| 影子读 | 新服务查库但不接流量,记录差异 | 停止影子任务,删除对比表 | 差异率低于 1% | 连续 3 天 |
| 双写 | 新服务写新库,同时写旧库 | 停止新库写入,保留旧库写入 | 双写失败率低于 0.1% | 连续 24 小时 |
| 切读 | 按用户区间把读流量切到新服务 | 把读流量切回旧链路 | 错误率、P99、数据差异 | 每批 30 分钟 |
| 切写 | 按用户区间把写流量切到新服务 | 把写流量切回旧库,保留双写 | 写入成功率、对账差异 | 每批 30 分钟 |
| 下线旧路径 | 停止旧代码、归档旧表 | 恢复旧代码与旧表读取 | 回退演练通过 | 发布前 |
表里最重要的是“回退动作”和“观测信号”两列。回退动作不能写成“撤销上一步”,因为上一步可能已经产生了不可撤销的副作用:双写期间新库已经有了数据,切读期间用户已经看到了新页面,切写期间新库已经接受了订单。回退要做的是让系统回到一个一致、可解释的状态,而不是假装什么都没发生。
另一个容易被忽略的是顺序。向前执行是第一步、第二步、第三步,回退必须从最后完成的步骤开始倒序执行。先切回读流量,再关双写,最后停影子读;如果先关双写,切读还在进行,新服务可能读到旧库尚未同步的数据。顺序错了,回退本身就会制造一次新的不一致。
回退动作还必须幂等。值班同学可能在紧张中重复执行同一条命令,或者自动化脚本重试。停止双写执行两次应该没有副作用,切回读流量执行两次也应该得到同一个结果。做不到幂等的回退动作,需要加上状态检查或去重键。
一次拆到一半的回退事故
案例假设:「回声」在切写阶段遇到过更严重的一次回退失败。切写进行到 10% 时,新服务的写入延迟升高,团队决定回退。操作手册写的是“先切回写流量,再关闭双写”,但值班同学先执行了关闭双写,理由是“先把新库保护起来”。
关闭双写之后,旧库仍然是主写入方,新库停止接收新写入。但切写的那 10% 用户已经被路由到新服务,新服务仍然在写新库。旧链路的对账任务看到双写标记消失,开始把旧库的数据批量同步到新库,覆盖了新服务刚写入的字段。两边都没有报错,因为每一侧都认为自己在做正确的事。
后果在第二天早上暴露。一部分用户的订单状态是“已支付”,优惠金额却是旧的;另一部分用户的退款记录只在新库存在。客服只能按用户逐个核对,财务对账多出四百多笔差异。回退用了十二分钟,修复用了三天。
复盘时,团队把问题拆成三层。第一层是顺序:关闭双写应该在切写回退完成之后,而不是之前。第二层是所有权:回退期间谁是新库的写入方,没有被明确写进手册。第三层是验证:回退完成后没有做一致性检查,直接宣布恢复。
修复方案不是加一条“禁止先关双写”的规定,而是把回退路径写成代码:每个阶段有名字、向前动作和回退动作,回退函数只返回失败点之前已经完成的阶段,并按倒序排列。值班同学照着输出执行,顺序不再依赖记忆。
把回退计划写成代码
回退计划和拆分计划一样,应该是可评审、可测试的输入,而不是事故发生时临时写的命令清单。下面这段代码来自本书配套的 webapp-lab,Stage 描述一个阶段,ValidateStages 检查计划完整性,RollbackPath 返回失败点之前的回退步骤。
// file: internal/split/rollback.go
// tier: L1
// verify: go test ./internal/split/
package split
import (
"fmt"
"strings"
)
// Stage 是拆分计划里的一个可回退步骤。
// Forward 是向前执行的动作,Back 是它对应的回退动作。
type Stage struct {
Name string
Forward string
Back string
}
// ValidateStages 检查每个阶段都有名字、向前动作与回退动作,且名字唯一。
// 同一包里已经有 Validate([]Module),Go 不支持函数重载,所以这里单独命名。
func ValidateStages(stages []Stage) error {
seen := make(map[string]bool, len(stages))
for i, st := range stages {
name := strings.TrimSpace(st.Name)
if name == "" {
return fmt.Errorf("split: stage[%d] has empty name", i)
}
if seen[name] {
return fmt.Errorf("split: duplicate stage %q", name)
}
seen[name] = true
if strings.TrimSpace(st.Forward) == "" {
return fmt.Errorf("split: stage %q has no forward action", name)
}
if strings.TrimSpace(st.Back) == "" {
return fmt.Errorf("split: stage %q has no rollback action", name)
}
}
return nil
}
// RollbackPath 返回失败点之前的回退步骤,按执行顺序倒序排列。
// failed 是第一个失败的阶段下标;该阶段自身的清理由它的局部逻辑负责,
// 不在返回结果里。failed 等于阶段总数表示全部阶段完成后才发现问题,
// 此时回退全部阶段;其余越界值或计划不完整时返回错误。
func RollbackPath(stages []Stage, failed int) ([]Stage, error) {
if err := ValidateStages(stages); err != nil {
return nil, err
}
if failed < 0 || failed > len(stages) {
return nil, fmt.Errorf("split: failed index %d out of range [0,%d]", failed, len(stages))
}
path := make([]Stage, 0, failed)
for i := failed - 1; i >= 0; i-- {
path = append(path, stages[i])
}
return path, nil
}
几个设计选择需要说明。
- 每个阶段必须有
Forward和Back。只有向前动作的阶段不允许进入计划,因为它在失败时没有退路。 - 阶段名唯一,且不能为空。回退输出和操作手册都靠名字对齐,重名会让值班同学执行错步骤。
RollbackPath只返回失败点之前的阶段,按倒序排列。失败阶段自身的清理由它的局部逻辑负责,避免把“未完成的动作”当成“已完成的动作”回退。failed允许等于阶段总数,表示全部阶段完成后才发现问题。这时回退全部阶段,覆盖“发布后验证失败”的场景。- 函数名是
ValidateStages而不是Validate,因为同一个包里已经有Validate([]Module),Go 不支持函数重载。分开命名比把两种入参塞进一个any更清楚。
这段代码不执行任何命令。它只负责回答“该退哪些阶段、按什么顺序退”。真正的回退动作仍然要由发布系统或运维脚本执行,执行前应该先做一次 dry-run,确认输出和手册一致。
测试:缺回退、重名与倒序
测试要证明三件事:不完整的计划会被拒绝;回退只覆盖已完成的阶段;失败点越界会报错。下面这组测试把这三类边界固定下来。
// file: internal/split/rollback_test.go
// tier: L1
// verify: go test ./internal/split/
package split
import (
"reflect"
"strings"
"testing"
)
func TestValidateStagesAcceptsCompletePlan(t *testing.T) {
stages := []Stage{
{Name: "read-shadow", Forward: "start shadow reads", Back: "stop shadow reads"},
{Name: "dual-write", Forward: "write both stores", Back: "disable new writes"},
{Name: "cutover", Forward: "route reads to split", Back: "route reads back"},
}
if err := ValidateStages(stages); err != nil {
t.Fatalf("ValidateStages() = %v, want nil", err)
}
}
func TestValidateStagesRejectsIncompletePlan(t *testing.T) {
cases := []struct {
name string
stages []Stage
want string
}{
{"empty-name", []Stage{{Forward: "f", Back: "b"}}, "empty name"},
{"duplicate-name", []Stage{
{Name: "s1", Forward: "f1", Back: "b1"},
{Name: "s1", Forward: "f2", Back: "b2"},
}, "duplicate stage"},
{"missing-forward", []Stage{{Name: "s1", Back: "b1"}}, "no forward action"},
{"missing-back", []Stage{{Name: "s1", Forward: "f1"}}, "no rollback action"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateStages(c.stages)
if err == nil {
t.Fatal("ValidateStages() = nil, want error")
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("ValidateStages() = %q, want substring %q", err, c.want)
}
})
}
}
func TestRollbackPathRunsCompletedStagesInReverse(t *testing.T) {
stages := []Stage{
{Name: "a", Forward: "fa", Back: "ba"},
{Name: "b", Forward: "fb", Back: "bb"},
{Name: "c", Forward: "fc", Back: "bc"},
}
got, err := RollbackPath(stages, 3)
if err != nil {
t.Fatal(err)
}
want := []Stage{stages[2], stages[1], stages[0]}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RollbackPath(3) = %v, want %v", got, want)
}
}
func TestRollbackPathCoversOnlyCompletedStages(t *testing.T) {
stages := []Stage{
{Name: "a", Forward: "fa", Back: "ba"},
{Name: "b", Forward: "fb", Back: "bb"},
}
got, err := RollbackPath(stages, 1)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, []Stage{stages[0]}) {
t.Fatalf("RollbackPath(1) = %v, want only stage a", got)
}
empty, err := RollbackPath(stages, 0)
if err != nil {
t.Fatal(err)
}
if len(empty) != 0 {
t.Fatalf("RollbackPath(0) = %v, want empty", empty)
}
}
func TestRollbackPathRejectsBadIndexAndPlan(t *testing.T) {
stages := []Stage{
{Name: "a", Forward: "fa", Back: "ba"},
{Name: "b", Forward: "fb", Back: "bb"},
}
for _, idx := range []int{-1, 3} {
if _, err := RollbackPath(stages, idx); err == nil {
t.Fatalf("RollbackPath(%d) = nil error, want out of range", idx)
}
}
broken := []Stage{{Name: "a", Forward: "fa"}}
if _, err := RollbackPath(broken, 1); err == nil {
t.Fatal("RollbackPath on broken plan = nil error, want validation error")
}
}
TestValidateStagesRejectsIncompletePlan 覆盖空名、重名、缺向前动作和缺回退动作。TestRollbackPathRunsCompletedStagesInReverse 用三个阶段和 failed=3 验证完整倒序:c、b、a。TestRollbackPathCoversOnlyCompletedStages 验证 failed=1 时只回退 a,failed=0 时返回空路径。
TestRollbackPathRejectsBadIndexAndPlan 检查 -1 和超出阶段总数的下标,同时确认计划本身不完整时,即使下标合法也会报错。最后这条很重要:回退函数不能因为“还没走到失败点”就放过一个缺少回退动作的未来阶段,计划必须在执行前完整。
测试没有覆盖的部分也要说清楚:它不验证回退动作是否幂等,不验证执行顺序在真实发布系统里是否可中断,也不验证回退后的数据一致性。这些需要演练和一致性检查,函数只负责给出顺序。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/split/ -run '^(TestValidateStages.*|TestRollbackPath.*)$' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateStagesAcceptsCompletePlan
--- PASS: TestValidateStagesAcceptsCompletePlan (0.00s)
=== RUN TestValidateStagesRejectsIncompletePlan
=== RUN TestValidateStagesRejectsIncompletePlan/empty-name
=== RUN TestValidateStagesRejectsIncompletePlan/duplicate-name
=== RUN TestValidateStagesRejectsIncompletePlan/missing-forward
=== RUN TestValidateStagesRejectsIncompletePlan/missing-back
--- PASS: TestValidateStagesRejectsIncompletePlan (0.00s)
--- PASS: TestValidateStagesRejectsIncompletePlan/empty-name (0.00s)
--- PASS: TestValidateStagesRejectsIncompletePlan/duplicate-name (0.00s)
--- PASS: TestValidateStagesRejectsIncompletePlan/missing-forward (0.00s)
--- PASS: TestValidateStagesRejectsIncompletePlan/missing-back (0.00s)
=== RUN TestRollbackPathRunsCompletedStagesInReverse
--- PASS: TestRollbackPathRunsCompletedStagesInReverse (0.00s)
=== RUN TestRollbackPathCoversOnlyCompletedStages
--- PASS: TestRollbackPathCoversOnlyCompletedStages (0.00s)
=== RUN TestRollbackPathRejectsBadIndexAndPlan
--- PASS: TestRollbackPathRejectsBadIndexAndPlan (0.00s)
PASS
ok webapp/internal/split 0.297s
输出里的 missing-back 子用例对应最危险的情况:一个阶段只能向前、不能向后。只要计划里存在这样的阶段,RollbackPath 就会在开始回退之前拒绝执行,而不是走到一半才发现无路可退。
小结
拆分和回退是同一份设计的两个方向。每个阶段都应该有名字、向前动作、回退动作、观测信号和观察窗口;回退按完成顺序倒序执行,只覆盖失败点之前已经完成的阶段,并且每一步都应当幂等。把回退留到事故发生时才想,通常会在最紧张的时刻做出最危险的顺序。
ValidateStages 把计划完整性变成可检查的约束,RollbackPath 把“退哪些、按什么顺序退”变成可测试的输出。它们不能替你执行命令,也不能保证数据一致,但能让值班同学在压力下照着同一份计划操作,而不是靠记忆和临场判断。
下一章进入规模化之后的第二个问题:用户量继续上涨时,单体拆分已经不够,如何用单元化把故障限制在一部分用户之内。
阅读导航:上一节:1.2 按产品域拆还是按层拆 · 下一节:2.1 单元化与用户路由 。
常见错误与边界
- 只设计向前步骤,不设计回退动作。事故发生时才开始想怎么退,顺序通常经不起压力。
- 把回退理解成“反向执行”。双写、切读、切写都会留下副作用,回退的目标是一致状态,不是假装没发生。
- 回退顺序写反。先关双写再切回写流量,会让两边对写入权的判断不一致。
- 回退动作不幂等。重试或重复执行会产生第二次副作用,值班同学越紧张越容易触发。
- 失败阶段自身也纳入回退。未完成的动作没有完整的回退语义,应该由该阶段的局部清理负责。
- 计划里保留没有回退动作的未来阶段。执行前就必须完整校验,不能等走到那一步才发现。
- 回退后不做一致性检查。命令执行成功不等于数据一致,订单、余额、库存都需要对账。
- 从不演练回退。只在文档里存在的路径,第一次执行通常就是事故现场。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。