数据库迁移
用版本化、可逆的迁移无停机演进 schema。
为什么
数据库 schema 变更是软件中最险操作 — 它改变无法重新部署消除的持久状态。坏迁移锁表 10 分钟、损坏数据或使应用无法启动。有纪律的方式是版本化、可逆迁移配 expand-contract 模式:先扩展(加新列/表、双写),再迁移回填,再收缩(停写旧、删旧)。每步独立可部署可逆,故步 3 出问题无需从备份恢复 — 只不做步 3。这是无维护窗口改 1TB 表 schema 的方 式。
何时使用
schema 必须变时。绝不手跑原始 ALTER TABLE — 总通过版本化迁移工具。
如何做
1. 生成迁移:npx knex migrate:make add_users_display_name(或 prisma migrate dev) 2. 写 UP 迁移(扩展):ALTER TABLE users ADD COLUMN display_name TEXT 3. 写 DOWN 迁移(可逆):ALTER TABLE users DROP COLUMN display_name 4. 部署双写代码(同时写旧和新)— 旧代码在新 schema 上仍工作 5. 回填:批量从现有数据填 display_name(UPDATE ... WHERE display_name IS NULL LIMIT 1000) 6. 部署从新列读且停写旧的代码 7. 收缩:最终迁移删旧列(监控期确认无读取后) 8. 每步各自部署 + 迁移;绝不一步合并扩展 + 收缩
示例
# Knex 迁移:加 display_name(扩展)
exports.up = function(knex) {
return knex.schema.alterTable('users', t => {
t.text('display_name').nullable(); // 扩展:加列
});
};
exports.down = function(knex) {
return knex.schema.alterTable('users', t => {
t.dropColumn('display_name'); // 可逆
});
};
# 回填脚本(批量、幂等、可续)
async function backfill() {
let done = 0;
while (true) {
const rows = await db.raw(`
UPDATE users SET display_name = name
WHERE display_name IS NULL
RETURNING id LIMIT 1000`);
if (rows.length === 0) break;
done += rows.length;
await sleep(100); // 节流避免锁竞争
}
}
# 收缩迁移(监控确认双读已去后)
exports.up = (knex) => knex.schema.alterTable('users', t => t.dropColumn('name'));常见问题
- ⚠一步合并扩展和收缩 — 若收缩(删列)失败无法回滚因应用已停写。
- ⚠不可逆迁移(DROP COLUMN 无备份)— down 迁移是 no-op,坏部署意味数据丢。
- ⚠一个巨型 UPDATE 回填 — 锁表数分钟;1000 行分块带 sleep。
最佳实践
- ✓总用 expand-contract:加新、双写、回填、切读、删旧 — 每步各自部署。
- ✓每迁移可逆(写真 DOWN)并在 staging 测 UP 然后 DOWN 然后 UP。
- ✓小幂等分块带节流回填 — 绝不单 UPDATE 锁全表。