软件项目开发全流程 · 第 34 课 · 数据迁移与数据库演进
1、代码能回滚,数据不能
一次发布:新版本把 user.name 拆成了 first_name + last_name,代码写得漂漂亮亮。上线后发现问题,回滚——新版本已经把 1000 条数据写成了新格式,旧代码读不懂了。服务彻底瘫痪。
应用代码可以回滚,数据库变更往往"覆水难收"。 这使得数据库演进(Database Evolution)成为发布中最危险、最需要谨慎的一环。本课专门讲它。
2、为什么数据库变更这么难
| 代码 | 数据库 |
|---|---|
| 无状态、可随时替换 | 有状态,数据是命根子 |
| 回滚 = 换回旧版本 | 回滚 = 可能丢数据/结构不兼容 |
| 一份代码一个版本 | 新旧代码可能同时运行(滚动/灰度) |
| 出错影响一次部署 | 出错影响所有历史与未来数据 |
核心矛盾:滚动发布/灰度期间,新旧两版代码会同时访问同一个库。 所以数据库变更必须向后兼容:
数据库的当前结构,要能同时满足"旧代码"和"新代码"。
3、迁移(Migration):数据库的版本控制
迁移把数据库的每次结构变更,记录成一个有序、可执行、可追溯的脚本:
migrations/
001_create_tasks.sql
002_add_due_date.sql
003_add_index_on_status.sql
004_split_user_name.sql ← 本次发布每个迁移分 up(正向) 和 down(回滚):
-- 002_add_due_date.up.sql
ALTER TABLE tasks ADD COLUMN due_date DATE;
-- 002_add_due_date.down.sql
ALTER TABLE tasks DROP COLUMN due_date;工具:Flyway、Liquibase(Java 生态)、Alembic(Python)、Rails Migrations、Prisma Migrate、golang-migrate。
迁移三原则:
- 只进不退(大部分情况):生产上
down讲起来容易做起来难(数据已丢)。很多团队只写 up,靠向后兼容来保证可回滚。 - 幂等/可重复:脚本重复执行不出错(或用版本号表跳过)。
- 纳入版本控制:迁移脚本和代码一起提交、一起评审。
4、向后兼容的"扩展-收缩"模式
处理破坏性变更(改名、拆表、改类型)的黄金模式:扩展 → 迁移 → 收缩(Expand-Contract / Parallel Change)。
以"user.name 拆成 first_name + last_name"为例:
阶段 0(现状):表有 name 字段,代码用 name
阶段 1 · 扩展(Expand)
① 数据库:新增 first_name、last_name 两列(可空,不删 name)
② 代码:写的时候同时写 name 和 first/last(双写);
读的时候优先读 first/last,回退读 name
→ 新旧代码都能工作 ✅
阶段 2 · 迁移历史数据
跑后台任务,把存量数据从 name 拆填到 first/last
阶段 3 · 收缩(Contract)
① 代码:确认全部改用 first/last 后,停止写 name
② 数据库:确认无人再用 name 后,删除 name 列要点:
- 每一步都向后兼容,任意时刻都可以回滚到上一步。
- 删字段放到最后(且通常隔一个发布周期),确认无人使用再删。
- 加字段优先可空(或带默认值),避免锁表 & 老代码插入失败。
口诀:先加后删、先双写后切换、先兼容后清理。5、危险操作清单(生产上要格外小心)
| 操作 | 风险 | 安全做法 |
|---|---|---|
| 加非空列(无默认) | 老代码插入失败、锁表 | 先加可空列,再回填,再设非空 |
| 改列类型 | 隐式转换、精度丢失、锁表 | 加新列 → 回填 → 切换 → 删旧列 |
| 重命名列/表 | 老代码找不到 | 同"扩展-收缩" |
| 大表加索引 | 长时间锁表 | CREATE INDEX CONCURRENTLY(PG)等在线方式 |
| 删除列/表 | 不可逆、旧代码崩 | 确认无人用、备份、延后 |
| 大批量更新 | 锁表、主从延迟 | 分批、限速、错峰 |
| 直接改数据 | 无审计、易错 | 用脚本 + 事务 + 记录 |
大表 DDL 的教训:一条 ALTER TABLE 可能锁表几分钟,线上直接雪崩。先在小数据量环境测时机,或用在线 DDL 工具(pt-online-schema-change、gh-ost)。
6、数据迁移 ≠ 结构迁移
除了改结构,还有搬数据:
- 同库数据清洗:如统一手机号格式(分批 + 可重入 + 记录)。
- 跨库/跨系统迁移:如从 MySQL 迁到 PostgreSQL、老系统迁到新系统。
跨系统迁移的关键:
① 双写:新老系统同时写,保证两边一致
② 校验:比对两边数据,找出差异
③ 灰度:逐步把读流量切到新系统
④ 切换:读全部切新,老系统停写
⑤ 观察:确认无误后,下线老系统迁移的可回滚性:要么能切回,要么保留原数据只读不动,作为兜底。
7、迁移与发布如何配合
发布前:
□ 迁移脚本已评审、已在预发演练
□ 变更向后兼容(旧代码能跑新库)
□ 有备份(重要!)
□ 大表操作已评估锁表影响
发布中:
□ 先跑迁移(兼容版),再发新代码
□ 观察数据库指标(连接、慢查询、主从延迟)
发布后:
□ 确认新老代码都正常
□ 数据校验(数量、抽样)
□ 收缩步骤(删旧列)**下一个**发布再做顺序:数据库先兼容 → 代码后切换 → 清理放最后。
8、常见误区
| 误区 | 问题 | 正解 |
|---|---|---|
| 迁移能 up 就能 down | down 常丢数据、难执行 | 靠向后兼容保证可回滚 |
| 直接改列名/类型 | 旧代码崩、锁表 | 扩展-收缩四步走 |
| 发布时跑大表 DDL | 锁表、雪崩 | 在线 DDL / 错峰 / 分批 |
| 删字段立刻删 | 无法回滚 | 延后一个周期,确认无人用 |
| 不做备份 | 出事无法恢复 | 变更前必备份 |
| 迁移脚本不入库 | 环境间不一致 | 脚本进版本控制,纳入评审 |
9、动手实验 🎯
任务:为贯穿项目「团队任务清单」做一次向后兼容的数据库演进。
- 搭迁移工具:选一个(Flyway/Alembic/原生脚本),把现有表结构变成第一个迁移脚本
001。 - 做一次扩展-收缩:把
tasks.title拆成title+备注,或给任务加一个priority字段——严格走四步(扩展 → 回填 → 切换 → 收缩)。 - 验证兼容性:在"只加列"阶段,用旧代码插入一条数据,确认成功。
- 写 up 和 down:为
002迁移写回滚脚本,并在测试库演练一次回滚。 - 分批更新:写一个分批回填脚本(每批 100 条),模拟大数据量场景。
- 演练一次"发布中迁移":备份 → 跑迁移 → 部署新代码 → 校验数据 → 规划下一周期的收缩。
- 反思:如果这次迁移在发布后 10 分钟必须回滚,你的数据能安全回退吗?为什么?
下一课:系统退役与迁移。
10、今天你带走的三句话
- 代码可回滚,数据难回滚;数据库变更必须向后兼容,让新旧代码共存。
- 扩展 → 迁移 → 收缩:先加后删、先双写后切换、先把删字段拖到最后。
- 结构变更与数据搬运都走"可回滚路径":备份、分批、双写、灰度、校验。
下一课:系统退役与迁移。