Skip to content
Published on

Spring Boot Flyway完全ガイド:DBマイグレーション戦略と本番環境パターン

Authors

1. Flywayとは?

Flywayはデータベーススキーマのバージョン管理を自動化するオープンソースのマイグレーションツールです。アプリケーションコードとDBスキーマの変更を一緒に追跡し、チームメンバー全員が同じDB状態を保てるよう支援します。

Flyway vs Liquibase 比較

項目FlywayLiquibase
マイグレーション形式SQL または JavaXML, YAML, JSON, SQL
学習コスト低い中程度
ロールバック対応有料(Teams)無料(内蔵)
Spring Boot統合自動設定自動設定
コミュニティ非常に活発活発

FlywayはSQLを中心としたシンプルなアプローチと低い学習コストから、多くのSpring Bootプロジェクトで採用されています。

flyway_schema_historyテーブル

Flywayはflyway_schema_historyテーブルを通じてマイグレーション実行履歴を管理します。各マイグレーションのバージョン、チェックサム、実行日時、成功・失敗を記録します。

-- flyway_schema_historyの確認
SELECT installed_rank, version, description, type, script, checksum, installed_on, success
FROM flyway_schema_history
ORDER BY installed_rank;

2. 依存関係と基本設定

Maven依存関係

<!-- Maven pom.xml -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

<!-- MySQL使用時に追加 -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-mysql</artifactId>
</dependency>

<!-- PostgreSQLはflyway-coreのみでOK -->

Gradle依存関係

// build.gradle
dependencies {
    implementation 'org.flywaydb:flyway-core'
    // MySQL使用時
    implementation 'org.flywaydb:flyway-mysql'
}

application.yml 基本設定

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: myuser
    password: mypassword
    driver-class-name: org.postgresql.Driver
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true
    validate-on-migrate: true
    out-of-order: false
    table: flyway_schema_history
    encoding: UTF-8
    connect-retries: 3

主要な設定項目の説明:

  • enabled: Flyway自動実行の有効化フラグ
  • locations: マイグレーションスクリプトの場所(複数指定可)
  • baseline-on-migrate: 既存DBにFlywayを初めて適用する際に現在の状態をベースラインとして設定
  • validate-on-migrate: マイグレーション前にチェックサムを検証
  • out-of-order: バージョン順序を無視して適用するかどうか(デフォルトfalse)
  • table: 履歴テーブル名のカスタマイズ

3. マイグレーションファイルの命名規則

マイグレーションタイプ別の規則

バージョン付きマイグレーション:

V{version}__{description}.sql

例:

  • V1__create_users_table.sql
  • V2__add_email_index.sql
  • V1.2.3__fix_user_status_column.sql

アンドゥマイグレーション(Flyway Teams専用):

U{version}__{description}.sql

例:

  • U2__add_email_index.sql

リピータブルマイグレーション:

R__{description}.sql

例:

  • R__create_views.sql
  • R__update_stored_procedures.sql

命名規則の詳細

  • バージョン区切り文字:数字とドット/アンダースコア可 — V1V1.1V1_1 すべて有効
  • 説明区切り文字:アンダースコア2個(__
  • 説明内のアンダースコア(_)はスペースとして表示
  • 大文字小文字の区別:V は大文字が必須
src/main/resources/
└── db/
    └── migration/
        ├── V1__init_schema.sql
        ├── V2__add_user_status.sql
        ├── V3__insert_seed_data.sql
        ├── V4__add_indexes.sql
        └── R__create_reporting_views.sql

4. 実践マイグレーション例

V1__init_schema.sql - 初期スキーマ作成

-- V1__init_schema.sql
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    username    VARCHAR(50)  NOT NULL UNIQUE,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,
    created_at  TIMESTAMP    NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP    NOT NULL DEFAULT NOW()
);

CREATE TABLE products (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(200) NOT NULL,
    description TEXT,
    price       DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
    stock       INTEGER        NOT NULL DEFAULT 0,
    created_at  TIMESTAMP      NOT NULL DEFAULT NOW()
);

CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT       NOT NULL REFERENCES users(id),
    total       DECIMAL(10, 2) NOT NULL,
    status      VARCHAR(20)  NOT NULL DEFAULT 'PENDING',
    created_at  TIMESTAMP    NOT NULL DEFAULT NOW()
);

V2__add_user_status.sql - カラム追加

-- V2__add_user_status.sql
ALTER TABLE users
    ADD COLUMN status       VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    ADD COLUMN last_login   TIMESTAMP,
    ADD COLUMN phone_number VARCHAR(20);

