代码能回滚,数据不跟着回滚
蓝绿和金丝雀解决的是流量切换,回滚命令执行完,旧代码重新拿到流量,但数据库里已经写入的新格式数据不会自动变回去。如果新版本把 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 天。
| 时刻 | 动作 | 观测与判据 | 允许的回滚 |
|---|---|---|---|
| D0 | expand:加可空 status_v2,新代码不读写 | 旧版本无感,DDL 不锁表 | 直接删列 |
| D1 | 发布 dual write 版本 | 新订单两列都有值,ReadOld 仍成功 | 重新部署旧版本 |
| D2–D3 | 回填历史行,跑一致性校验 | Mismatched=0,未回填行归零 | 停回填,旧列仍是权威 |
| D4 | 下线所有旧版本实例 | OldReaders=0 | 回到 dual write |
| D11 | 兼容窗口满 7 天,验证备份 | WindowElapsed>=RequiredWindow | 回到 dual write |
| D12 | contract:删 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 开关与灰度的数据兼容约束 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。