#DevOps

数据库迁移管理实战

改数据库 schema 是高风险操作——线上数据不能丢、不能停。本文讲清迁移工具(Prisma migrate/Flyway/Liquibase)、向前/向后兼容的迁移策略、零停机迁移(扩展→迁移→收缩)、回滚,以及大表改 schema 的坑。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月11日·更新于 2026年7月23日

数据库迁移管理实战

线上跑着的数据库,改一个字段类型,可能让整个系统停摆半小时。这不是危言耸听——我见过一次 ALTER TABLE 把核心订单表锁死,直接导致支付超时雪崩。数据库迁移的本质风险在于:它是在「运行中的系统」上动手术,而且数据是不可再生的。

本文聊聊如何在生产环境安全地管理 schema 变更。

一、schema 变更到底难在哪

把代码部署想成换轮胎,数据库迁移就是给飞行中的飞机换引擎。四个痛点几乎无法回避:

  1. 数据不能丢:删错一列,用户数据没了;备份恢复可能丢几分钟事务。
  2. 不能停机:很多业务没有「维护窗口」,99.9% 的可用性意味着一年只能停 8.7 小时。
  3. 兼容性:新代码 + 旧 schema、旧代码 + 新 schema,任何一个组合都可能炸。
  4. 回滚难:代码回滚是 git revert,数据回滚是「把已经删掉的行变回来」——不可能。

因此成熟的团队都把 schema 变更当作独立的一等公民来管理:用迁移工具、写版本化脚本、建立纪律。

二、迁移工具:把 schema 变更代码化

主流的关系型迁移工具,核心模型都一样:版本化 SQL 文件 + 按序执行 + 状态表记录进度。

Prisma migrate

Node.js/TypeScript 生态首选。你在 schema.prisma 里声明式描述 schema,工具自动算出 diff 生成迁移文件:

bash
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,版本号即顺序:

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 上):

xml
<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。别追求「最优」,迁移工具的核心价值是纪律,不是花活。

三、迁移文件的铁律

不管用哪个工具,以下原则通用:

  1. 每次变更一个文件:不要把「加列 + 改类型 + 删表」塞进一个迁移,失败时无法定位、无法回滚。
  2. 只增量向前:已经合并到主干的迁移文件,哪怕写错了也绝不修改,而是再写一个新迁移修正。改 checksum 会让所有环境状态错乱。
  3. 进版本控制:迁移文件和代码同仓库、同 PR、同 review。schema 漂移的根本原因就是脚本散落在各种 wiki 和本地。
  4. 可重入友好: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):新增结构,不删旧结构。

sql
-- 迁移 1:加新列,与旧列并存
ALTER TABLE users ADD COLUMN email_v2 TEXT;

2. 迁移数据(Migrate):双写 + 回填。

sql
-- 代码层:所有写路径同时写 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):稳定后删除旧结构。

sql
-- 迁移 2:确认无回滚需求后
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users RENAME COLUMN email_v2 TO email;

这套模式的核心是:删除是最后一步,且不可逆。前面所有阶段都可以回滚,只有最后一步一旦做了,就回不去了——所以收缩前请确保新代码已经稳定跑了足够长时间。

六、回滚:down 迁移不是万能药

很多工具支持 down 迁移(反向脚本),例如 Rails 的 db:rollback、Liquibase 的 rollback 标签:

sql
-- 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 迁移兜底。更可靠的回滚策略是:

  1. 向前兼容 + feature flag:代码回滚即可,数据库不动。
  2. 迁移前备份:对要改的表做 pg_dump,或建一张 _backup_yyyymmdd 影子表。
  3. 可逆迁移优先设计:加列永远可逆(只需 down 时 DROP),删列不可逆——所以删列永远走 Expand-Contract。

七、大表改 schema 的三个深坑

生产环境的表动辄千万、亿级行,DDL 不是想跑就跑。

坑 1:加索引必须 CONCURRENTLY

普通 CREATE INDEX 会锁表写入。PostgreSQL 提供 CONCURRENTLY 选项:

sql
-- 错误:阻塞所有写
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)会重写整张表,锁到天荒地老。分步做法:

sql
-- 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。

八、生产迁移的纪律清单

把以上套路收敛成可落地的纪律:

  1. 先备份:迁移前对目标库做完整 pg_dump/快照,这是最后一道防线。
  2. 低峰期执行:DDL 放在业务低谷(凌晨、周末),降低锁冲突影响面。
  3. 灰度发布:代码 + 迁移分阶段,先预发、再 10%、再全量,每阶段盯监控。
  4. 监控指标:数据库连接数、慢查询、锁等待、复制延迟、应用错误率——任何一项异常立刻停止推进。
  5. 写 Runbook:每次迁移有书面步骤,包括「预期耗时」「回滚动作」「负责人」。出事时人脑是靠不住的。

我个人的一个小习惯:任何超过「加可空列」级别的迁移,都在 staging 用生产数据的脱敏副本跑一遍,记录耗时,再决定是否在生产低峰期执行。

小结

  • 迁移工具的核心价值是纪律,不是语法糖:版本化、按序执行、状态记录,选你语言生态的工具就好。
  • 向前兼容 + 两步发布是 schema 安全的命门:代码先兼容新旧 schema,再分步迁移,配合 feature flag 让回滚变成代码动作而非数据动作。
  • 零停机大变更走 Expand-Migrate-Switch-Contract:删除永远放最后,前面所有阶段都能回滚。
  • down 迁移是幻觉:数据迁移本质上不可逆,真正的兜底是兼容设计 + 备份。
  • 大表 DDL 必踩坑:加索引用 CONCURRENTLY,改类型走双写,缩短/换类型等于重写表,生产前必在等量数据上压测。

改代码出事是故障,改数据库出事是灾难。把每一次 schema 变更当成「最后一次机会」来对待——可逆性是设计出来的,不是救回来的。

Related

相关推荐