凌晨发布,旧代码写不进订单表
「回声」的订单表有一个 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。报表、风控和离线任务可能还在读旧列,删除后静默失败。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。