使用 Flyway 在 Spring Boot 中进行数据库迁移
1. 总览
当应用程序改变时,数据库 schema 也需要随之改变。如果你在每个环境上手动运行 ALTER TABLE 语句,迟早开发、测试和生产环境的 schema 会出现差异。Flyway 解决了这个问题:每个 schema 变更都是一个有版本号的迁移文件,会自动按照正确的顺序应用。
在本文中,你将为 Spring Boot 3 应用配置 Flyway,使用 SQL 和 Java 编写迁移脚本,用 JUnit 5 进行测试,并学习如何处理常见错误,比如 checksum 不匹配。
2. 安装依赖
Spring Boot 已经管理了 Flyway 的版本,所以你不需要指定版本号。下面的示例使用 PostgreSQL:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
从 Flyway 10 开始(随 Spring Boot 3.3+ 一起),对各种数据库的支持被分为单独的模块。如果缺少 flyway-database-postgresql,应用将无法启动并报错:
org.flywaydb.core.api.FlywayException: Unsupported Database: PostgreSQL 16.4
H2 仍然包含在 flyway-core 中,所以测试端不需要添加额外的模块。
3. 基本配置
只要 flyway-core 在 classpath 上,Spring Boot 就会自动创建 Flyway bean,并在 EntityManagerFactory 初始化之前运行迁移。application.properties 中的最小配置如下:
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.locations=classpath:db/migration
spring.flyway.validate-on-migrate=true
重要的几行说明:
spring.jpa.hibernate.ddl-auto=validate:Hibernate 仅检查 entity 是否与 schema 一致。它不会自动修改 schema。Flyway 是 schema 的唯一权威来源。spring.flyway.locations:包含迁移文件的目录。默认值已经是classpath:db/migration。这里明确写出便于理解。spring.flyway.validate-on-migrate=true:迁移前,Flyway 会检查已运行迁移的 checksum。这也是默认值。
注意:不要同时使用
ddl-auto=update或create和 Flyway。Hibernate 和 Flyway 会同时修改 schema,结果很难预测。
4. 使用 SQL 编写迁移
4.1. 命名规则
Flyway 通过文件名来识别迁移脚本:
V1__create_customer_table.sql
│ │ └── 描述(下划线被理解为空格)
│ └── 两个下划线作为分隔符
└── V = 版本迁移,后面跟版本号
版本号可以是 1、2、1.1 或 20261016.1。Flyway 按数值比较版本,而不是按字母顺序。因此 V10 会在 V9 之后运行。
4.2. 第一个迁移
创建文件 src/main/resources/db/migration/V1__create_customer_table.sql:
CREATE TABLE customer (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
full_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
接下来是 V2__create_orders_table.sql:
CREATE TABLE orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer (id),
total NUMERIC(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL
);
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
应用启动时,日志会显示:
Flyway Community Edition 10.x by Redgate
Database: jdbc:postgresql://localhost:5432/shop (PostgreSQL 16.4)
Successfully validated 2 migrations (execution time 00:00.012s)
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "1 - create customer table"
Migrating schema "public" to version "2 - create orders table"
Successfully applied 2 migrations to schema "public", now at version v2
4.3. flyway_schema_history 表
Flyway 将所有已运行的迁移记录到 flyway_schema_history 表中。你可以直接查询:
SELECT installed_rank, version, description, checksum, success
FROM flyway_schema_history
ORDER BY installed_rank;
installed_rank | version | description | checksum | success
----------------+---------+-----------------------+-------------+---------
1 | 1 | create customer table | -1253721480 | t
2 | 2 | create orders table | 873416502 | t
在下一次启动时,Flyway 看到 schema 已经在 version 2,所以不会再运行任何东西。checksum 列是第 7 部分的关键。
4.4. 可重复迁移
以 R__ 开头的文件是可重复迁移。它没有版本号。每当 checksum 变化时,Flyway 会重新运行该文件,并且总是在所有版本迁移之后运行。这种类型适合用于视图、函数或存储过程:
-- R__order_summary_view.sql
CREATE OR REPLACE VIEW order_summary AS
SELECT c.id AS customer_id, c.full_name, COUNT(o.id) AS order_count
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.full_name;
5. 使用 Java 编写迁移
某些改变很难用纯 SQL 编写,例如需要字符串处理逻辑的数据回填。此时你可以用 Java 编写迁移,继承 BaseJavaMigration。
首先,在 V3__add_customer_email.sql 中用 SQL 添加列:
ALTER TABLE customer ADD COLUMN email VARCHAR(150);
然后将以下类放在 db.migration 包中,位置为 src/main/java/db/migration/V4__Backfill_customer_email.java:
package db.migration;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.SingleConnectionDataSource;
import java.text.Normalizer;
public class V4__Backfill_customer_email extends BaseJavaMigration {
@Override
public void migrate(Context context) {
var jdbc = new JdbcTemplate(
new SingleConnectionDataSource(context.getConnection(), true));
jdbc.query("SELECT id, full_name FROM customer WHERE email IS NULL", rs -> {
long id = rs.getLong("id");
String email = toSlug(rs.getString("full_name")) + "." + id + "@example.com";
jdbc.update("UPDATE customer SET email = ? WHERE id = ?", email, id);
});
}
private static String toSlug(String name) {
String noAccent = Normalizer.normalize(name, Normalizer.Form.NFD)
.replaceAll("\\p{M}", "")
.replace('đ', 'd').replace('Đ', 'D');
return noAccent.toLowerCase().trim().replaceAll("\\s+", ".");
}
}
说明:
- 类名遵循与 SQL 文件相同的规则:
V4__Backfill_customer_email。 context.getConnection()返回 Flyway 正在使用的连接,位于迁移的同一事务中。SingleConnectionDataSource的true参数阻止JdbcTemplate关闭此连接。- Flyway 扫描
db.migration包,因为它对应于classpath:db/migration。
对于 id 为 1 的客户 Nguyễn Văn An,结果是 nguyen.van.an.1@example.com。
Spring Boot 还会自动将所有 JavaMigration 类型的 bean 注册到 Flyway。因此你可以将迁移声明为 @Component。不过,你不应该向迁移中注入 repository 或 service。业务代码会随时间改变,而迁移必须永远以相同的方式运行。
6. 测试迁移
你应该至少有一个测试来确认所有迁移都能从空 schema 开始运行,并且 Hibernate 验证成功。创建 src/test/resources/application-test.properties:
spring.datasource.url=jdbc:h2:mem:shop;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
测试:
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class FlywayMigrationTest {
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void whenContextStarts_thenAllMigrationsAreApplied() {
var current = flyway.info().current();
assertThat(current).isNotNull();
assertThat(current.getVersion().getVersion()).isEqualTo("4");
assertThat(flyway.info().pending()).isEmpty();
}
@Test
void givenNewCustomer_whenInsert_thenEmailColumnExists() {
jdbcTemplate.update(
"INSERT INTO customer (full_name, email) VALUES (?, ?)",
"Trần Thị Bình", "binh@example.com");
String email = jdbcTemplate.queryForObject(
"SELECT email FROM customer WHERE full_name = ?",
String.class, "Trần Thị Bình");
assertThat(email).isEqualTo("binh@example.com");
}
}
第一个测试检查 schema 已经升到 version 4,并且没有待运行的迁移。如果一个 SQL 文件有语法错误,context 将无法启动,测试会立即失败。第二个测试确认来自 V3 的 email 列确实存在。
注意:H2 在
MODE=PostgreSQL下只能模拟 PostgreSQL 语法的一部分。如果迁移使用专有功能,比如JSONB、部分索引或CREATE INDEX CONCURRENTLY,你应该用 Testcontainers 在真实的 PostgreSQL 上运行测试。
7. 常见错误及处理方法
7.1. 修改已运行的迁移
这是最常见的错误。假设你在 V2__create_orders_table.sql 已经在 staging 上运行后修改了它。下一次启动时会报错:
Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 2
-> Applied to database : 873416502
-> Resolved locally : -402981177
规则是:已在任何共享环境上运行过的迁移不能被修改。要进行更改,请创建新的迁移,例如 V5__alter_orders_status_length.sql。
如果更改只是注释或空格,并且你确定 schema 没有改变,你可以使用 repair 命令更新 checksum。例如使用 Flyway Maven plugin:
mvn flyway:repair -Dflyway.url=jdbc:postgresql://localhost:5432/shop \
-Dflyway.user=shop -Dflyway.password=secret
repair 也会从 flyway_schema_history 中删除失败的迁移记录。这对 MySQL 很重要,因为 MySQL 在事务中无法回滚 DDL。
7.2. 团队合作中的版本冲突
如果两个开发人员在不同的分支上都创建了 V5__...,合并后会遇到错误 Found more than one migration with version 5。有两种方式可以防止这种情况:
- 使用基于时间戳的版本号,例如
V20261016_0930__add_order_note.sql。 - 在创建文件之前协商好版本号的“预留位置”。
还有另一种情况:分支 A 先合并了 V7,然后分支 B 合并了 V6。Flyway 会报告 V6 被忽略了(ignored)。你可以启用 spring.flyway.out-of-order=true,但更好的做法是将新文件的版本号改为大于当前版本号。
7.3. 为现有数据库应用 Flyway
对于已经有表但没有 flyway_schema_history 的生产数据库,Flyway 会因为 schema 不为空而拒绝运行。你可以使用 baseline:
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
Flyway 将当前 schema 标记为 version 1。之后它只运行版本号大于 1 的迁移。此时 V1 应该是当前 schema 的转储,以便新环境仍能从头开始构建。
注意:仅在第一次迁移时启用
baseline-on-migrate。如果长期启用它,并意外指向一个有现存表的数据库,Flyway 会直接执行 baseline 而不是报错。
7.4. clean 命令
flyway clean 会删除 schema 中的所有对象。Spring Boot 3 默认设置 spring.flyway.clean-disabled=true。你不应该在任何可能接触生产的 profile 中关闭这个标志。
7.5. 迁移耗时较长
Spring Boot 在启动时运行迁移。对有数千万行的表执行 ALTER TABLE 可能会锁定表并导致健康检查失败。在这种情况下,你应该:
- 将更改分解为多个步骤:添加可为空的列,按批量回填,然后添加
NOT NULL约束。 - 通过
spring.flyway.enabled=false禁用自动迁移,并在部署管道中作为单独的步骤运行 Flyway,在应用上线前进行。
8. 何时不应该使用默认的 Flyway 方式
Flyway 非常适合大多数 Spring Boot 应用。不过,“启动时迁移”的方式有其局限:
- 同时运行多个实例: Flyway 使用数据库级别的锁,所以不会重复运行迁移。但其他实例必须等待,启动时间会增加。
- 应用账户没有 DDL 权限: 这是生产环境中的常见安全要求。此时你需要用单独的账户在管道中运行 Flyway。
- 需要自动回滚: Community 版本没有撤销迁移的功能。你必须编写“前向”迁移来撤销更改。
9. 总结
在本文中,你已经将 Flyway 集成到 Spring Boot 3 中。你编写了版本迁移、可重复迁移和 Java 迁移,用 JUnit 5 和 AssertJ 进行了测试,并学会了如何处理常见错误,比如 checksum 不匹配、版本冲突或旧数据库的 baseline。
你应该记住三条原则:
- Flyway 是唯一修改 schema 的地方,Hibernate 只执行
validate。 - 已运行的迁移绝不要修改。
- 始终有测试从空 schema 开始运行全部迁移。