《后端架构设计·高性能与高可用》9.2 回滚、兼容窗口与数据迁移

以 arch-lab 订单状态从字符串迁移到整数码为样本,实现 expand/contract 检查器:新代码读旧数据、旧代码读新数据、双写窗口与删除旧列前的守卫,用 Go 测试覆盖三种兼容、回滚后仍可读和迁移未完成禁止 contract,并给出完整时间线与回滚点。

代码能回滚,数据不跟着回滚

蓝绿和金丝雀解决的是流量切换,回滚命令执行完,旧代码重新拿到流量,但数据库里已经写入的新格式数据不会自动变回去。如果新版本把 status 从字符串改成了整数码,旧代码读到一个 30 只会报错;如果新版本新增了必填列,旧代码的插入语句会直接失败。发布事故里最危险的不是“新版本有 bug”,而是“新版本有 bug 且旧版本已经读不了新数据”。

expand/contract 是应对这个问题的标准手法:先扩展 schema 让新旧代码都能工作,再双写并回填,最后在确认没有旧读者后收缩。通用发布策略见 发布策略与金丝雀 ,上一节已经实现了流量回滚;本节只处理数据侧,并把“什么时候允许删旧列”写成可执行守卫。

四个阶段与各自的回滚点

arch-lab 把一次字段演进拆成四个阶段:expand 新增可空列,新旧代码都不受影响,回滚只需删列;dual write 让新代码同时写两列,旧代码仍只读写旧列,回滚只需重新部署旧版本;backfill 批量回填并校验,回滚是停止回填;contract 删除旧列,只能从备份恢复,单向。关键约束是阶段顺序不能跳。没有 expand 就双写,旧代码会因为未知列或非空约束失败;没有回填就 contract,历史行会丢失唯一可读的旧值;没有兼容窗口就删列,仍在运行的旧实例会在下一次请求时崩溃。守卫的职责就是把“不能跳”变成构建期或发布期的一次失败。

状态模型:字符串到整数码

订单状态原本是 VARCHAR,迁移目标是 SMALLINT。整数码的索引更小、比较不受排序规则影响、跨语言序列化稳定;代价是可读性下降,所以迁移期间保留字符串列作为权威,直到新列被回填并验证。六个状态与码值的映射必须唯一且稳定,未知值要报错,不能静默落成零值。

// file: internal/migrate/orderstatus.go
// tier: L1
// verify: go test ./internal/migrate/ -count=1 -v
package migrate

import (
	"errors"
	"fmt"
)

type Status string

const (
	StatusCreated           Status = "CREATED"
	StatusInventoryReserved Status = "INVENTORY_RESERVED"
	StatusPaid              Status = "PAID"
	StatusFulfilled         Status = "FULFILLED"
	StatusCancelled         Status = "CANCELLED"
	StatusRefunded          Status = "REFUNDED"
)
type Code int16
var (
	statusCodes = map[Status]Code{
		StatusCreated:           Code(10),
		StatusInventoryReserved: Code(20),
		StatusPaid:              Code(30),
		StatusFulfilled:         Code(40),
		StatusCancelled:         Code(50),
		StatusRefunded:          Code(60),
	}
	codeStatuses = map[Code]Status{
		Code(10): StatusCreated,
		Code(20): StatusInventoryReserved,
		Code(30): StatusPaid,
		Code(40): StatusFulfilled,
		Code(50): StatusCancelled,
		Code(60): StatusRefunded,
	}
)
var (
	ErrUnknownStatus = errors.New("unknown order status")
	ErrLegacyMissing = errors.New("legacy status column is empty")
)
func Encode(s Status) (Code, error) {
	c, ok := statusCodes[s]
	if !ok {
		return 0, fmt.Errorf("%w: %q", ErrUnknownStatus, s)
	}
	return c, nil
}
func Decode(c Code) (Status, error) {
	s, ok := codeStatuses[c]
	if !ok {
		return "", fmt.Errorf("%w: code %d", ErrUnknownStatus, c)
	}
	return s, nil
}
// Row 是迁移期间的一行;V2 为 nil 表示历史行尚未回填。
type Row struct {
	OrderID int64
	Legacy  string
	V2      *Code
}
// ReadNew 是新代码的读取路径:优先新列,缺失时回退旧列。
func ReadNew(r Row) (Status, error) {
	if r.V2 != nil {
		return Decode(*r.V2)
	}
	return parseLegacy(r)
}
// ReadOld 是旧代码的读取路径:只认旧列,删列后必然失败。
func ReadOld(r Row) (Status, error) {
	if r.Legacy == "" {
		return "", ErrLegacyMissing
	}
	return parseLegacy(r)
}
func parseLegacy(r Row) (Status, error) {
	s := Status(r.Legacy)
	if _, err := Encode(s); err != nil {
		return "", err
	}
	return s, nil
}
// WriteDual 是 dual write 阶段的唯一合法写入方式:两列必须同时写。
func WriteDual(orderID int64, s Status) (Row, error) {
	c, err := Encode(s)
	if err != nil {
		return Row{}, err
	}
	return Row{OrderID: orderID, Legacy: string(s), V2: &c}, nil
}
// BackfillReport 记录回填结果;Mismatched 非零时禁止进入 contract。
type BackfillReport struct {
	Migrated   int
	Mismatched int
}
func Backfill(rows []Row) (BackfillReport, error) {
	var rep BackfillReport
	for i := range rows {
		r := &rows[i]
		fromLegacy, err := Encode(Status(r.Legacy))
		if err != nil {
			return rep, fmt.Errorf("order %d: %w", r.OrderID, err)
		}
		if r.V2 == nil {
			c := fromLegacy
			r.V2 = &c
			rep.Migrated++
			continue
		}
		if *r.V2 != fromLegacy {
			rep.Mismatched++
		}
	}
	return rep, nil
}

