《互联网产品的服务端架构·产品、流量与基础能力》10.3 数据兼容与双写窗口

凌晨发布时直接 rename 列,老 Pod 还在跑,写入全部报未知列,订单创建失败十二分钟。文章把列变更为先扩后收的 expand、migrate、contract 三阶段,说明双写、回填、双读校验与切换的停点,用 Go 校验阶段顺序和双写幂等,并给出表驱动测试与真实运行输出。

凌晨发布,旧代码写不进订单表

「回声」的订单表有一个 channel 字段,记录订单来源。产品要把渠道拆成“来源”和“投放计划”两个维度,新字段叫 channel_v2。开发同学认为改动很小:把旧列改名,代码里同步改字段名,一次发布就完成。为了避开白天的流量,发布安排在凌晨两点。

迁移脚本在发布开始时执行:ALTER TABLE orders RENAME COLUMN channel TO channel_v2。脚本几秒钟就完成了。随后 Kubernetes 开始滚动更新,30 个 Pod 按 25% 的批次替换。第一批新 Pod 启动前,旧 Pod 仍在接收流量,它们执行的 INSERT INTO orders (..., channel, ...) 全部报 Unknown column 'channel'。订单创建接口在十二分钟里持续失败,按案例假设值约 2.9 万次下单尝试没有成功。

更糟的是回滚。值班同学发现错误后回滚镜像,但列名已经是 channel_v2,回滚后的旧代码仍然写 channel,错误没有消失。数据库迁移没有“撤销”按钮,团队只能在凌晨手工加回一列并双写,恢复时间从预计的五分钟变成四十分钟。

这次事故的根因不是 SQL 写错,而是把“代码发布”和“数据变更”当成同一个原子操作。代码可以滚动、可以回滚,数据结构的变更一旦被旧代码依赖,就必须考虑新老代码同时运行的窗口。

先扩后收:每一步都能停

列变更的标准做法是“先扩后收”:先让新结构存在,再让新代码同时写新旧两边,然后回填历史数据、校验一致性、切换读写,最后才删除旧结构。整个过程里旧代码始终能工作,任何一步都可以停下来观察。

阶段动作旧代码是否兼容可以停在哪里出问题怎么退
expand加可空列、加新表、加索引兼容,旧代码忽略新结构任何时刻保留新列,不影响旧路径
双写新代码同时写旧列和新列兼容,旧代码只写旧列发现双写失败率升高时关闭新列写入,回到单写
回填分批把旧数据补到新列兼容,回填只更新新列每批之后停在水位线,修好再继续
双读校验同时读新旧,比较差异兼容,读路径不变差异率超阈值时继续用旧列,不回填
切换读写读新列,写新列并保留旧列兼容,旧代码仍写旧列切换后观察期内读回旧列,写仍双写
contract停双写,删除旧列只有旧代码全部下线后才兼容不可停只能向前修复或从备份恢复

expand 阶段只做“增加”:加可空列、加有默认值的列、加新表、加索引。加列必须是可空或有默认值,因为旧代码的 INSERT 不会带这一列;如果新列是 NOT NULL 且没有默认值,旧代码的写入会立刻失败。加索引也要考虑锁表,生产库通常用在线 DDL 或影子表,而不是直接 ALTER TABLE。

双写阶段让新代码在同一个事务里写旧列和新列。旧代码只写旧列,所以旧列仍然是权威数据;新列是副本,允许暂时为空或不一致。双写失败时必须让整个事务失败,而不是“旧列写成功、新列写失败”后继续提交,否则新列会积累无法解释的缺口。

回填阶段把历史数据从旧列复制到新列。回填要分批、可暂停、可续跑,并且跳过最近被新代码修改过的行,或者用更新时间水位线避免覆盖。回填不是一次性全表 UPDATE:大表会锁住写入、拉高复制延迟,还会在失败时留下半成品。

双读校验阶段同时读新旧两列,比较结果但不改变读路径。差异率要按业务维度统计,不能只看总数;某些渠道的旧数据可能本来就没有值,某些新订单可能还没回填。差异率超过阈值时停下来查原因,而不是继续切换。

