数据库迁移管理实战
改数据库 schema 是高风险操作——线上数据不能丢、不能停。本文讲清迁移工具(Prisma migrate/Flyway/Liquibase)、向前/向后兼容的迁移策略、零停机迁移(扩展→迁移→收缩)、回滚,以及大表改 schema 的坑。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
数据库迁移管理实战
线上跑着的数据库,改一个字段类型,可能让整个系统停摆半小时。这不是危言耸听——我见过一次 ALTER TABLE 把核心订单表锁死,直接导致支付超时雪崩。数据库迁移的本质风险在于:它是在「运行中的系统」上动手术,而且数据是不可再生的。
本文聊聊如何在生产环境安全地管理 schema 变更。
一、schema 变更到底难在哪
把代码部署想成换轮胎,数据库迁移就是给飞行中的飞机换引擎。四个痛点几乎无法回避:
- 数据不能丢:删错一列,用户数据没了;备份恢复可能丢几分钟事务。
- 不能停机:很多业务没有「维护窗口」,99.9% 的可用性意味着一年只能停 8.7 小时。
- 兼容性:新代码 + 旧 schema、旧代码 + 新 schema,任何一个组合都可能炸。
- 回滚难:代码回滚是 git revert,数据回滚是「把已经删掉的行变回来」——不可能。
因此成熟的团队都把 schema 变更当作独立的一等公民来管理:用迁移工具、写版本化脚本、建立纪律。
二、迁移工具:把 schema 变更代码化
主流的关系型迁移工具,核心模型都一样:版本化 SQL 文件 + 按序执行 + 状态表记录进度。
Prisma migrate
Node.js/TypeScript 生态首选。你在 schema.prisma 里声明式描述 schema,工具自动算出 diff 生成迁移文件:
npx prisma migrate dev --name add_user_avatar
npx prisma migrate deploy # 生产环境按序执行
它会在数据库建一张 _prisma_migrations 表,记录哪些迁移已经应用过。好处是 schema as code,坏处是复杂变更(如自定义索引选项)需要手写 migration.sql。
Flyway
Java/JVM 生态的事实标准,简单到极致。迁移文件命名 V1__create_users.sql、V2__add_email_index.sql,版本号即顺序:
-- V3__add_user_status.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
CREATE INDEX CONCURRENTLY idx_users_status ON users(status);
Flyway 维护 flyway_schema_history 表,记录每个版本的 checksum,改动过的迁移文件会直接报错——这强制你「只增量向前」。
Liquibase
更重、更企业级。用 XML/YAML/JSON 描述变更,抽象掉方言差异(同一份变更能跑在 Postgres、MySQL、Oracle 上):
<changeSet id="add-user-status" author="alice">
<addColumn tableName="users">
<column name="status" type="varchar(20)" defaultValue="active"/>
</addColumn>
</changeSet>
适合跨数据库产品、需要审计和 rollback 标签的企业场景。小团队会觉得啰嗦。
选型原则:跟你语言生态走。TS 用 Prisma,Java 用 Flyway,多数据库大企业用 Liquibase。别追求「最优」,迁移工具的核心价值是纪律,不是花活。
三、迁移文件的铁律
不管用哪个工具,以下原则通用:
- 每次变更一个文件:不要把「加列 + 改类型 + 删表」塞进一个迁移,失败时无法定位、无法回滚。
- 只增量向前:已经合并到主干的迁移文件,哪怕写错了也绝不修改,而是再写一个新迁移修正。改 checksum 会让所有环境状态错乱。
- 进版本控制:迁移文件和代码同仓库、同 PR、同 review。schema 漂移的根本原因就是脚本散落在各种 wiki 和本地。
- 可重入友好:DDL 尽量写成幂等(配合
IF NOT EXISTS),重建本地库时不慌。
四、向前兼容:迁移安全的命门
真正高手的做法,是把「schema 变更」和「代码变更」解耦。核心思想:先让代码同时兼容新旧 schema,再分步迁移。
以「删除 username 列」为例,新手做法是一个迁移直接 DROP COLUMN,但此时旧版本代码还在跑,查询立刻报错。正确做法分两次发布:
第一次发布(代码 + 迁移 A):
- 迁移:啥也不删,先确保所有读路径不再依赖
username。 - 代码:移除所有对
username的引用。
第二次发布(迁移 B,代码已稳定运行几天):
- 迁移:
ALTER TABLE users DROP COLUMN username;
加列也一样,不要立刻 NOT NULL:先加成可空或带默认值的列,等数据回填完、读路径都正常,再加约束。改字段类型更是要双写过渡(后面细讲)。
这种「两步发布」是 schema 安全的金科玉律。配合 feature flag,你可以在出问题时关掉新代码路径,完全不需要回滚数据库。
五、零停机迁移:扩展 → 迁移数据 → 切换 → 收缩
对于大表或关键变更(比如把 email 拆成独立表,或把整型 ID 换成 UUID),前面的两步法不够,需要更正式的零停机模式,业界叫 Expand-Contract(扩展→收缩),中间夹着数据迁移:
1. 扩展(Expand):新增结构,不删旧结构。
-- 迁移 1:加新列,与旧列并存
ALTER TABLE users ADD COLUMN email_v2 TEXT;
2. 迁移数据(Migrate):双写 + 回填。
-- 代码层:所有写路径同时写 email 和 email_v2
-- 回填历史数据(分批,避免长事务)
UPDATE users SET email_v2 = email WHERE email_v2 IS NULL AND id BETWEEN 0 AND 100000;
-- 每次一万行,循环直到完成
回填一定要分批,否则一个长事务会锁住整张表、撑爆 WAL。
3. 切换(Switch):代码读路径切到新列,观察一周。 通过 feature flag 灰度切换,出问题立即回退到读旧列——这就是为什么旧结构此刻不能删。
4. 收缩(Contract):稳定后删除旧结构。
-- 迁移 2:确认无回滚需求后
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users RENAME COLUMN email_v2 TO email;
这套模式的核心是:删除是最后一步,且不可逆。前面所有阶段都可以回滚,只有最后一步一旦做了,就回不去了——所以收缩前请确保新代码已经稳定跑了足够长时间。
六、回滚:down 迁移不是万能药
很多工具支持 down 迁移(反向脚本),例如 Rails 的 db:rollback、Liquibase 的 rollback 标签:
-- down: 删掉 up 加的列
ALTER TABLE users DROP COLUMN avatar_url;
听起来很美好,但实战中 down 迁移经常是幻觉:
- 数据迁移不可逆:
up里把full_name拆成first_name+last_name,down 怎么合回去?信息可能已经丢失(比如大小写、空格)。 - 删列后无数据:
up删了一列,down 加回来是空的。 - 不可逆 DDL:
DROP TABLE之后,down 也救不了你。
因此,生产环境我从不依赖 down 迁移兜底。更可靠的回滚策略是:
- 向前兼容 + feature flag:代码回滚即可,数据库不动。
- 迁移前备份:对要改的表做
pg_dump,或建一张_backup_yyyymmdd影子表。 - 可逆迁移优先设计:加列永远可逆(只需 down 时 DROP),删列不可逆——所以删列永远走 Expand-Contract。
七、大表改 schema 的三个深坑
生产环境的表动辄千万、亿级行,DDL 不是想跑就跑。
坑 1:加索引必须 CONCURRENTLY
普通 CREATE INDEX 会锁表写入。PostgreSQL 提供 CONCURRENTLY 选项:
-- 错误:阻塞所有写
CREATE INDEX idx_users_email ON users(email);
-- 正确:不锁写,耗时更长但业务无感
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
注意 CONCURRENTLY 不能在事务里跑,且失败后会留下 INVALID 索引,需要手动清理。
坑 2:改列类型要分步
把 VARCHAR(50) 改成 VARCHAR(100) 多数数据库是元数据操作,秒级完成。但反过来(缩短)或换类型(如 varchar → int)会重写整张表,锁到天荒地老。分步做法:
-- 1. 加新列
ALTER TABLE users ADD COLUMN phone_e164 TEXT;
-- 2. 双写 + 回填(转换格式)
UPDATE users SET phone_e164 = normalize(phone) WHERE id BETWEEN ? AND ?;
-- 3. 切读路径
-- 4. 删旧列
坑 3:在线 DDL 还是会触发锁升级
MySQL 的 ADD COLUMN 在 8.0 之后支持 INSTANT 算法,但加在中间位置、改主键、加全文索引仍会走 INPLACE 甚至 COPY,锁表时间不可控。上线前必在 staging 用等量数据压测,看 SHOW PROCESSLIST 里有没有 Waiting for table metadata lock。
八、生产迁移的纪律清单
把以上套路收敛成可落地的纪律:
- 先备份:迁移前对目标库做完整
pg_dump/快照,这是最后一道防线。 - 低峰期执行:DDL 放在业务低谷(凌晨、周末),降低锁冲突影响面。
- 灰度发布:代码 + 迁移分阶段,先预发、再 10%、再全量,每阶段盯监控。
- 监控指标:数据库连接数、慢查询、锁等待、复制延迟、应用错误率——任何一项异常立刻停止推进。
- 写 Runbook:每次迁移有书面步骤,包括「预期耗时」「回滚动作」「负责人」。出事时人脑是靠不住的。
我个人的一个小习惯:任何超过「加可空列」级别的迁移,都在 staging 用生产数据的脱敏副本跑一遍,记录耗时,再决定是否在生产低峰期执行。
小结
- 迁移工具的核心价值是纪律,不是语法糖:版本化、按序执行、状态记录,选你语言生态的工具就好。
- 向前兼容 + 两步发布是 schema 安全的命门:代码先兼容新旧 schema,再分步迁移,配合 feature flag 让回滚变成代码动作而非数据动作。
- 零停机大变更走 Expand-Migrate-Switch-Contract:删除永远放最后,前面所有阶段都能回滚。
- down 迁移是幻觉:数据迁移本质上不可逆,真正的兜底是兼容设计 + 备份。
- 大表 DDL 必踩坑:加索引用
CONCURRENTLY,改类型走双写,缩短/换类型等于重写表,生产前必在等量数据上压测。
改代码出事是故障,改数据库出事是灾难。把每一次 schema 变更当成「最后一次机会」来对待——可逆性是设计出来的,不是救回来的。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。