使用 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。

你应该记住三条原则:

  1. Flyway 是唯一修改 schema 的地方,Hibernate 只执行 validate。
  2. 已运行的迁移绝不要修改。
  3. 始终有测试从空 schema 开始运行全部迁移。
spring-bootflywaydatabasemigrationpostgresql