切换读写阶段把读路径指向新列,写路径仍然双写。此时旧列还在,回滚只需要把读路径切回旧列,不需要改数据库。观察一个完整的业务周期,确认新列在高峰、低峰、退款、补偿等场景都正确,才进入 contract。

contract 阶段停止双写并删除旧列。这是唯一不可逆的一步,必须确认旧代码已经全部下线、旧列的读取方已经迁移、备份和审计要求已经满足。删除之后如果发现新列有问题,只能从备份恢复或向前修复,不能再靠回滚镜像解决。

失败场景:rename 列让老代码写入全失败

把那次事故按时间线拆开,可以看到三个独立的问题。

第一个问题是迁移和代码发布的顺序。rename 在滚动更新之前执行,旧代码还在跑,旧列已经不存在。正确的顺序是 expand 先加新列,旧代码不受影响;新代码上线后双写;回填和校验完成后再 contract。rename 把 expand 和 contract 合并成一步,等于跳过了整个兼容窗口。

第二个问题是没有考虑回滚路径。即使 rename 在滚动更新之后执行,回滚镜像仍然会让旧代码写旧列,而旧列已经被改名。一个不可逆的 schema 变更会让代码回滚失效,团队以为“回滚镜像”是安全网,实际上安全网已经被数据库变更剪断。

第三个问题是读取方没有梳理。除了订单服务,还有报表任务、风控脚本和两个离线作业在读 channel。rename 之后这些任务不会立刻报错,而是在下一次运行时失败;有的任务把错误吞掉,报表出现空白,直到第二天对账才发现。

正确的做法不是“写一个更小心的一步到位迁移”,而是把变更拆成多个可停的发布单元:expand 一次发布,双写和回填一次发布,切换一次发布,contract 一次发布。每次发布都能独立观测、独立回滚,最坏情况是停在中途,而不是让新旧代码同时写一个不存在的列。

用 Check、ValidatePhases 与 Idempotent 固定数据兼容

ColumnChange 描述一次列变更,Check 判断某个阶段是否允许执行;ValidatePhases 检查阶段计划有没有跳过 migrate;DualWrite 和 Idempotent 用来验证双写在重复执行下只产生一次逻辑写入。

// file: internal/release/schema.go
// tier: L1
// verify: go test ./internal/release/

package release

import "fmt"

// Phase 是列变更所处的阶段。顺序必须是 expand、migrate、contract。
type Phase string

const (
	PhaseExpand   Phase = "expand"
	PhaseMigrate  Phase = "migrate"
	PhaseContract Phase = "contract"
)

// ColumnChange 是一次列变更。Drop 表示删除旧列。
type ColumnChange struct {
	Table      string
	Column     string
	Nullable   bool
	HasDefault bool
	Drop       bool
}

// Check 检查某个阶段是否允许执行这次列变更。
func Check(phase Phase, c ColumnChange) error {
	if c.Table == "" || c.Column == "" {
		return fmt.Errorf("release: column change needs table and column")
	}
	switch phase {
	case PhaseExpand:
		if c.Drop {
			return fmt.Errorf("release: expand phase cannot drop %s.%s", c.Table, c.Column)
		}
		if !c.Nullable && !c.HasDefault {
			return fmt.Errorf("release: expand phase needs nullable or default for %s.%s",
				c.Table, c.Column)
		}
	case PhaseMigrate:
		if c.Drop {
			return fmt.Errorf("release: migrate phase must keep old column %s.%s", c.Table, c.Column)
		}
	case PhaseContract:
		if !c.Drop {
			return fmt.Errorf("release: contract phase must drop old column %s.%s", c.Table, c.Column)
		}
	default:
		return fmt.Errorf("release: unknown migration phase %q", phase)
	}
	return nil
}