ReadNew 的回退分支是兼容窗口的核心:新代码可以读旧数据,所以新版本可以先于回填上线。ReadOld 则用来证明反向兼容——只要双写还在,旧代码回滚后仍能读到完整状态。Backfill 只补缺失列,不覆盖已有值;已有值与旧列不一致时计入 Mismatched,交给人判断,而不是让脚本静默“修正”。

守卫:删旧列之前必须全部为真

contract 是不可逆动作,守卫必须同时检查六件事:阶段正确、回填完成、没有不一致、旧读者清零、兼容窗口已满、备份已验证。任何一项缺失都返回可判定的错误,而不是一个布尔值。窗口时长用 time.Duration 显式传入,测试可以精确控制,生产中由发布系统按“旧版本下线时间”计算。

// file: internal/migrate/guard.go
// tier: L1
// verify: go test ./internal/migrate/ -count=1 -v
package migrate

import (
	"errors"
	"fmt"
	"time"
)

type Phase string

const (
	PhaseExpanded   Phase = "expanded"
	PhaseDualWrite  Phase = "dual_write"
	PhaseBackfilled Phase = "backfilled"
	PhaseContracted Phase = "contracted"
)

var (
	ErrPhaseTooEarly      = errors.New("phase must be backfilled before contract")
	ErrBackfillIncomplete = errors.New("backfill is not complete")
	ErrMismatch           = errors.New("legacy and v2 values disagree")
	ErrOldReadersRemain   = errors.New("old code can still read the legacy column")
	ErrWindowOpen         = errors.New("compatibility window has not elapsed")
	ErrNoBackup           = errors.New("no verified backup before contract")
	ErrContractIsOneWay   = errors.New("contract is one-way; restore from backup")
	ErrRollbackForward    = errors.New("rollback target must be an earlier phase")
)

var phaseRanks = map[Phase]int{
	PhaseExpanded: 0, PhaseDualWrite: 1, PhaseBackfilled: 2, PhaseContracted: 3,
}

type ContractInput struct {
	Phase          Phase
	BackfillDone   bool
	Mismatches     int
	OldReaders     int
	WindowElapsed  time.Duration
	RequiredWindow time.Duration
	BackupVerified bool
}

func CheckContract(in ContractInput) error {
	if in.Phase != PhaseBackfilled {
		return fmt.Errorf("%w: current=%s", ErrPhaseTooEarly, in.Phase)
	}
	if !in.BackfillDone {
		return ErrBackfillIncomplete
	}
	if in.Mismatches > 0 {
		return fmt.Errorf("%w: rows=%d", ErrMismatch, in.Mismatches)
	}
	if in.OldReaders > 0 {
		return fmt.Errorf("%w: readers=%d", ErrOldReadersRemain, in.OldReaders)
	}
	if in.WindowElapsed < in.RequiredWindow {
		return fmt.Errorf("%w: elapsed=%s required=%s", ErrWindowOpen, in.WindowElapsed, in.RequiredWindow)
	}
	if !in.BackupVerified {
		return ErrNoBackup
	}
	return nil
}

// CheckRollback 允许回到更早阶段,但 contract 之后只能从备份恢复。
func CheckRollback(current, target Phase) error {
	if current == PhaseContracted {
		return ErrContractIsOneWay
	}
	if phaseRanks[target] >= phaseRanks[current] {
		return ErrRollbackForward
	}
	return nil
}

