55.4 数据库变更管理 (Expand-Contract)

一句话

数据库变更唯一安全的姿势是把“改结构”拆成多次兼容的发布:先加(expand)、再迁(migrate)、最后删(contract);任何一步都能被上一版代码容忍。

先破除四个误区

误区 现实
“加个字段/改个类型,一条 SQL 的事” 大表 ALTER 可能锁表几分钟;旧代码不认新列、新代码读不到旧值,都会在发布窗口里炸
“先改库,再发代码” 改库的一瞬间,线上跑的还是旧代码。它可能因为 NOT NULL、删列、改类型立刻失败
“回滚就是执行 down 脚本” down 常常是删列/删数据;数据回滚不是“反向 DDL”,是恢复备份或补偿
“大表只能停机维护” MySQL 在线 DDL、gh-ost、pt-online-schema-change 都能不锁表改,但要付复制延迟与双写复杂度的代价

为什么“一步到位”必炸

发布期间新旧版本同时在线(见 55.1 发布策略):旧代码读旧结构,新代码读新结构,而数据库只有一份。

时间 ──────────────────────────────────────────────▶
代码:   v1(读 email)        v2(读 email_norm)
数据库: email 列 ── 改名/删列 ──▶ email_norm 列
                    ↑
          这一刀下去,v1 立刻读不到数据

“一步到位”的问题不是 SQL 写错,而是它假设所有读者同时切换。滚动发布、灰度、回滚都违反这个假设。

三阶段:Expand → Migrate → Contract

阶段 做什么 谁还能跑 回滚动作 回滚代价
Expand 加新列/新表/新索引,全部可空或有默认值;部署双写代码 新旧代码都能跑 删新列 低(还没人依赖)
Migrate 回填历史数据,校验一致;切读新列 新旧代码都能跑 停回填、切回读旧列 低–中(回填可重入)
Contract 停旧写、删旧列/旧表 只有新代码能跑 恢复列 + 回填 高(小时级,可能丢数据)

三个阶段共同的前提是向后兼容:旧代码要能读新结构、新代码要能读旧数据,判定规则见 55.5 向后兼容。

🔑 Expand 和 Migrate 都是“可回退”的;Contract 是唯一真正不可逆的阶段,所以它必须最后做,并且要等“旧代码完全下线 + 回滚窗口结束”。

发布顺序:六次发布,不是一次

release N    : expand 迁移(加列,可空)
release N+1  : 双写代码(写新列,读旧列)
release N+2  : 回填任务(分批,可重入)
release N+3  : 切读(读新列,双写继续)
release N+4  : 停旧写(旧列不再写)        ← 回滚开始变贵
release N+5  : contract(删旧列)          ← 真正不可逆,≥ 回滚窗口后

可以合并部分步骤(例如双写代码与回填同批),但不能改变相对顺序:读新列不能早于回填完成;删旧列不能早于旧代码下线;停旧写不能早于切读稳定。

双写、校验、切读

步骤 做法 失败会怎样
双写 写请求同时写旧列和新列,同一事务 两列不一致,切读后读到错值
回填 按主键分批 UPDATE ... WHERE new IS NULL LIMIT n,记录断点 重复回填(可重入,幂等)、长事务锁表
校验 抽样比对 + COUNT/SUM/校验和 不一致时切读,错误被放大
切读 读新列,写仍双写;灰度开关控制 新列有空值时读到 NULL
停旧写 只写新列 回滚要先把新列回填到旧列
删列 contract 旧代码/旧报表/旧备份恢复流程全断

回填 SQL 的执行计划与索引,见 44.7 SQL 与查询优化;索引与最左前缀见 44.8 索引原理(待补)。

校验不要只抽样“看起来对”:至少三条——行数一致、关键字段空值数为 0、抽样比对不一致数为 0。抽样比例按风险定,金额类字段建议全量比对或按日对账。

在线 DDL:能不停机,但要付代价

方案 原理 适用 代价
MySQL ALGORITHM=INSTANT 只改元数据 加列(版本/位置有限制) 版本差异大,需实测
MySQL ALGORITHM=INPLACE 原地重建/改索引 加索引、部分改列 仍可能短暂拿元数据锁
gh-ost 读 binlog 同步到影子表,切表 大表改结构 需要额外磁盘、复制延迟、切换瞬间
pt-online-schema-change 触发器同步到影子表 大表、MySQL 传统方案 触发器开销、失败回滚复杂
PostgreSQL ADD COLUMN(非易失默认值) 只改元数据(11+) 加列 易失默认值仍会重写全表
PostgreSQL ALTER TYPE / 改约束 重写表 + 强锁 小表/维护窗口 必须设 lock_timeout

在线 DDL 不是“无代价”:它会占用 IO、产生复制延迟、在切换瞬间拿锁。生产上要限速、在低峰做、盯着复制延迟与主从切换预案。

一个能跑的 Expand-Contract 状态机

下面这段程序把六个阶段与“非法顺序”跑出来:先 expand、再分批回填、校验、切读、停旧写、最后 contract;中途想在切读前 contract,会被拒绝。

package main