UPDATE users SET status = 'ACTIVE' WHERE status IS NULL;

V3__insert_seed_data.sql - 初期データ挿入

-- V3__insert_seed_data.sql
INSERT INTO products (name, description, price, stock) VALUES
    ('ノートPC', '高性能開発者向けノートPC', 150000, 50),
    ('マウス', 'ワイヤレスエルゴノミクスマウス', 8000, 200),
    ('キーボード', 'メカニカルRGBキーボード', 12000, 150);

INSERT INTO users (username, email, password, status) VALUES
    ('admin', 'admin@example.com', '$2a$10$hashedpassword', 'ACTIVE');

V4__add_indexes.sql - インデックス最適化

-- V4__add_indexes.sql
CREATE INDEX idx_users_email     ON users(email);
CREATE INDEX idx_users_status    ON users(status);
CREATE INDEX idx_orders_user_id  ON orders(user_id);
CREATE INDEX idx_orders_status   ON orders(status);
CREATE INDEX idx_orders_created  ON orders(created_at DESC);
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

5. Javaベースのマイグレーション

SQLだけでは処理しにくい複雑なデータ変換はJavaマイグレーションで実装します。

BaseJavaMigrationの実装

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.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;

/**
 * V5__MigrateUserData
 * usersテーブルのfull_nameカラムをfirst_nameとlast_nameに分割
 */
public class V5__MigrateUserData extends BaseJavaMigration {

    @Override
    public void migrate(Context context) throws Exception {
        JdbcTemplate jdbcTemplate = new JdbcTemplate(
            new SingleConnectionDataSource(context.getConnection(), true)
        );

        List<String[]> users = new ArrayList<>();

        try (Statement stmt = context.getConnection().createStatement();
             ResultSet rs = stmt.executeQuery(
                 "SELECT id, full_name FROM users WHERE full_name IS NOT NULL")) {
            while (rs.next()) {
                long id = rs.getLong("id");
                String fullName = rs.getString("full_name");
                String[] parts = fullName.split(" ", 2);
                users.add(new String[]{
                    String.valueOf(id),
                    parts[0],
                    parts.length > 1 ? parts[1] : ""
                });
            }
        }

        for (String[] user : users) {
            jdbcTemplate.update(
                "UPDATE users SET first_name = ?, last_name = ? WHERE id = ?",
                user[1], user[2], Long.parseLong(user[0])
            );
        }

        System.out.printf("%d件のユーザーレコードをマイグレーションしました%n", users.size());
    }
}

Spring BeanをJavaマイグレーションに注入する

package db.migration;

import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class V6__EncryptSensitiveData extends BaseJavaMigration {

    // @ComponentによりSpring Bean注入が可能
    // FlywayAutoConfigurationがJavaMigration Beanを自動検出
    @Autowired
    private EncryptionService encryptionService;

    @Override
    public void migrate(Context context) throws Exception {
        var jdbcTemplate = new org.springframework.jdbc.core.JdbcTemplate(
            new org.springframework.jdbc.datasource.SingleConnectionDataSource(
                context.getConnection(), true)
        );

        jdbcTemplate.query(
            "SELECT id, phone_number FROM users WHERE phone_number IS NOT NULL",
            (rs) -> {
                long id = rs.getLong("id");
                String phone = rs.getString("phone_number");
                String encrypted = encryptionService.encrypt(phone);
                jdbcTemplate.update(
                    "UPDATE users SET phone_number = ? WHERE id = ?",
                    encrypted, id
                );
            }
        );
    }
}

6. マルチ環境設定

Spring Profile別のFlyway設定

# application.yml (共通)
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration

---
# application-dev.yml
spring:
  config:
    activate:
      on-profile: dev
  flyway:
    locations:
      - classpath:db/migration
      - classpath:db/migration/dev
    clean-on-validation-error: true # 開発環境のみ許可

---
# application-test.yml
spring:
  config:
    activate:
      on-profile: test
  flyway:
    locations:
      - classpath:db/migration
      - classpath:db/migration/test
    clean-disabled: false # テスト前のDB初期化を許可

---
# application-prod.yml
spring:
  config:
    activate:
      on-profile: prod
  flyway:
    locations: classpath:db/migration
    clean-disabled: true # 本番環境でのcleanコマンドを無効化
    out-of-order: false
    validate-on-migrate: true

ベンダー別マイグレーション分岐

spring:
  flyway:
    locations:
      - classpath:db/migration
      - classpath:db/migration/{vendor}