// ValidatePhases 检查一次列变更的阶段计划。
// 可以停在 migrate,但一旦进入 contract,必须已经过 migrate。
func ValidatePhases(phases []Phase) error {
	if len(phases) == 0 {
		return fmt.Errorf("release: empty migration phase plan")
	}
	seen := make(map[Phase]bool, len(phases))
	for i, p := range phases {
		switch p {
		case PhaseExpand, PhaseMigrate, PhaseContract:
		default:
			return fmt.Errorf("release: unknown migration phase %q", p)
		}
		if seen[p] {
			return fmt.Errorf("release: phase %q appears twice", p)
		}
		seen[p] = true
		if i == 0 && p != PhaseExpand {
			return fmt.Errorf("release: phase plan must start with expand, got %q", p)
		}
	}
	if !seen[PhaseExpand] {
		return fmt.Errorf("release: phase plan is missing expand")
	}
	if seen[PhaseContract] {
		if !seen[PhaseMigrate] {
			return fmt.Errorf("release: contract phase skips migrate")
		}
		if indexOf(phases, PhaseMigrate) > indexOf(phases, PhaseContract) {
			return fmt.Errorf("release: contract phase must follow migrate")
		}
	}
	return nil
}

func indexOf(phases []Phase, want Phase) int {
	for i, p := range phases {
		if p == want {
			return i
		}
	}
	return -1
}

// DualWrite 描述新旧两张表的双写窗口。Since 是窗口开始的 Unix 秒。
type DualWrite struct {
	OldTable string
	NewTable string
	Since    int64
}

// Idempotent 返回 applies 次重复执行后真正生效的逻辑写入次数。
// 双写依赖业务主键去重,同一变更重放多少次,两张表都只保留一份。
func Idempotent(w DualWrite, applies int) (int, error) {
	if applies < 0 {
		return 0, fmt.Errorf("release: applies must be non-negative, got %d", applies)
	}
	if w.OldTable == "" || w.NewTable == "" {
		return 0, fmt.Errorf("release: dual write needs both table names")
	}
	if w.OldTable == w.NewTable {
		return 0, fmt.Errorf("release: dual write tables must differ")
	}
	if w.Since < 0 {
		return 0, fmt.Errorf("release: dual write start time must be non-negative")
	}
	if applies == 0 {
		return 0, nil
	}
	return 1, nil
}

Check 的规则按阶段分开。expand 不允许 Drop,并且要求列可空或有默认值;migrate 允许旧列继续存在,但不允许删除;contract 只允许删除旧列。未知阶段直接报错,避免调用方把拼错的 "expnd" 当成合法阶段放过去。

contract 要求 Drop=true,是为了让“删除”这个动作显式出现在发布单里。如果 contract 阶段传一个普通列变更也能通过,调用方可能以为已经完成收尾,实际上旧列还在,后续的写入会继续双写,窗口永远不会关闭。

ValidatePhases 检查阶段顺序。计划可以从 expand 开始,停在 migrate,也可以走完三步;但不能以 migrate 或 contract 开头,不能重复,不能从 expand 直接跳到 contract。允许停在 migrate 是有意的:有些变更长期保留旧列作为兼容层,不需要删除。

Idempotent 把 applies 次重复执行折算成逻辑写入次数。双写依赖业务主键去重,同一个变更重放一次和重放一百次,两张表都只保留一份记录,所以返回值最多是 1。applies 为负、表名为空、新旧表相同、Since 为负都会报错;这些错误通常在配置或调用方拼参数时出现,越早拦住越好。

Since 是双写窗口开始的 Unix 秒。它本身不参与去重计算,但会写进发布记录和监控标签,用来回答“这条数据是在双写开始前还是开始后产生的”。没有这个时间点,回填任务无法判断哪些行需要补,事故复盘也无法对齐时间线。

测试:expand 不收、contract 才删、重放只算一次

测试覆盖三组规则:expand 只加不删、migrate 保留旧列、contract 才允许删除;阶段计划不能跳过 migrate;双写重复执行只算一次逻辑写入。

// file: internal/release/schema_test.go
// tier: L1
// verify: go test ./internal/release/

package release

import "testing"