守卫把“回滚点”表达成阶段比较:contract 之前,回到 dual_write 或 expanded 都是合法操作;contract 之后,函数直接拒绝,因为旧列已经不存在,任何“回滚代码”都需要先恢复数据。这比在 runbook 里写一句“删列后不可回滚”可靠得多。

测试:三种兼容与一个禁止

测试覆盖四个关键场景。新代码读旧数据:V2 为空、只有 Legacy,ReadNew 必须成功。旧代码读新数据:WriteDual 写入后 ReadOld 必须成功,模拟回滚后的读取。迁移未完成禁止 contract:分别让阶段、回填、不一致、旧读者、窗口、备份六个条件失败,逐一断言错误类型。contract 后不可回滚:构造只有新列的行,ReadOld 必须报 ErrLegacyMissing。

// file: internal/migrate/migrate_test.go
// tier: L1
// verify: go test ./internal/migrate/ -count=1 -v
package migrate

import (
	"errors"
	"testing"
	"time"
)

func TestReadCompatibility(t *testing.T) {
	for _, s := range []Status{StatusCreated, StatusInventoryReserved, StatusPaid, StatusFulfilled, StatusCancelled, StatusRefunded} {
		if got, err := ReadNew(Row{OrderID: 1, Legacy: string(s)}); err != nil || got != s {
			t.Fatalf("ReadNew(%s)=(%s,%v)", s, got, err)
		}
		row, err := WriteDual(10, s)
		if err != nil {
			t.Fatal(err)
		}
		if row.V2 == nil {
			t.Fatalf("%s: dual write left V2 nil", s)
		}
		if got, err := ReadOld(row); err != nil || got != s {
			t.Fatalf("rollback read %s=(%s,%v)", s, got, err)
		}
	}
	row, _ := WriteDual(100, StatusPaid)
	old, err := ReadOld(row)
	if err != nil {
		t.Fatal(err)
	}
	t.Logf("dual_write legacy=%q v2=%v old_reader=%s", row.Legacy, *row.V2, old)
}
func TestBackfillAndMismatch(t *testing.T) {
	rows := []Row{{OrderID: 1, Legacy: string(StatusCreated)}, {OrderID: 2, Legacy: string(StatusPaid)}}
	rep, err := Backfill(rows)
	if err != nil || rep.Migrated != 2 || rep.Mismatched != 0 {
		t.Fatalf("backfill rep=%+v err=%v", rep, err)
	}
	migrated := rep.Migrated
	bad, _ := Encode(StatusCancelled)
	rows[0].V2 = &bad
	if rep, err = Backfill(rows); err != nil || rep.Mismatched != 1 {
		t.Fatalf("mismatch rep=%+v err=%v", rep, err)
	}
	t.Logf("backfill migrated=%d mismatched=%d", migrated, rep.Mismatched)
}

func TestContractBlockedUntilAllGuardsPass(t *testing.T) {
	good := ContractInput{Phase: PhaseBackfilled, BackfillDone: true, OldReaders: 0,
		WindowElapsed: 7 * 24 * time.Hour, RequiredWindow: 7 * 24 * time.Hour, BackupVerified: true}
	if err := CheckContract(good); err != nil {
		t.Fatalf("good contract rejected: %v", err)
	}
	cases := []struct {
		name string
		mut  func(*ContractInput)
		want error
	}{
		{"phase", func(in *ContractInput) { in.Phase = PhaseDualWrite }, ErrPhaseTooEarly},
		{"backfill", func(in *ContractInput) { in.BackfillDone = false }, ErrBackfillIncomplete},
		{"mismatch", func(in *ContractInput) { in.Mismatches = 3 }, ErrMismatch},
		{"readers", func(in *ContractInput) { in.OldReaders = 1 }, ErrOldReadersRemain},
		{"window", func(in *ContractInput) { in.WindowElapsed = 6 * 24 * time.Hour }, ErrWindowOpen},
		{"backup", func(in *ContractInput) { in.BackupVerified = false }, ErrNoBackup},
	}
	for _, tc := range cases {
		in := good
		tc.mut(&in)
		if err := CheckContract(in); !errors.Is(err, tc.want) {
			t.Fatalf("%s: err=%v want %v", tc.name, err, tc.want)
		}
	}
}