db/migration/
├── V1__create_tables.sql          # 共通
├── V2__add_indexes.sql            # 共通
├── postgresql/
│   └── V3__add_pg_specific.sql   # PostgreSQL専用
└── mysql/
    └── V3__add_mysql_specific.sql # MySQL専用

Java Configによる細かい設定

@Configuration
public class FlywayConfig {

    @Bean
    public FlywayMigrationStrategy migrationStrategy() {
        return flyway -> {
            System.out.println("Flywayマイグレーション開始...");
            flyway.migrate();
            System.out.println("Flywayマイグレーション完了。");
        };
    }

    @Bean
    @Profile("!prod")
    public FlywayMigrationStrategy devMigrationStrategy() {
        return flyway -> {
            // 開発環境: スキーマをクリアしてから再実行
            flyway.clean();
            flyway.migrate();
        };
    }
}

7. テスト戦略

@FlywayTestアノテーションの活用

<dependency>
    <groupId>org.flywaydb.flyway-test-extensions</groupId>
    <artifactId>flyway-spring-test</artifactId>
    <version>9.5.0</version>
    <scope>test</scope>
</dependency>
@SpringBootTest
@FlywayTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    @FlywayTest(locationsForMigrate = {"classpath:db/migration/test"})
    void testUserCreation() {
        User user = new User("testuser", "test@example.com");
        User saved = userRepository.save(user);
        assertThat(saved.getId()).isNotNull();
    }
}

H2インメモリDBテスト

# application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    driver-class-name: org.h2.Driver
    username: sa
    password:
  flyway:
    enabled: true
    locations: classpath:db/migration

TestContainers + Flyway(推奨)

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class IntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass");

    @DynamicPropertySource
    static void setProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void flywayMigration_appliesAllScripts() {
        assertThat(userRepository.count()).isGreaterThanOrEqualTo(0);
    }
}

8. 本番環境での注意事項

カラム削除の3ステップ戦略

稼働中のシステムでカラムを即座に削除するとローリングデプロイ中にアプリケーションエラーが発生する可能性があります。3ステップのアプローチを推奨します。

ステップ1:カラムをNULL許容に変更(デプロイN)

-- V10__make_old_column_nullable.sql
ALTER TABLE users ALTER COLUMN old_field DROP NOT NULL;

ステップ2:アプリケーションコードから該当カラムの参照を削除(デプロイN+1)

  • JPAエンティティ、クエリ、リポジトリから参照を削除

ステップ3:カラムの実際の削除(デプロイN+2)

-- V12__drop_old_column.sql
ALTER TABLE users DROP COLUMN IF EXISTS old_field;

PostgreSQL大規模インデックス作成戦略

public class V15__AddLargeIndexConcurrently extends BaseJavaMigration {

    @Override
    public boolean canExecuteInTransaction() {
        return false; // トランザクション外で実行
    }

    @Override
    public void migrate(Context context) throws Exception {
        try (Statement stmt = context.getConnection().createStatement()) {
            // CONCURRENTLYはテーブルロックなしでインデックス作成
            // ただしトランザクション内では使用不可
            stmt.execute(
                "CREATE INDEX CONCURRENTLY IF NOT EXISTS " +
                "idx_orders_large ON orders(created_at, status)"
            );
        }
    }
}

大規模テーブルのバッチマイグレーション

public class V16__BatchMigrateLargeTable extends BaseJavaMigration {

    private static final int BATCH_SIZE = 1000;

    @Override
    public void migrate(Context context) throws Exception {
        JdbcTemplate jdbc = new JdbcTemplate(
            new SingleConnectionDataSource(context.getConnection(), true)
        );

        Long maxId = jdbc.queryForObject("SELECT MAX(id) FROM orders", Long.class);
        if (maxId == null) return;

        long processedCount = 0;
        for (long offset = 0; offset <= maxId; offset += BATCH_SIZE) {
            final long batchOffset = offset;
            int updated = jdbc.update(
                "UPDATE orders SET new_status_code = CASE status " +
                "WHEN 'PENDING' THEN 1 WHEN 'COMPLETED' THEN 2 ELSE 0 END " +
                "WHERE id > ? AND id <= ? AND new_status_code IS NULL",
                batchOffset, batchOffset + BATCH_SIZE
            );
            processedCount += updated;
            System.out.printf("進捗: %d件更新済み%n", processedCount);
        }
    }
}

flyway repairコマンド

マイグレーション失敗後に履歴を復旧する際に使用します。

# Maven
./mvnw flyway:repair

# Gradle
./gradlew flywayRepair