func TestCheckExpand(t *testing.T) {
	base := ColumnChange{Table: "orders", Column: "channel"}
	cases := []struct {
		name string
		c    ColumnChange
		want bool
	}{
		{name: "nullable", c: base, want: true},
		{name: "default", c: ColumnChange{Table: "orders", Column: "channel", HasDefault: true}, want: true},
		{name: "not nullable without default", c: ColumnChange{Table: "orders", Column: "channel"}, want: false},
		{name: "drop in expand", c: ColumnChange{Table: "orders", Column: "channel", Drop: true}, want: false},
		{name: "missing column", c: ColumnChange{Table: "orders"}, want: false},
	}
	// nullable case needs Nullable=true
	cases[0].c.Nullable = true
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := Check(PhaseExpand, tc.c)
			if tc.want && err != nil {
				t.Fatalf("Check() = %v, want nil", err)
			}
			if !tc.want && err == nil {
				t.Fatal("Check() = nil, want error")
			}
		})
	}
}

func TestCheckMigrateAndContract(t *testing.T) {
	old := ColumnChange{Table: "orders", Column: "channel_legacy"}
	if err := Check(PhaseMigrate, old); err != nil {
		t.Fatalf("migrate with old column = %v, want nil", err)
	}
	if err := Check(PhaseMigrate, ColumnChange{Table: "orders", Column: "channel_legacy", Drop: true}); err == nil {
		t.Fatal("migrate drop = nil, want error")
	}
	if err := Check(PhaseContract, ColumnChange{Table: "orders", Column: "channel_legacy", Drop: true}); err != nil {
		t.Fatalf("contract drop = %v, want nil", err)
	}
	if err := Check(PhaseContract, old); err == nil {
		t.Fatal("contract without drop = nil, want error")
	}
	if err := Check("revert", old); err == nil {
		t.Fatal("unknown phase = nil, want error")
	}
}

func TestValidatePhases(t *testing.T) {
	ok := [][]Phase{
		{PhaseExpand},
		{PhaseExpand, PhaseMigrate},
		{PhaseExpand, PhaseMigrate, PhaseContract},
	}
	for _, phases := range ok {
		if err := ValidatePhases(phases); err != nil {
			t.Fatalf("ValidatePhases(%v) = %v, want nil", phases, err)
		}
	}
	bad := [][]Phase{
		{},
		{PhaseMigrate},
		{PhaseExpand, PhaseContract},
		{PhaseExpand, PhaseMigrate, PhaseMigrate},
		{PhaseExpand, PhaseContract, PhaseMigrate},
	}
	for _, phases := range bad {
		if err := ValidatePhases(phases); err == nil {
			t.Fatalf("ValidatePhases(%v) = nil, want error", phases)
		}
	}
}

func TestIdempotentDeduplicatesReplays(t *testing.T) {
	w := DualWrite{OldTable: "orders", NewTable: "orders_v2", Since: 1760000000}
	if got, err := Idempotent(w, 0); err != nil || got != 0 {
		t.Fatalf("Idempotent(0) = %d, %v, want 0, nil", got, err)
	}
	for _, applies := range []int{1, 2, 99} {
		got, err := Idempotent(w, applies)
		if err != nil {
			t.Fatal(err)
		}
		if got != 1 {
			t.Fatalf("Idempotent(%d) = %d, want 1 logical write", applies, got)
		}
	}
	if _, err := Idempotent(w, -1); err == nil {
		t.Fatal("Idempotent(-1) = nil error, want error")
	}
	if _, err := Idempotent(DualWrite{OldTable: "orders", NewTable: "orders", Since: 1}, 1); err == nil {
		t.Fatal("same table = nil error, want error")
	}
	if _, err := Idempotent(DualWrite{OldTable: "", NewTable: "orders_v2", Since: 1}, 1); err == nil {
		t.Fatal("empty table = nil error, want error")
	}
}

TestCheckExpand 用一张表覆盖可空列、有默认值、既不可空也无默认值、expand 阶段删除、缺少列名五种情况。只有前两种可以通过,后三种必须在发布前被拒绝。