import (
	"fmt"
	"strings"
)

type user struct {
	id        int
	email     string // 旧列
	emailNorm string // 新列(expand 阶段后才有)
}

type schema struct {
	phase     string // baseline / expanded / read-new / write-old-off / contracted
	oldColumn bool
	newColumn bool
	dualWrite bool
}

func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }

func main() {
	rows := []user{
		{1, "A@Example.com", ""}, {2, "b@example.com", ""}, {3, "C@EXAMPLE.COM", ""},
		{4, "d@example.com", ""}, {5, "E@Example.com", ""}, {6, "f@example.com", ""},
		{7, "G@Example.com", ""}, {8, "h@example.com", ""},
	}
	s := schema{phase: "baseline", oldColumn: true}

	fmt.Println("① expand:加新列(可空)+ 部署双写代码")
	s.newColumn, s.dualWrite, s.phase = true, true, "expanded"
	// 双写只作用于“新写入”;历史行仍是 NULL,等下一步回填。
	fmt.Printf("   schema=%s old=%v new=%v dualWrite=%v\n", s.phase, s.oldColumn, s.newColumn, s.dualWrite)

	fmt.Println("\n② migrate:分批回填历史行(可重入、可断点续跑)")
	const batch = 3
	filled := 0
	for start := 0; start < len(rows); start += batch {
		end := start + batch
		if end > len(rows) {
			end = len(rows)
		}
		for i := start; i < end; i++ {
			if rows[i].emailNorm == "" {
				rows[i].emailNorm = norm(rows[i].email)
			}
		}
		filled += end - start
		fmt.Printf("   batch %d-%d 完成,累计 %d/%d\n", start+1, end, filled, len(rows))
	}
	mismatch := 0
	for _, r := range rows {
		if r.emailNorm != norm(r.email) {
			mismatch++
		}
	}
	fmt.Printf("   校验:mismatch=%d(必须为 0 才能进入下一步)\n", mismatch)

	fmt.Println("\n③ 非法顺序:回填后、切读前就想 contract")
	if s.phase != "read-new" {
		fmt.Println("   REFUSED:旧代码仍在读/写 email 列,删列会让它立刻报错")
	}

	fmt.Println("\n④ 切读:读走新列,双写继续(此时仍可回退到读旧列)")
	s.phase = "read-new"
	fmt.Printf("   schema=%s read=new dualWrite=%v\n", s.phase, s.dualWrite)

	fmt.Println("\n⑤ 停旧写:旧列不再写入(回滚开始变贵的点)")
	s.dualWrite, s.phase = false, "write-old-off"
	fmt.Println("   ⚠ 此后新数据的旧列为空,回退到旧代码会读到 NULL")

	fmt.Println("\n⑥ contract:删旧列(不可逆,必须等回滚窗口结束 + 有备份)")
	s.oldColumn, s.phase = false, "contracted"
	fmt.Printf("   schema=%s old=%v new=%v\n", s.phase, s.oldColumn, s.newColumn)

	fmt.Println("\n回填耗时估算(按 5,000 行/秒):")
	for _, n := range []int{1_000_000, 100_000_000, 1_000_000_000} {
		h := float64(n) / 5000 / 3600
		if h < 1 {
			fmt.Printf("   %d 行 → %.1f 分钟\n", n, h*60)
		} else {
			fmt.Printf("   %d 行 → %.1f 小时\n", n, h)
		}
	}
}

本机运行结果:

① expand:加新列(可空)+ 部署双写代码
   schema=expanded old=true new=true dualWrite=true

② migrate:分批回填历史行(可重入、可断点续跑)
   batch 1-3 完成,累计 3/8
   batch 4-6 完成,累计 6/8
   batch 7-8 完成,累计 8/8
   校验:mismatch=0(必须为 0 才能进入下一步)

③ 非法顺序:回填后、切读前就想 contract
   REFUSED:旧代码仍在读/写 email 列,删列会让它立刻报错

④ 切读:读走新列,双写继续(此时仍可回退到读旧列)
   schema=read-new read=new dualWrite=true

⑤ 停旧写:旧列不再写入(回滚开始变贵的点)
   ⚠ 此后新数据的旧列为空,回退到旧代码会读到 NULL

⑥ contract:删旧列(不可逆,必须等回滚窗口结束 + 有备份)
   schema=contracted old=false new=true

回填耗时估算(按 5,000 行/秒):
   1000000 行 → 3.3 分钟
   100000000 行 → 5.6 小时
   1000000000 行 → 55.6 小时

回填时长决定“这次变更要占用多少个发布窗口”。1 亿行、5,000 行/秒要 5.6 小时;如果再把批次调小以控制复制延迟,时间还要翻倍。先把回填当作一个独立项目排期,再谈什么时候能 contract。

回滚:每个阶段退到哪

当前阶段 回滚到 动作 代价
expand 变更前 删新列 低;新列还没被读
回填中 回填前 停任务、忽略新列 低;回填可重入
切读后 读旧列 开关切回读旧 低;旧列仍在双写
停旧写后 双写 重新打开旧写 + 把新列回填到旧列 中;期间产生的行要补
contract 后 不可能 只能恢复备份或重建列 + 回填 高;可能丢数据

