数据库迁移实战:改结构不再心惊肉跳
后端最怕的两件事:一是上生产忘了同步表结构导致接口 500,二是上线后想回退却发现结构改不回去了。数据库迁移(Migration)就是给 改表结构 这件高风险的事上的一道保险:把每一次表结构变化写成不带状态的文件,用顺序执行、支持回滚,让结构变更变得可追溯、可协作、可回退。这篇文章就把它讲透,重点是你最容易忽略的坑。

一、没有迁移时,改表有多痛
接手的项目如果没有迁移,改表通常是这样的:连上数据库,手敲一句 ALTER TABLE ... ADD COLUMN ... ,敲完本地能跑,然后……要么忘了在生产也敲一下,要么生产敲的时候把 SQL 少抄了一段,要么三个环境表结构各不相同、对不上一地鸡毛。
问题本质: 表结构的变化是 一次性动作 ,却没有任何记录,也没法回退。 而迁移解决的就是 把一次变化变成一个可管理的东西 。
二、迁移是什么:把变化变成一个个带版本的文件
迁移的基本模型非常简单:
database/migrations/
20260701000001-create-users.js # 版本 1:建 users 表
20260715000002-add-age-to-users.js # 版本 2:给 users 加 age 列
20260718000003-add-unique-email.js # 版本 3:给 email 加唯一索引
...每个文件都是一个 可上可下 的变化描述,通常长这样(以 Sequelize / knex 风格为例):
'use strict';
module.exports = {
// 执行迁移:向前推进一步
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'age', {
type: Sequelize.INTEGER,
allowNull: true,
comment: '年龄',
});
},
// 回滚迁移:撤销这一步
down: async (queryInterface) => {
await queryInterface.removeColumn('users', 'age');
},
};每个迁移文件由 up (前进)和 down (回退)两个动作组成 ,这是迁移的 灵魂 。有 down ,你才能随时回退到上一步;没有 down ,迁移就只能进、不能退。
三、谁帮你记录运行到哪一步了
工具会自己维护一张 迁移记录表 (比如 SequelizeMeta ),专门记 已经跑过哪些文件 。每次 migrate ,它会把新文件按顺序跑一遍,并把文件名记录进这张表;每次 migrate:undo ,则按顺序 倒着回退 最近的一步。
# 执行所有未跑过的迁移
npx sequelize db:migrate
# 回退最近一次
npx sequelize db:migrate:undo
# 回退全部
npx sequelize db:migrate:undo:all
# 查看当前状态
npx sequelize db:migrate:status关键在于: 工具只看 文件名时间戳 决定顺序,也只认识 已经跑过哪些 来决定下一步。 所以时间戳前缀( 20260702000001_ )不只是好看,它是排序的命根子 - 不要乱改一个已经跑过的迁移文件的文件名或内容。
四、最核心的纪律:已跑过的迁移不修改
这是新手最容易破的戒。场景是:上线后才发现迁移漏了个字段,于是 直接改了那个已应用过的迁移文件 ,再跑 migrate ……但记录表里已经记了 跑过了 ,所以它根本不会再执行,你改的等于白改,而库结构又对不上。严重时 migrate:status 还会显示错乱。
正确做法是:任何对已应用结构的修改,都新建一个新的迁移文件。 迁移文件出生后就是 只读历史 ,你只在它的基础上 再叠一层 。
五、数据迁移 vs 结构迁移:常常被一起提起
结构变了,往往老数据还 不合新结构 。迁移除了能改表结构,还能顺便处理存量数据(虽然是同一个 up 里做)。举例:你给 users 加了一个 type 列,默认值空,但希望把已有数据按规则填上,就可以在 up 的后面补一段更新:
async up(queryInterface) {
// 1) 加列
await queryInterface.addColumn('users', 'type', {
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: '',
});
// 2) 补数据:把老的'普通用户'标出来
await queryInterface.sequelize.query(
`UPDATE users SET type = 'normal' WHERE type = '' AND is_vip = 0`,
);
}把 结构变更 和 数据迁移 放在同一个迁移,能保证它们要么一起成功、要么一起回滚 ,不会出现 结构改了数据没跟上 的中间态。
六、几件能让你不心虚的 验证闭环
迁移搞一次,心里要不要有底,取决于你有没有验证过这三件事:
- 能不能跑 :
db:migrate完整执行成功; - 能不能回滚 :
db:migrate:undo回滚刚好又变回原样(up与down是对称的); - 数据在不在 :尤其是遗留数据结构,跑完确认数据没丢。
做一个 先 up 再 down 再 up 的来回验证,就能把迁移当成了可回放的了一次测试。很多半吊子的迁移 down 是空的,那回滚就等同没写,只进不能退,不是好面团。
七、生产环境的大铁律:禁止自动同步、必须走迁移
很多框架在开发期会开一个 自动改表 (如 sync: { alter: true } ) - 图方便,本地模型一动表就跟着改。这只有开发期可用。 上生产必须关掉自动同步,一律走迁移。
原因:自动改动不可控、不可回滚、不记录,生产上一旦改了结构出了问题,你连 改了什么 都说不清。而迁移可追溯、可回滚、可 review(每个文件都是一次 codereview 的对象)。
所以团队惯例通常是这么分工的:
| 环境 | 怎么改表 |
|---|---|
| 本地开发 | 可开自动同步,图快(alter: true) |
| 测试/生产 | 必须写迁移文件,走 db:migrate |
八、写迁移文件时的自查清单
- [ ] 文件名带时间戳前缀(
YYYYMMDDHHMMSS-description.js); - [ ] 同时写了
up和down; - [ ] 不修改任何 已应用过 的迁移文件;
- [ ] 结构变更 + 存量数据迁移尽量放同一个迁移文件(一荣俱荣、一损俱损);
- [ ] 关键的
ALTER涉及大数据量表时,考虑分批/锁评估; - [ ] 写完至少跑一次 up → down → up 验证可回放;
- [ ] 生产禁止自动同步,必走迁移。
九、小结
数据库迁移,本质是 给结构变化也做版本管理 。它让你能安全地演进库,能兜底回滚,能让每个结构变更都可以被 review、被重放。
up/down对称、只增不改(已跑的别动)、结构与数据同迁移;- 开发快靠自动,生产稳靠迁移;
- 用 先 up / 再 down 来验证一个迁移的可靠性。
下一篇,我们回到 ORM 本身 - 讲讲关联查询里那些 看着会、一写就掉坑 的姿势,包括外键的取舍、关联的 prefix、以及 急切加载(Eager Loading) 对性能的影响。