TestCheckMigrateAndContract 验证 migrate 阶段旧列仍在时通过、带 Drop 时失败;contract 阶段带 Drop 时通过、不带 Drop 时失败;未知阶段失败。它把“删除只能发生在 contract”这条规则钉死。

TestValidatePhases 的正向用例包括只做 expand、expand 加 migrate、完整三步;反向用例包括空计划、以 migrate 开头、expand 直接跳 contract、重复 migrate、contract 在 migrate 之前。跳过 migrate 是最危险的一种,因为它意味着旧列还没被新代码完全接管就被删除。

TestIdempotentDeduplicatesReplays 检查 0 次执行返回 0,1 次、2 次、99 次都只返回 1 次逻辑写入;负执行次数、新旧表相同、表名为空都报错。测试不验证数据库唯一约束是否真的生效,那属于集成测试;单元测试只固定函数契约。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/release/ -count=1 -run 'TestCheckExpand|TestCheckMigrateAndContract|TestValidatePhases|TestIdempotentDeduplicatesReplays' -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestCheckExpand
=== RUN   TestCheckExpand/nullable
=== RUN   TestCheckExpand/default
=== RUN   TestCheckExpand/not_nullable_without_default
=== RUN   TestCheckExpand/drop_in_expand
=== RUN   TestCheckExpand/missing_column
--- PASS: TestCheckExpand (0.00s)
    --- PASS: TestCheckExpand/nullable (0.00s)
    --- PASS: TestCheckExpand/default (0.00s)
    --- PASS: TestCheckExpand/not_nullable_without_default (0.00s)
    --- PASS: TestCheckExpand/drop_in_expand (0.00s)
    --- PASS: TestCheckExpand/missing_column (0.00s)
=== RUN   TestCheckMigrateAndContract
--- PASS: TestCheckMigrateAndContract (0.00s)
=== RUN   TestValidatePhases
--- PASS: TestValidatePhases (0.00s)
=== RUN   TestIdempotentDeduplicatesReplays
--- PASS: TestIdempotentDeduplicatesReplays (0.00s)
PASS
ok  	webapp/internal/release	0.223s

输出只包含列变更检查和双写幂等的测试,不包含同包的发布单元与灰度测试。0.223s 是一次本机运行的耗时,会随机器和负载变化。

小结

数据兼容的核心是“先扩后收”:先加新结构,再双写,再回填,再双读校验,再切换读写,最后删除旧结构。每一步都要能停、能观测、能回到上一步,只有在旧代码全部下线、读取方全部迁移之后才允许 contract。

Check 把每个阶段的合法动作固定下来,ValidatePhases 防止跳过 migrate,Idempotent 把双写的重复执行折算成一次逻辑写入。它们不能替你做在线 DDL、不能保证业务主键一定唯一,但能把“rename 一步到位”这类不可逆操作挡在发布之前。

下一章会从单次发布转向稳定性度量:用户视角的 SLI、错误预算和告警如何设计。

阅读导航:上一节:10.2 灰度、开关与回滚 · 下一节:11.1 用户视角的 SLI 。

常见错误与边界

  • 用 rename 或一步 DDL 完成列迁移。旧代码还在跑时,旧列已经不存在,写入会立刻失败。
  • 先删旧列再下线旧代码。代码回滚后仍然写旧列,回滚镜像救不了数据变更。
  • expand 阶段加 NOT NULL 且无默认值的列。旧代码的 INSERT 不带这一列,写入会失败。
  • 在业务高峰做全表回填。长时间更新会锁住写入、拉高复制延迟,失败后留下半成品。
  • 双写只写新列不写旧列。旧代码和旧读路径还在用旧列,数据会分叉。
  • 双写失败后仍然提交事务。新列会积累无法解释的缺口,回填和校验都补不回来。
  • 跳过双读校验直接切换读路径。新列的格式、精度、默认值差异要到线上才暴露。
  • 没有确认读取方就进入 contract。报表、风控和离线任务可能还在读旧列,删除后静默失败。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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