《Spring Boot 入门》15.2 版本化迁移脚本

本节讲透 Flyway 脚本本身:V{版本}__{描述}.sql 命名规范里那个常被写错的双下划线、整数与时间戳两类版本号怎么选、版本化迁移与 R__ 可重复迁移的适用场景,以及「脚本一旦执行就不能改」背后的 checksum 校验机制与真实报错。针对社区版没有 undo 的现实,给出前滚修复与备份的应对方式,并完整演示 V1 建表、V2 加字段、R__ 建视图。

本节目标:掌握 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 开始,新脚本才会真正执行。

存量库接入的完整步骤:

  1. 先用 mysqldump / pg_dump 或 DDL 导出当前真实表结构。
  2. 把这份结构整理成第一个脚本,但不要命名为 V1(否则会和基线冲突),或干脆让它成为基线本身。
  3. 配置 baseline-on-migrate: true 与合适的 baseline-version。
  4. 启动应用,确认日志显示 Successfully baselined schema,且没有尝试重建已有表。
  5. 之后所有结构变更,从基线版本之后的新 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」为例:

  1. V5:新增 name 列,允许为空。
  2. 应用改为同时写 title 和 name(双写)。
  3. V6:写一段数据迁移,把 title 的值批量刷进 name。
  4. 应用改为只读 name。
  5. 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 多环境数据管理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计