Spring BootでFlywayを使ったデータベースマイグレーション

1. 概要

アプリケーションが変更されると、データベーススキーマも変更する必要があります。すべての環境で手動で ALTER TABLE を実行していると、遅かれ早かれ dev、staging、production 環境のスキーマがずれてきます。Flywayはこの問題を解決します。スキーマの変更ごとにバージョン付きのマイグレーションファイルを作成し、自動的に正しい順序で適用されます。

このチュートリアルでは、Spring Boot 3 アプリケーション用に Flyway を設定し、SQL と Java でマイグレーションを作成し、JUnit 5 でテストし、チェックサムミスマッチなどよくあるエラーの対処方法を学びます。

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. 基本的な設定

classpath に flyway-core があるだけで、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 はエンティティがスキーマと一致しているかのみ検査します。自動的にスキーマは修正しません。Flyway がスキーマの唯一の信頼できるソースです。
  • spring.flyway.locations:マイグレーションを含むディレクトリ。デフォルト値は既に classpath:db/migration です。ここで明示的に記載して読みやすくしています。
  • spring.flyway.validate-on-migrate=true:マイグレーション前に、Flyway は実行済みマイグレーションのチェックサムを検査します。これもデフォルト値です。

注意: Flyway と同時に ddl-auto=update または create を使用しないでください。Hibernate と Flyway の両方がスキーマを修正しようとし、結果が予測不可能になります。

4. SQL によるマイグレーション作成

4.1. ネーミング規則

Flyway はファイル名でマイグレーションを識別します:

V1__create_customer_table.sql
│ │  └── 説明(アンダースコアは空白として解釈されます)
│ └── 2つのアンダースコアで区切られています
└── 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 はスキーマが既にバージョン 2 にあることを確認するため、何も実行しません。checksum カラムは第 7 セクションの重要なキーとなります。

4.4. Repeatable マイグレーション

R__ で始まるファイルは repeatable マイグレーションです。バージョンがありません。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 では書きにくい変更もあります。たとえば、文字列処理ロジックを必要とするデータのバックフィルです。この場合、BaseJavaMigration を継承して Java でマイグレーションを作成します。

まず SQL で列を追加します。V3__add_customer_email.sql:

ALTER TABLE customer ADD COLUMN email VARCHAR(150);

その後、src/main/java/db/migration/V4__Backfill_customer_email.java に以下のクラスを配置します(package は db.migration):

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 package をスキャンします。これは classpath:db/migration に対応するためです。

顧客 Nguyễn Văn An で ID が 1 の場合、結果は nguyen.van.an.1@example.com です。

Spring Boot は JavaMigration タイプのすべての bean を Flyway に自動的に登録します。このため、マイグレーションを @Component として宣言できます。ただし、マイグレーションにリポジトリやサービスをインジェクションしないでください。ビジネスコードは時間とともに変わりますが、マイグレーションは永遠に同じように実行される必要があります。

6. マイグレーションのテスト

少なくとも、スキーマが空の状態から全マイグレーションが実行でき、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");
    }
}

最初のテストはスキーマがバージョン 4 に達し、実行待ちのマイグレーションがないことを確認します。SQL ファイルに構文エラーがある場合、コンテキストは起動できず、テストは即座に失敗します。2 番目のテストは V3 からの email カラムが実際に存在することを確認します。

注意: MODE=PostgreSQL の H2 は PostgreSQL の構文の一部のみをエミュレートします。マイグレーションが JSONB、partial index、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)を作成してください。

変更がコメントまたは空白のみで、スキーマが変わっていない場合は、repair コマンドを使用してチェックサムを更新できます。例として、Flyway Maven プラグインの場合:

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. チームで作業する場合のバージョン競合

2 人の開発者が異なるブランチで V5__... を作成し、マージ後にエラーが発生します。Found more than one migration with version 5。これを防ぐ方法は 2 つあります:

  • タイムスタンプ付きバージョンを使用(例:V20261016_0930__add_order_note.sql)。
  • ファイル作成前にバージョン「保持」について合意する。

もう 1 つのケースがあります:ブランチ A が V7 を先にマージし、その後ブランチ B が V6 をマージする場合です。Flyway は V6 がスキップされたと報告します。spring.flyway.out-of-order=true を有効にできますが、新しいファイルのバージョンを現在のバージョンより大きくする方が良いです。

7.3. 既存のデータベースに Flyway を適用

既に表を持つが flyway_schema_history がない production データベースの場合、Flyway はスキーマが空でないため実行を拒否します。baseline を使用します:

spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1

Flyway は現在のスキーマをバージョン 1 としてマークします。その後、バージョン 1 より大きいマイグレーションのみを実行します。この場合、V1 は現在のスキーマのダンプである必要があり、新しい環境でも最初から構築できるようにします。

注意: 最初の移行時にのみ baseline-on-migrate を有効にしてください。長時間有効なままで、間違ったデータベースを指すと、既に表がある場合、Flyway がエラーを報告する代わりに baseline を作成してしまいます。

7.4. clean コマンド

flyway clean はスキーマ内のすべてのオブジェクトを削除します。Spring Boot 3 はデフォルトで spring.flyway.clean-disabled=true を設定しています。production に到達する可能性のあるプロファイルでこのフラグを無効にしないでください。

7.5. 実行時間が長いマイグレーション

Spring Boot はアプリケーション起動時にマイグレーションを実行します。数千万行のテーブルに対する ALTER TABLE はテーブルをロックでき、ヘルスチェックが失敗する可能性があります。この場合、以下を行う必要があります:

  • 変更を複数のステップに分割:NULL 可能な列を追加し、バッチでバックフィルし、その後 NOT NULL を追加します。
  • spring.flyway.enabled=false で自動マイグレーションを無効にし、Flyway をパイプラインの独立したステップとして実行し、アプリケーション展開前に実行します。

8. デフォルト Flyway を使用しないべき場合

Flyway はほとんどの Spring Boot アプリケーションに適しています。ただし、「起動時にマイグレーション」アプローチには制限があります:

  • 複数のインスタンスを同時に実行: Flyway はデータベース上のロックを使用するため、マイグレーションは重複実行されません。ただし、他のインスタンスは待機し、起動時間が増加します。
  • アプリケーションアカウントに DDL 権限がない: これは production で一般的なセキュリティ要件です。この場合、パイプラインで別のアカウントを使用して Flyway を実行します。
  • 自動ロールバックが必要: Community バージョンはマイグレーションのアンドゥ機能がありません。変更を取り消すには「進む」マイグレーションを作成する必要があります。

9. 結論

このチュートリアルでは、Flyway を Spring Boot 3 に統合しました。バージョン付きマイグレーション、repeatable マイグレーション、Java マイグレーションを作成し、JUnit 5 と AssertJ でテストし、チェックサムミスマッチ、バージョン競合、古いデータベースの baseline など、よくあるエラーの対処方法を学びました。

3 つの原則を覚えておくべきです:

  1. Flyway がスキーマを変更する唯一の場所であり、Hibernate は validate のみを行います。
  2. 実行されたマイグレーションは決して修正しません。
  3. 常にスキーマが空の状態から全マイグレーションを実行するテストを用意します。
spring-bootflywaydatabasemigrationpostgresql