返回博客

数据库迁移:让 Schema 变更成为可控工程

生产数据库的 Schema 变更,是我见过最容易把「自信」变成「事故」的环节。

早期我也干过在服务器上直接 ALTER TABLE、完事不留记录的事情。出问题之前一切都显得很顺手;出问题之后才意识到——数据库状态已经和代码库脱钩了,没有人知道现在的表结构到底是怎么来的,更没有办法在另一台机器上重现同样的状态。

「迁移即代码」解决的本质问题就是这个:让数据库状态和代码库一起受版本控制,从而让每一次 Schema 变更都变得可审计、可重放、可回溯

五条核心原则

花了相当多时间在不同项目里踩坑,最终归纳出五条真正有价值的原则:

1. 所有变更走迁移文件,不存在「临时手动操作」。 哪怕只是加一列,也要写迁移脚本提交进仓库。一旦开了「临时手动」的口子,环境漂移就是必然结果。

2. 生产环境只走 forward migration。 「回滚」在理论上是跑 down migration,但实际上 down migration 几乎不可靠——数据已经写进去了,你怎么回滚?正确做法是把回滚也写成一条新的 forward migration,明确记录「我们决定撤销这个改动」这件事。

3. DDL 和 DML 分开写,不要混在同一个迁移里。 改表结构(DDL)和补数据(DML / backfill)是两件事,时间尺度不同、失败处理也不同,混在一起的迁移又慢又难调试。

4. 迁移文件一旦在生产跑过就不可编辑。 这条很多人知道但还是会犯——「只改了个注释」、「只格式化了一下」。工具是靠文件内容哈希来判断是否已执行的,编辑已部署的迁移文件会导致环境状态不可信。

5. 用真实规模的数据测试迁移。 100 行的测试数据上跑通的迁移,在 1000 万行的生产表上可能锁表 20 分钟。这条原则听起来理所当然,但能真正做到的团队不多。

最容易忽视的那一条

加新列时,绝对不要在有存量数据的表上直接加 NOT NULL 且没有默认值的列。这会触发全表重写,把整张表锁住。正确做法:先加可空列,backfill 数据,再加约束——三步分开走。

零停机变更的 Expand-Contract 模式

改列名、改列类型这类「破坏性」变更,用一步到位的 ALTER TABLE 必然会有停机窗口(或者让老版应用代码报错)。Expand-Contract 模式把这个过程拆成三个阶段:

阶段一:Expand(扩展)
  → 新增目标列(可空或有默认值)
  → 部署新版本代码:同时写入旧列和新列

阶段二:Migrate(迁移)
  → 跑 backfill migration,补全历史数据
  → 部署下一个版本:只从新列读,继续双写

阶段三:Contract(收缩)
  → 确认数据一致后,部署只写新列的版本
  → 单独一条迁移删掉旧列

整个过程可能横跨几天,甚至一周,但服务始终在线,每一步都可以独立验证和暂停。

大批量数据迁移的处理方式

数据 backfill 有个常见反模式:一条 UPDATE 语句更新全部存量行。这在小表上没问题,但在大表上会产生一个巨大事务,持有行锁的时间足以把 P99 延迟打爆。

正确做法是分批处理,每批提交一次事务,配合 SKIP LOCKED 避免和正常业务写入冲突:

-- 每次只更新一批,循环直到没有剩余行
UPDATE users
SET display_name = username
WHERE id IN (
  SELECT id FROM users
  WHERE display_name IS NULL
  LIMIT 10000
  FOR UPDATE SKIP LOCKED
);

Django 里对应的是在 RunPython 里手写分页循环;Prisma / Drizzle 里可以用 --create-only 生成空迁移然后手写 SQL。工具可以不同,批量提交这个模式是一样的。

删列的正确顺序

顺序非常重要,而且很容易搞反:

  1. 先从应用代码里删掉所有对该列的引用
  2. 部署这个版本,确认上线
  3. 在下一次部署的迁移里才执行 DROP COLUMN

很多人会把步骤 3 和步骤 1、2 放在同一次部署里,结果是:迁移先跑,列没了,老版本 Pod 还在跑,立刻开始报错。蓝绿部署、滚动部署的环境尤其容易踩这个坑。

一句话心法

迁移文件是数据库的变更日志,写给将来的自己和队友看——每一步要能独立验证,每一步都要能说清楚「为什么」。