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 天),且期间没有回滚需求。两个条件都满足才删;否则先保留旧列,代价只是存储和一点维护成本,比“删了之后无法回滚”便宜得多。
关联
- 前置:55.1 发布策略、55.5 向后兼容
- 并列:55.3 回滚设计
- 下游:44.7 SQL 与查询优化、36.2 备份策略