结构变更真正危险的地方不是 DDL 写错——那跑一次就报错。危险的是没人知道某个字段是谁、在什么时候、因为什么加的。等到三套环境(本地、测试、生产)的表结构出现微妙差异,加一个 NOT NULL 列就能让上线卡住一整晚。
Flyway 把"改表"这件事变成一组按版本号排序、带校验和、有执行记录的 SQL 文件。它不做 ORM,也不做 DDL 生成,只保证一件事:同一个版本号,在任何环境上跑出同一个结果。
1. 没有迁移工具时会发生什么
| 做法 | 短期 | 长期 |
|---|---|---|
| 手工在客户端执行 DDL | 快 | 无记录,环境间漂移 |
代码里 CREATE TABLE IF NOT EXISTS |
自动 | 只能加不能改,改列类型无处安放 |
JPA ddl-auto=update |
省事 | 不删列不改类型,且不可审查 |
一份 schema.sql 全量重建 |
干净 | 生产不能重建,历史数据没法走 |
| 迁移脚本 + 版本表 | 有成本 | 可审计、可重放、可回滚到某个版本点 |
2. 命名规则:两个下划线是硬约定
| 形式 | 含义 | 执行时机 |
|---|---|---|
V1__init.sql |
版本化迁移,版本 1 | 未执行过就按序执行 |
V1_1__add_tag.sql |
版本 1.1,下划线即小数点 | 同上,排在 1 之后 |
V20261109_1200__add_index.sql |
时间戳版本 | 同上,团队协作首选 |
R__refresh_view.sql |
可重复迁移,无版本号 | checksum 变化时重跑 |
U1__undo_init.sql |
回滚脚本 | 仅 Teams 版支持,社区版无效 |
V 和描述之间必须是两个下划线 __。写成 V1_add_tag.sql 会被忽略——不是报错,是静默跳过。这是新手第一个坑,因为 Flyway 启动日志里根本不会提这个文件。
3. flyway_schema_history 表
Flyway 第一次运行会在目标 schema 建这张表,之后每次迁移都往里插一行:
| 列 | 含义 | 排查时的用途 |
|---|---|---|
installed_rank |
执行顺序号(自增) | 判断实际执行次序,而非版本号次序 |
version |
脚本版本 | 与 V 前缀对应 |
description |
描述 | 人读 |
type |
SQL / JDBC / BASELINE |
认出基线那行 |
script |
文件名 | 定位文件 |
checksum |
文件内容的 CRC32 | validate 失败时的证据 |
installed_by |
执行者 | 追责与审计 |
installed_on |
执行时间 | 与环境发布时间对照 |
execution_time |
毫秒数 | 长迁移的耗时记录 |
success |
是否成功 | false 的行会阻塞后续迁移 |
-- 查看当前版本与是否有失败记录
SELECT installed_rank, version, description, success, installed_on
FROM flyway_schema_history
ORDER BY installed_rank DESC
LIMIT 10;
success=0 的行必须处理掉,否则后面每个 V 脚本都会因为"存在失败的迁移"而拒绝执行。
4. baseline:接管已有库
baseline 是为"表已经存在、但没有历史记录"的库准备的。第一次接入时有两种写法:
| 场景 | 配置 | 结果 |
|---|---|---|
| 空库,从头建 | 什么都不配 | 从 V1 开始全跑 |
| 已有库,结构等于 V1 | baseline-on-migrate=true |
写入一行 BASELINE(version=1),V1 被跳过,V2 起执行 |
| 已有库,结构领先于 V1 | baseline-version=5 |
基线定在 5,V1–V5 全部跳过 |
| 想显式控制时机 | 先跑 flyway baseline 再迁移 |
多一步,但不会误触发 |
关键理解:baseline 不创建任何表结构,它只在历史表里写一条"到这里为止算已完成"。所以 baseline-version 必须等于"现有结构实际对应的那个版本",写大了会漏掉本该执行的脚本,写小了会在已有表上重跑建表语句直接报错。
5. validate 与 checksum 冲突
validate-on-migrate 默认 true。每次启动前 Flyway 对比磁盘脚本的 CRC32 与历史表里的记录,不一致就拒绝启动:
Validate failed: Migration checksum mismatch for migration version 1.2
-> Applied to database : -1234567890
-> Resolved locally : 1234567890
处理分两类,别混:
| 情况 | 正确做法 | 错误做法 |
|---|---|---|
| 脚本被格式化工具改了空格/注释 | 还原内容;或 flyway repair 更新 checksum |
直接改历史表 |
| 真的需要改结构 | 写新的 V 脚本 |
改已执行的 V1.2 |
迁移中途失败留下 success=0 |
flyway repair 清掉失败行再重跑 |
手删一行了事(可能漏清理) |
repair 只修"记录",不重跑 SQL。它的语义是"让历史表重新对齐磁盘现状",不是"再执行一次"。
6. locations:多目录与方言隔离
spring:
flyway:
enabled: true
locations: classpath:db/migration,classpath:db/migration/mysql
encoding: UTF-8
table: flyway_schema_history
baseline-on-migrate: true
baseline-version: 0
validate-on-migrate: true
out-of-order: false
fail-on-missing-locations: true
placeholders:
app_schema: clarity
| 键 | 默认 | 说明 |
|---|---|---|
locations |
classpath:db/migration |
多目录逗号分隔,同一版本号只能出现一次 |
baseline-version |
1 |
与已有结构对齐 |
out-of-order |
false |
开 true 才允许补跑低版本 |
fail-on-missing-locations |
true |
配错路径直接启动失败,这是好事 |
placeholders.* |
无 | 脚本里用 ${app_schema} 引用 |
table |
flyway_schema_history |
多服务共用库时必须改名 |
多目录的常见用法是按方言拆:db/migration 放通用 DDL,db/migration/mysql 放 MySQL 专有语法。但不要把同一版本号在两个目录里各放一份,Flyway 会报 Found more than one migration with version。
7. 与 JPA 的冲突:必须关掉 ddl-auto
spring:
jpa:
hibernate:
ddl-auto: validate # 生产建议 validate 或 none
properties:
hibernate:
hbm2ddl:
auto: none
| ddl-auto | 与 Flyway 共存的结果 |
|---|---|
update |
两边都改表,谁先谁后不确定,update 会悄悄加列 |
create / create-drop |
启动时删表重建,迁移记录瞬间失真 |
validate |
只校验,不修改,冲突会以启动失败暴露 |
none |
完全不碰,完全交给 Flyway |
validate 的价值在于:它会在启动时比对实体映射与真实表结构,字段类型不匹配立刻报错——这正好是迁移脚本漏写时的第一道闸。
8. 团队协作:版本号冲突的预防
两个人同一天各写一个 V12__xxx.sql,后合并的人会遇到两种结果:如果对方已经执行过 V12,自己的脚本 checksum 不同 → validate 失败;如果还没执行,两个 V12 同版本 → Flyway 直接拒绝。
| 方案 | 做法 | 代价 |
|---|---|---|
| 时间戳版本号 | V202611091200__xxx.sql |
号长,但基本不撞 |
| 日期区间预分配 | 每人当天固定号段 | 需要约定纪律 |
| CI 前置校验 | 合并前跑 flyway info,检查有无重复版本 |
多一条流水线步骤 |
| 单点归口 | 迁移脚本由一个人合并 | 有瓶颈 |
CI 里最实用的一条是解析 flyway_schema_history 与本地脚本做差集,发现"待执行版本号小于当前最高版本"就失败——这能在合并前抓住 out-of-order 场景。
9. 不可回滚的迁移:forward-only
MySQL 的 DDL 是隐式提交的,一个脚本里三条 DDL 挂在第二条失败,前一条已经生效且无法回退。所以正确姿势不是写 U 脚本,而是:
- 一个脚本只做一件事,把大变更拆成多个
V; - 加列、回填、加约束分三次提交,中间版本允许空值;
- 出问题不改旧脚本,写新的
V修正; - 删列这类破坏性操作放到"确认无引用"的后续版本,先改名下线。
-- V10__add_article_status.sql 只加列,允许 NULL
ALTER TABLE article ADD COLUMN status TINYINT NULL;
-- V11__backfill_article_status.sql 回填,仍不加约束
UPDATE article SET status = 1 WHERE status IS NULL;
-- V12__tighten_article_status.sql 最后收紧
ALTER TABLE article MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1;
这样每个版本的失败面都很小,回滚只需要部署上一版应用,数据库保持向前。
10. clean 为什么必须禁用
clean 会 drop 掉目标 schema 里的所有对象,包括历史表和业务数据。Flyway 9 之后默认 cleanDisabled=true,Spring Boot 也把 spring.flyway.clean-disabled 默认设为 true。
要警惕的是有人为了"本地重来一次"把它设成 false 并提交上去,而 spring.flyway.url 又是从环境变量读的——一旦某次环境变量指向了预发库,mvn flyway:clean 就是一次全库删除。
| 防线 | 做法 |
|---|---|
| 配置层 | 只在 application-dev.yaml 里关掉 clean-disabled |
| 账号层 | 生产迁移账号不给 DROP 权限 |
| 流程层 | 禁止在 CI 里出现 flyway:clean |
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
启动报 Found non-empty schema without schema history table |
已有库未配 baseline | 加 baseline-on-migrate=true 并定好版本 |
报 Detected resolved migration not applied to database |
版本低于当前最高且 out-of-order=false |
改版本号,或临时开 out-of-order |
报 Migration checksum mismatch |
已执行脚本被改 | 还原;确需改则 repair |
| 脚本完全没被执行 | 文件名缺 __ 或不在 locations |
检查命名与路径 |
Unable to obtain connection 但应用能连库 |
spring.flyway.url 与 spring.datasource.url 各写一份且不一致 |
只留 datasource,Flyway 会复用 |
| 迁移卡住很久 | 大表加索引/改列,MySQL 锁表 | 拆脚本,或用 ALGORITHM=INPLACE 评估 |
12. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 只做一次性的数据修复 | 运维脚本 + 审批 | 不该进版本历史 |
| 多租户按 schema 隔离 | 每租户一个 Flyway 实例 | 单实例只认一个 schema |
| 需要严格回滚 | 迁移 + 应用双版本滚动 + 兼容期 | 数据库回滚本身不可靠 |
| 结构由代码生成(如多租户动态表) | 运行期建表 + 独立元数据表 | Flyway 是静态脚本 |
| 分库分表后的物理表 | 初始化脚本 + 逻辑表统一迁移 | 物理表数量会爆炸 |
13. 自测
V1_add_tag.sql为什么会被静默跳过?baseline-on-migrate=true之后,V1会不会执行?为什么?repair修好 checksum 之后,那条迁移会重新跑一遍吗?- 生产为什么建议
ddl-auto=validate而不是none? - MySQL 下为什么"写一个 U 脚本回滚"不成立?
clean-disabled之外,还有哪两道防线能防住误清库?