# CLI
flyway -url=jdbc:postgresql://localhost:5432/mydb \
       -user=myuser \
       -password=mypassword \
       repair

9. CI/CD統合

GitHub ActionsでのFlyway検証

# .github/workflows/flyway-validate.yml
name: Flyway Validation

on:
  pull_request:
    paths:
      - 'src/main/resources/db/migration/**'
      - 'src/main/java/db/migration/**'

jobs:
  flyway-validate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Flyway検証
        run: |
          ./mvnw flyway:validate \
            -Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
            -Dflyway.user=testuser \
            -Dflyway.password=testpass

      - name: マイグレーション実行
        run: |
          ./mvnw flyway:migrate \
            -Dflyway.url=jdbc:postgresql://localhost:5432/testdb \
            -Dflyway.user=testuser \
            -Dflyway.password=testpass

本番デプロイ前検証スクリプト

#!/bin/bash
# scripts/pre-deploy-flyway-check.sh

set -e

echo "本番データベースに対してFlyway検証を実行中..."

./mvnw flyway:validate \
  -Dflyway.url="${PROD_DB_URL}" \
  -Dflyway.user="${PROD_DB_USER}" \
  -Dflyway.password="${PROD_DB_PASSWORD}" \
  -Dflyway.cleanDisabled=true

echo "Flyway検証が完了しました。デプロイ可能です。"

クイズ:Flyway知識チェック

Q1. flyway_schema_historyテーブルはどのような役割を担っていますか?

答え: マイグレーション実行履歴を追跡するメタデータテーブルです。

解説: flyway_schema_historyテーブルは、適用された各マイグレーションスクリプトのバージョン、ファイル名、チェックサム(ファイル変更検出用)、実行日時、成功・失敗状態を保存します。Flywayはこのテーブルを基に、どのマイグレーションが実行済みかを把握し、新たに追加されたスクリプトのみを順番に実行します。チェックサムを利用して、適用済みスクリプトへの不正な変更も検出します。

Q2. baseline-on-migrateオプションはどのような状況で使用すべきですか?

答え: すでに稼働中の既存データベースにFlywayを初めて導入する際に使用します。

解説: flyway_schema_historyテーブルが存在しない既存DBにFlywayを初めて適用するとエラーが発生することがあります。baseline-on-migrate: trueを設定することで、初回実行時に現在のDB状態をベースラインとして設定し、以降のマイグレーションのみを適用します。ただし、すでにFlywayで管理されているDBには使用しないことを推奨します。

Q3. リピータブルマイグレーション(R__プレフィックス)はどのような場合に使用しますか?

答え: ビュー、ストアドプロシージャ、ファンクションなど、内容が変更されるたびに再実行が必要なSQLオブジェクトの管理に使用します。

解説: バージョン付きマイグレーションは一度しか実行されませんが、リピータブルマイグレーションはファイルのチェックサムが変更されるたびに再実行されます。そのためR__create_views.sqlにビュー定義を記述しておけば、ビューの内容を変更したい時にファイルを修正するだけで自動的に再適用されます。リピータブルマイグレーションは常にバージョン付きマイグレーションが全て適用された後に実行されます。

Q4. 本番環境で大規模テーブルにインデックスを追加する際、Flywayのトランザクション処理はどうすべきですか?

答え: canExecuteInTransaction()メソッドをfalseにオーバーライドしたJavaマイグレーションを使用し、PostgreSQLのCREATE INDEX CONCURRENTLYを活用します。

解説: PostgreSQLのCREATE INDEX CONCURRENTLYはテーブルロックなしでインデックスを作成できますが、トランザクション内では使用できません。JavaマイグレーションでcanExecuteInTransaction()をfalseにすると、Flywayはそのマイグレーションをトランザクション外で実行します。これにより、本番サービスを止めることなく安全にインデックスを作成できます。

Q5. 適用済みのマイグレーションファイルを変更した場合に発生する「checksum mismatch」エラーはどう解決しますか?

答え: flyway repairコマンドを実行するか、修正内容を含む新しいバージョンのマイグレーションファイルを追加します。

解説: Flywayは既に適用されたマイグレーションファイルが変更されるとチェックサム不一致エラーを発生させます。これはDBの一貫性を保護するための安全機能です。解決方法は2つあります。まずflyway repairで履歴テーブルのチェックサムを現在のファイルと同期させます(開発環境でのみ推奨)。次に、絶対に既存ファイルを変更せず、修正内容を含む新しいバージョンのマイグレーションファイルを追加します(本番環境では必須)。