引言
数据模型不是一次设计定终身的——业务变了,表就得变。但"加一列"在开发环境是三秒的事,在生产环境可能是锁表、宕机、数据丢失的开始。数据库迁移(Schema Migration)的难点从来不是"写 ALTER TABLE",而是"如何在用户无感知、数据不损坏的前提下,安全地改变数据形态"。
Schema 迁移的工程本质:把"改表"变成可版本化、可回滚、可审计、对生产无惊扰的常规操作。
本文覆盖迁移工具与版本管理、安全迁移模式(expand-contract / 双写)、破坏性变更的规避、大表迁移的性能控制,以及流式数据与数仓场景的 Schema 演进策略。
一、迁移工具与版本管理
1.1 为什么需要迁移工具
手工在线上执行 DDL 的问题:
- 不可追溯:谁改的、什么时候、怎么改的,没有记录。
- 不可回滚:改错了没有撤销途径。
- 不可复制:开发/测试/生产环境各自为政,schema 漂移。
迁移工具把 schema 变更当作"代码版本"管理:一个迁移 = 一个文件 = 一个版本,按序应用,全环境一致。
1.2 主流工具对比
| 工具 | 语言 | 反向迁移 | 适用 |
|---|---|---|---|
| Flyway | 通用(SQL/Java) | 支持 undo 文件 | 轻量、SQL 原生 |
| Liquibase | 通用(XML/YAML/SQL) | 支持 rollback | 企业级、多云 |
| Prisma Migrate | Prisma schema | 支持 | Node/TS 生态 |
| Alembic | Python (SQLAlchemy) | 支持 downgrade | Python 服务 |
| dbt 迁移(建模) | dbt | 重跑模型 | 数仓建模层 |
选型:业务库(Postgres/MySQL)用 Flyway/Liquibase;数仓建模演进用 dbt(models/ + 版本化);TS 服务用 Prisma 生态。
1.3 迁移文件规范
# 迁移命名: V<版本>__<描述>.sql
# V1__create_users.sql
# V2__add_email_verified.sql
# V3__rename_status_to_state.sql
# 规则
# 1) 版本号单调递增,绝不修改已应用的迁移(只加新版本)
# 2) 一个迁移只做一件事(可独立回滚)
# 3) 迁移文件进 Git,CI 里自动应用与校验
# 4) 迁移不可包含"应用逻辑"(默认值/数据回填要谨慎,可能需脚本)
二、安全迁移模式
2.1 非破坏性变更(优先)
# 安全的操作(多数情况下)
# ✅ 新增 nullable 列(无默认值)
# ✅ 新增索引(在线/并发构建)
# ✅ 新增表 / 新增约束(无历史数据冲突)
# ✅ 新增枚举值 / 放宽约束
# ⚠️ 需谨慎
# 加 NOT NULL 列 → 要默认值 + 分批回填
# 改类型 → 需兼容转换
# ❌ 破坏性(禁止直接做)
# 删列 / 删表 / 改主键 / 锁表重建
2.2 Expand-Contract 模式(加列改名)
经典安全模式分三步,每步可独立上线:
# 以"改名 status → state"为例
# Step 1 (Expand): 新增 state 列(nullable),应用双写/回填
# ALTER TABLE orders ADD COLUMN state varchar(32);
# Step 2 (过渡): 应用层同时写 status 和 state;
# 读端迁移到 state;回填历史数据校验一致
# Step 3 (Contract): 应用完全用 state,无依赖后
# ALTER TABLE orders DROP COLUMN status;
# 关键: 每步都能回滚,跨步部署窗口内新老代码共存
expand-contract 的价值:加列和删列不发生在同一发布里,杜绝"新代码读老列、老代码读新列"的兼容撕裂。
2.3 加 NOT NULL 列的正确姿势
给存量数据加"非空列"最易踩坑(空值回填问题):
# 错误: ALTER TABLE t ADD COLUMN c INT NOT NULL; -- 老数据无值直接失败
# 正确(三步):
# 1) ADD COLUMN c INT NULL;
# 2) 应用层写入新值 + 脚本分批回填老数据
# 3) 确认无 NULL 后: ALTER TABLE t ALTER COLUMN c SET NOT NULL;
2.4 双写(Dual Write)模式
涉及"数据从旧结构迁到新结构"(拆表/合并表)时用双写:
# 双写: 新老结构同时接收写入,校验一致性,直到迁移完成
# 1) 建新表 + 触发/应用层双写
# 2) 历史数据回填到新表
# 3) 定期对账(新老行数/字段 diff),直到收敛
# 4) 读流量切到新表(灰度)
# 5) 停旧写,删旧表
# 适用: 拆大表、改分区策略、冷热分离
三、破坏性变更的规避
3.1 原则:永远别直接删
# 删除的替代
# 需要"删列/删表" → 先标记废弃(annotate)→ 观察无依赖 → 再删
# 需要"改类型" → 加新列写新类型,双写过渡,确认后删旧
# 需要"合并表" → 新表 + 双写 + 对账 + 切流 + 停旧
# 铁律: 删除前先确认"没有活跃依赖"(代码引用/查询/下游订阅)
3.2 迁移失败的回滚
# 迁移回滚策略
# 1) 结构回滚: undo 文件(Flyway/Liquibase)或反向 DDL
# 2) 数据回滚: 涉及数据变动的迁移,迁移前备份受影响数据
# 3) 应用兼容: 迁移设计成"新老代码都能跑"(expand-contract 天然如此)
# 4) 演练: 关键迁移先在 staging 做"失败-回滚"演练
3.3 迁移与发布的配合
迁移要与应用发布解耦:先迁移、再发布、后清理。
# 正确的发布时序
# T0: 迁移 v2(加列,不破坏)→ 上线(老代码也能跑)
# T1: 发布应用 v2(开始用新列)→ 无需迁移
# T2: 确认稳定 → 迁移 v3(删旧列)→ 应用 v3 不再依赖
# 绝不在同一个发布里"改表 + 改代码读新结构"
四、大表迁移的锁与性能
4.1 锁的危险
# MySQL 经典问题
# ALTER TABLE 大表 → 可能锁全表,读写阻塞数小时
# PostgreSQL: 加默认值列在 PG11+ 已优化(元数据操作),但仍要注意
# 规避
# 1) 在线 DDL(MySQL pt-online-schema-change / gh-ost; PG 原生较友好)
# 2) 分批: 用工具限流限速,逐步完成
# 3) 低峰期执行 + 监控锁等待
4.2 大表加列的工程做法
# 用 gh-ost / pt-osc(在线无锁迁移)
# 原理: 建影子表 → 拷数据(增量)→ 追 binlog → 切表
# 或(数仓场景):
# 不改原表,用"视图/新表 + 切换":
# 1) 建新表(新结构)
# 2) 双写/增量同步
# 3) 切换读取到新表
# 4) 旧表保留一段观察期
4.3 索引重建与回填的性能控制
- 索引重建在低峰 + 并发限制下执行。
- 数据回填分批(按主键区间/时间窗),每批带 checkpoint,可断点续跑。
- 回填脚本要幂等(同一条记录重复处理结果一致)。
五、数据演进(列/类型/合并)
5.1 改列类型的兼容性
| 原类型 | 目标类型 | 风险 |
|---|---|---|
| varchar → text | 低 | 直接扩 |
| int → bigint | 低 | 直接扩 |
| varchar → date | 高 | 解析失败/非法值 |
| decimal 精度提升 | 低 | 直接扩 |
| 枚举扩展 | 低 | 加值安全 |
规则:向"更宽松"的类型演进安全(扩宽度),向"更严格/不同语义"演进高风险(需新列双写过渡)。
5.2 合并/拆分表的演进
# 拆分大表(冷热分离):
# 1) 建 hot/cold 表 + 路由层(应用按规则写)
# 2) 存量回填
# 3) 读接口按需查对应表
# 合并两张表:
# 1) 建合并表(统一结构)
# 2) 双写 + 回填(处理 id 冲突,加来源字段)
# 3) 对账收敛后切流
# 永远保留"可回滚到旧表"的能力,直到新结构稳定
5.3 数据演进的可观测性
- 对账任务:双写期间定期 diff(行数、关键字段、校验和)。
- 指标:迁移进度、对账差异数、回填速率、失败重试。
- 告警:对账差异超过阈值立即告警,别让"静默不一致"潜伏。
六、流式与数仓的 Schema 演进
6.1 Kafka 与流式 Schema 演进
流式数据(Kafka)的 schema 演进更严格——消费者可能运行在不同版本:
# 用 Schema Registry(Confluent/自建)
# 兼容性策略(从严到宽)
# BACKWARD(默认): 新 schema 能读旧数据
# FORWARD: 旧消费者能读新数据(给消费端缓冲时间)
# FULL: 双向兼容(最稳,演进空间小)
# 演进规则
# 1) 只加列(设默认值)最安全,删列/改类型需走版本周期
# 2) 消费者升级先于生产者变更发布
# 3) 破坏性演进用 topic 新版本 + 双订阅过渡
6.2 数仓中的建模演进
# dbt 建模演进
# 1) 模型版本化(models_v2): 新旧模型并行,下游迁移
# 2) 加列安全: 直接加(nil 兼容),下游用 COALESCE
# 3) 重命名: 建新模型 + 下游迁移 + 旧模型下线(expand-contract)
# 4) 回填/重跑: 版本化后重跑旧数据按新逻辑(全量重算)
# 数仓原则: 建模层演进不破坏历史可追溯性(快照/拉链表)
6.3 Schema 漂移的监测
- Schema 变更审计:谁改了什么、影响哪些下游。
- 上下游契约:数据契约(字段、类型、兼容级别)版本化,变更通知订阅方。
- 异常检测:数据里突然出现新字段/空值比例变化 → 可能是上游结构漂移。
七、工程规范与常见陷阱
7.1 迁移检查清单
# [ ] 迁移可回滚(undo/备份)且已演练
# [ ] 破坏性操作拆成 expand-contract 多步
# [ ] NOT NULL 加列:默认值 + 分批回填
# [ ] 大表迁移用在线工具 + 低峰执行
# [ ] 迁移与应用发布解耦(先迁移后发布)
# [ ] 双写场景配了对账与告警
# [ ] 流式场景用 Schema Registry 管理兼容性
7.2 常见陷阱
- 一个迁移干太多事:回滚时没法只撤一半。
- 迁移依赖应用代码:迁移里的默认值/回填逻辑与代码耦合,发布时序被打乱。
- 忽略数据回填:加了列不回填,下游读到 NULL 还不知道。
- 直接删列:下游还在用,上线即故障。
- 迁移不回滚演练:真出事时才发现 undo 是坏的。
总结
| 环节 | 关键选择 | 最佳实践 |
|---|---|---|
| 工具 | Flyway / Liquibase / dbt | 迁移进 Git + CI |
| 变更 | 加列优先、删除慎行 | expand-contract 多步 |
| 回填 | 分批 + checkpoint | 幂等可续跑 |
| 大表 | 在线工具 + 低峰 | gh-ost/影子表 |
| 双写 | 新老并行 + 对账 | 收敛后才切流 |
| 流式 | Schema Registry | BACKWARD 兼容 |
| 发布 | 先迁移后发布 | 新老代码共存 |
Schema 迁移的本质是把"改变数据形态"变成可版本、可回滚、无惊扰的工程动作。核心原则:非破坏性变更优先,破坏性变更拆成 expand-contract 多步,迁移与应用发布解耦,所有变更带可观测与回滚能力。 数据形态的演进不该成为上线风险——它该是日常能力。
参考与延伸阅读
- Flyway 官方文档:迁移版本管理与 undo
- Liquibase 官方文档:rollback 与变更集
- gh-ost / pt-online-schema-change:在线无锁迁移原理
- Confluent 文档:Schema Registry 与兼容性策略
- Refactoring Databases(Addison-Wesley)——数据库重构经典
- Kafka Connect 与 CDC — 流式数据变更捕获
- 数据仓库建模 — 建模层的演进基础
- 数据治理与质量 — schema 契约与治理衔接
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。