本节目标:掌握 Flyway 脚本的命名与版本规则,分清版本化迁移和可重复迁移,理解脚本不可修改的原因,并在存量库与无
undo的前提下制定可行的变更策略。
适用版本:Spring Boot 4.1.x(Java 21)
15.2 版本化迁移脚本
15.1 里,图书服务用 V1__create_book_table.sql 建好了 book 表,历史表里也留下了第一条记录。但「会写一个脚本」和「能维护一套迁移」是两回事。这一节要回答的是脚本层面的所有规则:文件叫什么名字、版本号怎么排、什么时候该用 V 什么时候该用 R、为什么改一个标点都会让应用启动失败、存量库怎么接入、以及出错了怎么退回去。
15.2.1 命名规范:V{版本}__{描述}.sql
Flyway 靠文件名决定一个脚本何时执行、执行几次。文件名由四段拼成:
V 1 __ create_book_table .sql
│ │ │ │ └── 后缀(默认 .sql)
│ │ │ └── 描述(自由文本,人类可读)
│ │ └── 分隔符:两个下划线
│ └── 版本号
└── 前缀:V = 版本化迁移
最常犯的错误就藏在分隔符是两个下划线这一点上。下面三个文件名,只有一个能被正确解析:
| 文件名 | 结果 | 说明 |
|---|---|---|
V1__create_book_table.sql | ✅ | 版本 1,描述 create book table |
V1_create_book_table.sql | ❌ | 只有一个下划线,解析不出版本与描述的分界 |
V1__create book table.sql | ⚠️ | 文件名含空格,跨平台脚本里易出问题 |
V1__创建图书表.sql | ⚠️ | 能用,但日志与 script 列会是非 ASCII,建议英文 |
v1__create_book_table.sql | ❌ | 前缀必须大写 V,小写不识别 |
记住这个心智模型:__ 是「版本号」和「描述」之间的唯一分界,前一个下划线属于版本号的一部分,后一个属于分隔符。所以 V1_1__add_stock.sql 里的 1_1 是版本号(1.1),__ 才是分界,add_stock 是描述。
三个组成部分都可以通过配置改掉,但没必要——除非团队已有既定规范:
spring:
flyway:
sql-migration-prefix: V
sql-migration-separator: __
sql-migration-suffixes: .sql
15.2.2 版本号规则与选型
版本号决定了脚本的执行顺序,Flyway 会按版本的自然顺序排序,而不是文件名的字典序。它支持两类写法:
整数递增版本,最简单,适合大多数项目:
V1__create_book_table.sql
V2__add_stock_to_book.sql
V3__create_borrow_record_table.sql
带小版本的分段版本,用点或下划线分隔,适合「同一批需求拆成多步」:
V2__add_stock_to_book.sql
V2_1__backfill_stock_for_existing_books.sql
V3__create_borrow_record_table.sql
注意 V2_1 与 V2.1 是等价的,排序时都排在 V2 之后、V3 之前。
时间戳版本(如 V20261006103000__...)常见于多人协作、分支并行开发:用脚本生成时刻做版本号,天然避免两人同时挑中 V4 的冲突。代价是版本号很长、可读性差,且不同分支合并后顺序可能与预期不符。
| 版本方案 | 优点 | 缺点 | 适合 |
|---|---|---|---|
整数 V1 V2 V3 | 短、直观、易读 | 并行开发易撞号 | 单人 / 小团队、串行开发 |
分段 V2_1 V2_2 | 能表达「同批多步」 | 编号规则要团队约定 | 迭代中一个需求拆多脚本 |
时间戳 V20261006... | 并行不冲突 | 长、难读、顺序依赖机器时钟 | 多人多分支并行 |
无论用哪种,有两条铁律:已执行的版本号不能被别的脚本复用;新脚本的版本号必须大于历史表里的最大版本(除非开启 out-of-order,不推荐)。
15.2.3 版本化迁移 vs 可重复迁移
除了 V,Flyway 还有第二种脚本:可重复迁移(repeatable migration),前缀是 R,文件名不带版本号:
R__book_summary_view.sql
两者的差别是本质性的:
| 维度 | 版本化迁移 V | 可重复迁移 R |
|---|---|---|
| 文件名 | 必须带版本号 | 不带版本号 |
| 执行次数 | 每个版本只执行一次 | 每次启动都检查,内容变了就重跑 |
| 历史表记录 | 每个版本一行,version 有值 | 只保留一行,version 为 NULL |
| 触发条件 | 版本高于当前 | 脚本的 checksum 变了 |
| 执行时机 | 按版本顺序 | 永远在所有 V 之后执行 |
| 典型用途 | 建表、加列、改索引等结构变更 | 视图、存储过程、函数、字典/种子数据 |
判断标准可以简化成一句:「重复执行这段 SQL 会不会出错、且结果是否幂等」。视图、存储过程、CREATE OR REPLACE 语句天然幂等,每次覆盖即可,用 R;而 CREATE TABLE 重复执行会报错,只能执行一次,用 V。
给图书服务加一个「有库存的图书」视图:
-- src/main/resources/db/migration/R__book_summary_view.sql
CREATE OR REPLACE VIEW book_summary AS
SELECT b.id,
b.isbn,
b.title,
b.author,
b.stock
FROM book b
WHERE b.stock > 0;
以后想改视图定义,直接编辑这个文件——因为它是 R,Flyway 发现 checksum 变化后会自动重跑(CREATE OR REPLACE 保证幂等)。这是 R 相对于 V 最实用的地方:视图定义可以随代码一起演进,而不必每次新建一个 V 脚本去 DROP VIEW 再 CREATE。
反过来,R__ 也常被拿来维护种子数据(如字典表、默认分类),但要格外小心:R 的每一次重跑都会执行整段 SQL,所以脚本必须写成「幂等」的形式,例如 MERGE 或先 DELETE 再 INSERT,绝不能写裸 INSERT,否则每次启动都插一份重复数据。
15.2.4 迁移脚本不可修改原则
Flyway 的核心约束是:一个 V 脚本一旦被执行过,就再也不能改内容。 这不是建议,是机制强制。
15.1 提过,历史表里为每个脚本存了一列 checksum——它是脚本内容的哈希。每次启动,Flyway 会重新计算本地脚本的 checksum,与历史表里记录的比对。一旦发现不一致,直接让应用启动失败。
假设有人把已执行的 V1__create_book_table.sql 里 title VARCHAR(200) 改成了 VARCHAR(255),下次启动会看到这样的报错:
2026-10-06T10:00:03.640+08:00 INFO 54321 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 2 migrations (execution time 00:00.013s)
2026-10-06T10:00:03.642+08:00 ERROR 54321 --- [ main] o.f.core.internal.command.DbValidate : Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : 1374021955
-> Resolved locally : -1683543012
Either revert the changes to the migration, or run repair to update the schema history.
Spring Boot 会把它包成启动异常,应用起不来:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'flywayInitializer'
Caused by: org.flywaydb.core.api.FlywayException: Validate failed: Migrations have failed validation
正确的处理方式取决于场景:
| 场景 | 正确做法 |
|---|---|
| 生产 / 已上线环境 | 绝不改旧脚本。新增一个 V{n}__fix_xxx.sql 做前滚修正 |
| 本地开发、脚本还没推给别人 | 改完内容后执行 flyway repair,或干脆重建内存库 |
| 想改列类型 | 新建 V 脚本写 ALTER TABLE ...,而不是回头改 V1 |
flyway repair 做的事是:重算历史表里的 checksum,让它与当前脚本一致。它不会帮你把已经落库的结构改回去,只修正「账本」。所以在生产上用它等于自欺欺人——表里还是旧结构,账本却假装是新脚本。
这里能提炼出一条可操作的纪律:把迁移脚本当 append-only 的日志。只能往后追加,不能修改历史。代码可以重构、可以回滚提交,但迁移脚本不行。
15.2.5 baseline 与 baselineOnMigrate:存量库怎么接入
前面所有讨论都假设数据库是「空的」。现实中更常见的是:一个已经跑了几年、有数据的库,现在才想引入 Flyway。
问题在于,如果直接把 V1__create_book_table.sql 指向这个已有 book 表的库,Flyway 会尝试执行它,然后报 Table "BOOK" already exists。而如果历史表是空的,Flyway 又认为「什么都没执行过」,会把所有脚本从头跑一遍。
解决办法是打基线(baseline):告诉 Flyway「把这个库的当前状态视作某个起始版本,之前的脚本不要再跑了」。
spring:
flyway:
baseline-on-migrate: true
baseline-version: 1
baseline-on-migrate: true 表示:当检测到非空库且历史表不存在时,自动建历史表并写入一条 type = BASELINE 的基线记录。baseline-version 指定基线版本号,默认 1。
关键的语义要记牢:版本号小于等于基线的脚本会被跳过。所以如果基线设成 1,那么 V1 不会执行——这通常正是我们要的,因为存量库里 book 表早就存在了。后续从 V2 开始,新脚本才会真正执行。
存量库接入的完整步骤:
- 先用
mysqldump/pg_dump或 DDL 导出当前真实表结构。 - 把这份结构整理成第一个脚本,但不要命名为
V1(否则会和基线冲突),或干脆让它成为基线本身。 - 配置
baseline-on-migrate: true与合适的baseline-version。 - 启动应用,确认日志显示
Successfully baselined schema,且没有尝试重建已有表。 - 之后所有结构变更,从基线版本之后的新
V脚本开始。
一个容易忽略的坑:baseline-version 必须小于你接下来要执行的第一个脚本版本。如果把基线设成 5,而现有脚本只到 V4,那它们全被跳过,Flyway 认为库已是最新——新表根本没建。
15.2.6 回滚策略:社区版没有 undo
很多人对迁移工具的第一反应是「那出错了怎么回滚」。这里必须说清楚:Flyway 社区版没有 undo。flyway undo 是 Teams / Enterprise 版才有的命令,开源版不提供。
所以「回滚」在 Flyway 语境里要换一种思路——以「前滚」为主,辅以备份与可控的变更模式:
| 策略 | 做法 | 适用 |
|---|---|---|
| 前滚修复 | 发现错误后,新写一个 V 脚本把结构改回期望状态 | 默认方案,最常用 |
| 数据库备份 | 变更前做全量备份,出错时整体恢复 | 高风险的大改动 |
| 扩展-收缩(expand-contract) | 先加新列、双写、迁移数据,再删旧列,分多版本完成 | 零停机、涉及数据搬迁 |
| 补偿脚本 | 为一个破坏性 V 配一个逻辑相反的新 V | 需要精确回退某步时 |
前滚修复是日常最该掌握的。比如某次上线给 book 加了个 discount 列,上线后发现不该加,正确做法不是删掉那个 V 脚本(会触发 checksum 失败),而是追加:
-- V4__drop_book_discount_column.sql
ALTER TABLE book DROP COLUMN discount;
V3__add_book_discount_column.sql 留在历史里,V4 把它撤销——数据库的最终状态正确,历史也完整可追溯。这就是「前滚」。
扩展-收缩值得单独说,因为它是生产上做破坏性变更(改名、改类型)的标准姿势。以「title 改名为 name」为例:
V5:新增name列,允许为空。- 应用改为同时写
title和name(双写)。 V6:写一段数据迁移,把title的值批量刷进name。- 应用改为只读
name。 V7:删除title列。
整个过程每一步都可独立回退,且不需要停服。代价是要多写几个版本、应用代码要经历双写阶段。对于图书服务这种体量,book 表不算大,但方法值得记住。
需要强调的是:任何回滚方案的前提都是备份。迁移脚本管的是「结构」,它不负责「数据」。删列、改类型这类操作一旦执行,数据可能已经不可逆地丢失,只有备份能救回来。
15.2.7 一次完整演示:V1 建表 + V2 加字段 + R__ 视图
把前面所有规则串成一次真实演进。图书服务初始只有 V1:
-- src/main/resources/db/migration/V1__create_book_table.sql
CREATE TABLE book (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
isbn VARCHAR(20) NOT NULL,
title VARCHAR(200) NOT NULL,
author VARCHAR(120) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_book_isbn UNIQUE (isbn)
);
需求来了:图书要记录库存。按规矩,不改 V1,新增 V2:
-- src/main/resources/db/migration/V2__add_stock_to_book.sql
ALTER TABLE book ADD COLUMN stock INT NOT NULL DEFAULT 0;
ALTER TABLE book ADD CONSTRAINT ck_book_stock_non_negative CHECK (stock >= 0);
接着要一个「在售图书」视图。视图天然幂等,用 R:
-- src/main/resources/db/migration/R__book_summary_view.sql
CREATE OR REPLACE VIEW book_summary AS
SELECT b.id, b.isbn, b.title, b.author, b.stock
FROM book b
WHERE b.stock > 0;
启动应用,日志会显示这次真正执行了两个版本化迁移和一个可重复迁移:
2026-10-06T10:00:03.640+08:00 INFO 54321 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 3 migrations (execution time 00:00.013s)
2026-10-06T10:00:03.702+08:00 INFO 54321 --- [ main] o.f.core.internal.command.DbMigrate : Current version of schema "PUBLIC": 1
2026-10-06T10:00:03.705+08:00 INFO 54321 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" to version "2 - add stock to book"
2026-10-06T10:00:03.728+08:00 INFO 54321 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" with repeatable migration "book summary view"
2026-10-06T10:00:03.744+08:00 INFO 54321 --- [ main] o.f.core.internal.command.DbMigrate : Successfully applied 2 migrations to schema "PUBLIC", now at version v2 (execution time 00:00.041s)
注意最后一行说的是「applied 2 migrations」——V2 与 R__ 各算一次。查历史表:
SELECT installed_rank, version, description, type, script, success
FROM flyway_schema_history
ORDER BY installed_rank;
INSTALLED_RANK | VERSION | DESCRIPTION | TYPE | SCRIPT | SUCCESS
1 | 1 | create book table | SQL | V1__create_book_table.sql | TRUE
2 | 2 | add stock to book | SQL | V2__add_stock_to_book.sql | TRUE
3 | | book summary view | SQL | R__book_summary_view.sql | TRUE
第三行的 VERSION 是空的——这就是可重复迁移在历史表里的特征:它只有一行,version 为 NULL,内容变了才会更新。版本化迁移则是每个版本一行,永久保留。
以后再改视图,只要编辑 R__book_summary_view.sql,下次启动日志里会出现 Migrating schema "PUBLIC" with repeatable migration "book summary view",而 V1、V2 依旧一动不动。
15.2.8 常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 脚本完全没被执行 | 用了单下划线 V1_xxx.sql | 改成双下划线 V1__xxx.sql |
| 启动报 checksum mismatch | 改了已执行的 V 脚本 | 恢复旧内容并新增 V 前滚,或本地 repair |
| 存量库启动报表已存在 | 没打基线,Flyway 重跑 V1 | 配置 baseline-on-migrate: true |
| 基线设了却仍不执行新脚本 | baseline-version 大于脚本版本 | 让基线小于第一个待执行版本 |
| 视图每次启动都重跑 | 用了 R__ 且内容有变化 | 正常行为;若不想重跑就改成 V |
| 种子数据重复插入 | R__ 里写了裸 INSERT | 改成幂等写法(MERGE / 先删后插) |
小结
Flyway 脚本分两类:V{版本}__{描述}.sql 每个版本只跑一次,适合结构变更;R__{描述}.sql 不带版本、checksum 变了就重跑,适合视图与幂等种子数据。命名里那个双下划线是高频错误点。已执行的 V 脚本不能改——checksum 会让启动直接失败,正确做法是追加新脚本前滚。存量库靠 baseline-on-migrate 接入,社区版没有 undo,回滚要靠前滚、备份与扩展-收缩模式。下一节把这些脚本按开发、测试、生产三套环境组织起来,并处理「开发数据从哪来」的问题。
阅读导航:上一节:15.1 Flyway 入门 · 下一节:15.3 多环境数据管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。