func TestRollbackAfterContractIsOneWay(t *testing.T) {
	paid, _ := Encode(StatusPaid)
	row := Row{OrderID: 9, V2: &paid}
	if _, err := ReadOld(row); !errors.Is(err, ErrLegacyMissing) {
		t.Fatalf("old code after contract err=%v", err)
	}
	if err := CheckRollback(PhaseContracted, PhaseDualWrite); !errors.Is(err, ErrContractIsOneWay) {
		t.Fatalf("rollback after contract err=%v", err)
	}
	if err := CheckRollback(PhaseBackfilled, PhaseDualWrite); err != nil {
		t.Fatalf("rollback before contract err=%v", err)
	}
	if err := CheckRollback(PhaseDualWrite, PhaseBackfilled); !errors.Is(err, ErrRollbackForward) {
		t.Fatalf("forward rollback err=%v", err)
	}
	t.Log("rollback_point=redeploy_old_code_legacy_column_authoritative")
}

订单状态字段演进的完整时间线

把上面的阶段放进真实发布节奏,时间线如下。假设订单服务每天两个发布窗口,兼容窗口要求旧版本下线后至少保留 7 天。

时刻动作观测与判据允许的回滚
D0expand:加可空 status_v2,新代码不读写旧版本无感,DDL 不锁表直接删列
D1发布 dual write 版本新订单两列都有值,ReadOld 仍成功重新部署旧版本
D2–D3回填历史行,跑一致性校验Mismatched=0,未回填行归零停回填,旧列仍是权威
D4下线所有旧版本实例OldReaders=0回到 dual write
D11兼容窗口满 7 天,验证备份WindowElapsed>=RequiredWindow回到 dual write
D12contract:删 status 列守卫六项全真只能从备份恢复

D12 之后没有“回滚代码”这个选项。即便把旧镜像重新拉起来,它读不到 status 列,启动检查就会失败;这不是保守,而是让不可逆动作在发生前就必须通过六道门。真正的回滚点是 D11 之前:那时旧列和旧代码都还在,代价只是重新部署。

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在独立模块 .local/checks/be-arch-lab/pages/be-arch-perf-09-2-rollback-compatibility-window/ 执行 go test -v ./... -count=1,单次运行。原始输出保存在 .local/reports/be-arch/be-arch-perf-09-2-rollback-compatibility-window-migration-guards.txt。

$ go test -v ./... -count=1
=== RUN   TestReadCompatibility
    migrate_test.go:30: dual_write legacy="PAID" v2=30 old_reader=PAID
--- PASS: TestReadCompatibility (0.00s)
=== RUN   TestBackfillAndMismatch
    migrate_test.go:44: backfill migrated=2 mismatched=1
--- PASS: TestBackfillAndMismatch (0.00s)
=== RUN   TestContractBlockedUntilAllGuardsPass
--- PASS: TestContractBlockedUntilAllGuardsPass (0.00s)
=== RUN   TestRollbackAfterContractIsOneWay
    migrate_test.go:89: rollback_point=redeploy_old_code_legacy_column_authoritative
--- PASS: TestRollbackAfterContractIsOneWay (0.00s)
PASS
ok  	archlab/internal/migrate	0.236s

这些结果是确定性的:它们证明读取路径和守卫逻辑,不是性能基准。dual_write 之后旧代码读到 PAID,说明回滚安全;contract 之后旧代码得到 ErrLegacyMissing,说明删列确实单向。生产环境还要补两件事:回填必须可暂停、可续跑;一致性校验要按主键分段,避免一次全表扫描把数据库拖垮。

常见错误与边界

第一,先双写再 expand。旧代码不认识新列,遇到非空约束或严格 schema 校验会直接失败;expand 必须先行。

第二,回填时覆盖已有值。已有 status_v2 可能来自新代码,盲目用旧列覆盖会制造不一致;只补缺失、冲突上报。

第三,用“发布成功”代替“旧读者清零”。发布系统知道部署了几台,但不知道是否还有长连接、批处理或外部系统在读旧列;旧读者要按实际连接与版本统计。

第四,兼容窗口拍脑袋。窗口应覆盖最长发布周期、回滚演练和依赖方升级时间;arch-lab 的 7 天是示例值,不是通用常数。

小结

expand/contract 的本质是把一次不可逆的 schema 变更拆成多个可回滚的小步。arch-lab 的实现把“新代码读旧数据”“旧代码读新数据”“回填一致性”“旧读者清零”“窗口满足”“备份可用”写成守卫,并用测试证明迁移未完成时 contract 必然失败、contract 之后旧代码必然读不到数据。下一篇讨论一个更轻量但更容易被误用的发布控制:功能开关,以及为什么开关不能替代数据兼容。

阅读导航:上一节:9.1 滚动、蓝绿与金丝雀 · 下一节:9.3 开关与灰度的数据兼容约束 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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