这也是为什么 55.3 回滚设计 把“schema 兼容”列为回滚就绪的硬条件:制品能回,schema 不能回,等于不能回。

生产落地:指名 → 可抄 → 可验 → 可退

指名:迁移版本管理用 Flyway、Liquibase、golang-migrate 或 Atlas;大表在线改动用 gh-ost(MySQL)或 pg_repack/维护窗口(PostgreSQL)。原则:迁移文件只做“当前这一步”,不做“最终形态”;最终形态由多次发布组合出来。

可抄(本机未安装这些工具,以下命令未验证):

# 1) 迁移版本化:每个文件只做一步,文件名带序号
#    0007_expand_order_email_norm.up.sql   ALTER TABLE orders ADD COLUMN email_norm VARCHAR(255) NULL;
#    0007_expand_order_email_norm.down.sql ALTER TABLE orders DROP COLUMN email_norm;
golang-migrate -path ./migrations -database "$DSN" up 1

# 2) 大表在线加列/加索引(MySQL,低峰执行)
gh-ost --host=db --database=shop --table=orders \
  --alter="ADD COLUMN email_norm VARCHAR(255) NULL" \
  --allow-on-master --max-load='Threads_running=30' --critical-load='Threads_running=60'

# 3) 回填:按主键分批,带断点(示例 SQL,需按表结构调整)
#    UPDATE orders SET email_norm = LOWER(TRIM(email))
#    WHERE id > :last_id AND email_norm IS NULL ORDER BY id LIMIT 500;

可验:本节状态机真跑过,输出如上;它验证的是“阶段顺序与回滚代价”,不依赖数据库。真实库上至少验三件事:schema_migrations 版本与代码版本对得上;回填后 new IS NULL 的行数为 0;切读后抽样比对不一致数为 0。

可退:

  • 迁移写错:在 expand 阶段直接删新列;已有数据的大表要先备份。
  • 回填写脏:停任务、按主键重刷(回填必须幂等),不要用 TRUNCATE 重来。
  • 切读后出错:开关切回读旧列,双写不要停;确认新列数据完整后再切。
  • contract 前:至少保留一份全量备份(见 36.2 备份策略),并演练一次“从备份恢复 + 重新回填”,否则 contract 只是“赌”。

反模式

反模式 后果
直接 RENAME COLUMN 旧代码立刻找不到列;滚动发布期间随机 500
先删旧列,再发不读旧列的代码 中间窗口旧代码读不到数据
新增 NOT NULL 且无默认值 旧代码的 INSERT 失败;回填前新代码也可能写失败
一条 SQL 改大表 锁表数分钟,连接池打满,雪崩
回填不限速、不分批 主从延迟飙升、长事务、锁等待
回填不幂等、无断点 失败一次只能从头再来
只抽样校验就切读 长尾空值/格式不一致在切读后集中爆发
忘了 contract 双写永久保留,两套真相源慢慢漂移
用 down 脚本回滚数据 down 删列/删数据,二次事故

自检

Q1 为什么“先改库、再发代码”在滚动发布下一定出问题?

▸ 答案:改库的瞬间,线上仍有旧代码在跑。删列/改类型/加 NOT NULL 都会让旧代码的读写失败;而滚动发布期间新旧代码共存,失败是随机的、按实例分布的。正确顺序是 expand(兼容旧代码)→ 发双写代码 → 回填 → 切读 → 停旧写 → contract。

Q2 Expand-Contract 里,哪一步是真正的“不可逆点”?为什么?

▸ 答案:contract(删旧列/旧表)。在此之前,旧代码还能跑、数据还能从旧列重建;contract 之后旧列的数据没了,回滚只能恢复备份或重建列再回填,代价是小时级且可能丢数据。停旧写是“回滚开始变贵”的点,但不是绝对不可逆——把新列回填到旧列还能退。

Q3 回填任务为什么必须幂等、可断点续跑?

▸ 答案:回填通常是小时级任务,期间可能失败、被限流、被人工停止。幂等保证“重复执行同一批不会写坏数据”;断点保证“从上次位置继续,不用从头再来”。实现上按主键范围分批、只处理 new IS NULL 的行、记录最后处理的主键,避免 OFFSET 越翻越慢。

Q4 在线 DDL 不锁表,为什么还可能造成故障?

▸ 答案:它仍有代价:占用 IO 与 CPU、产生复制延迟、在切换影子表的瞬间拿元数据锁、需要额外磁盘。如果主从延迟超过阈值,读从库的请求会读到旧数据;如果切换时正好有长事务,会等待锁。所以在线 DDL 要限速、低峰执行、监控复制延迟,并准备失败回滚。

Q5 contract 应该等多久?

▸ 答案:至少满足两个条件:① 旧代码完全下线(没有实例、没有旧任务、没有旧报表连库);② 超过最大回滚窗口(例如 30 天),且期间没有回滚需求。两个条件都满足才删;否则先保留旧列,代价只是存储和一点维护成本,比“删了之后无法回滚”便宜得多。

关联