阅读提要

一次发布同时改表结构和读写代码,回滚就无从下手。迁移的正确姿势是拆成四步,让新旧版本能同时跑。

开始阅读

字号

为什么迁移会出事

最常见的场景:发布时执行 ALTER TABLE posts RENAME COLUMN title TO name,同时代码改成读 name。

发布过程中,旧版本进程还在跑,它读 title——报错。回滚时,代码回退到旧版,但表结构已经是新的——继续报错。

问题不在 SQL 写错,在「结构和代码同时变」这个动作本身。

迁移四步法

四步法:加、双写、回填、清理

以「把 title 改名为 name」为例。

第一步:加新列,可空

ALTER TABLE posts ADD COLUMN name TEXT;

这一步对旧代码完全透明。旧代码继续读写 title,新列空着。

第二步:双写

代码同时写 title 和 name。读仍然读 title。

这一步部署后,新数据在两个列里都有。旧版本进程如果还在跑,只写 title——所以回填要能覆盖这部分。

第三步:回填历史数据

UPDATE posts SET name = title WHERE name IS NULL;

分批执行(每批 1000 行),避免长事务锁表。回填期间双写仍在继续,所以不会漏。

第四步:切读,然后删旧列

代码改成读 name。观察一到两个发布周期,确认没有地方再读 title,再:

ALTER TABLE posts DROP COLUMN title;

到这里迁移完成。整个过程任何一步失败都可以只回滚代码,不需要回滚表结构——这是关键。

为什么值得这么麻烦

因为代码回滚比数据回滚便宜得多。

代码回滚是切一个版本,几十秒。数据回滚需要从备份恢复,可能丢数据,可能几十分钟。

四步法的每一步都保证「旧代码仍然能正常工作」,所以任何时刻出问题,回滚代码就够了。

几个具体场景

加字段:最简单。加可空列,或用默认值。注意大表加带默认值的列,某些数据库会重写整表(MySQL 8 之前),要用在线 DDL 工具。

删字段:先让代码不再读写它,观察一个周期,再删。直接删会让旧版本进程立刻崩。

改类型:加新列 → 双写 + 转换 → 切读 → 删旧列。不要原地 ALTER TYPE,尤其是有数据的情况下。

加索引:大表上直接 CREATE INDEX 会锁表。用 CREATE INDEX CONCURRENTLY(PostgreSQL)或在线 DDL(MySQL)。SQLite 的索引创建会阻塞写,安排在低峰期。

加非空约束:先回填,再加约束。直接加会让已有 NULL 行导致失败。

迁移脚本的管理

三条规则:

  1. 迁移脚本进版本库,和应用代码一起评审;
  2. 只前进,不改历史脚本。已经执行过的脚本再改,会导致不同环境状态不一致;
  3. 每个脚本可重复执行(IF NOT EXISTS、先查后建),避免部分失败后无法重跑。

一个简单的版本表就够:

CREATE TABLE IF NOT EXISTS schema_migrations (
  version TEXT PRIMARY KEY,
  applied_at TEXT NOT NULL
);

启动时读已应用的版本,跳过已执行的脚本。不需要复杂的框架。

数据量大的时候

回填几百万行,不能一条 UPDATE 跑到底。分批 + 限速:

UPDATE posts SET name = title
WHERE id IN (SELECT id FROM posts WHERE name IS NULL LIMIT 1000);

循环执行,每批之间 sleep 几十毫秒,给正常请求留出 IO。监控复制延迟(如果有从库),延迟过高就暂停。

一个检查清单

发布前问四个问题:

  1. 旧版本代码在新表结构下还能跑吗?
  2. 新版本代码在旧表结构下还能跑吗?(发布中途会遇到)
  3. 迁移脚本失败了,能直接重跑吗?
  4. 回滚代码之后,数据还一致吗?

四个都是「是」,这次迁移就是安全的。

迁移与备份的关系

每次迁移前必须有一次可用备份,而且这次备份要验证过能恢复。

原因是迁移脚本的失败模式往往不是「报错退出」,而是「执行了一半」。比如分批回填跑到第 300 批时进程被 kill,前 30 万行已经改了,后面的没有。

这时候重跑脚本必须能继续,而不是从头再来——这也是为什么回填条件要写成 WHERE name IS NULL 而不是按 id 区间。

如果迁移涉及删列或改类型,备份就是唯一的退路。这类操作没有「重跑一次」的选项,一旦数据被破坏,只能从备份恢复。

什么时候该停下来

三种情况应该中断迁移,而不是硬着头皮跑完:

从库延迟持续升高:说明写入压力超过了复制能力,继续会拖垮读请求。

锁等待时间变长:大表上的 DDL 会阻塞所有写操作,等多久取决于你的耐心和用户的不满程度。

错误率上升:迁移本身不应该影响正常请求。如果开始出现 5xx,先停,别赌「跑完就好了」。

停下来不丢人。跑到一半出事才丢人。

你认为这篇文章怎么样?

还没有表情反馈

分享文章