Datenbankmigrationen mit Flyway in Spring Boot

1. Überblick

Wenn sich Ihre Anwendung ändert, muss sich auch das Datenbankschema ändern. Wenn Sie jedes ALTER TABLE manuell in jeder Umgebung ausführen, werden die Schemas von Entwicklung, Staging und Production früher oder später auseinanderlaufen. Flyway löst dieses Problem: Jede Schemaänderung ist eine versionierte Migrationsdatei, die automatisch und in der richtigen Reihenfolge angewendet wird.

In diesem Artikel konfigurieren Sie Flyway für eine Spring Boot 3-Anwendung, schreiben Migrationen in SQL und Java, testen sie mit JUnit 5 und erfahren, wie Sie häufige Fehler wie Checksum-Nichtübereinstimmungen beheben.

2. Abhängigkeiten installieren

Spring Boot verwaltet die Version von Flyway bereits, daher müssen Sie keine Version angeben. Im folgenden Beispiel wird PostgreSQL verwendet:

<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>

Ab Flyway 10 (im Lieferumfang von Spring Boot 3.3+) wird die Unterstützung für verschiedene Datenbanktypen in separate Module aufgeteilt. Ohne flyway-database-postgresql startet die Anwendung nicht und meldet einen Fehler:

org.flywaydb.core.api.FlywayException: Unsupported Database: PostgreSQL 16.4

H2 ist bereits in flyway-core enthalten, daher benötigt der Test-Bereich kein zusätzliches Modul.

3. Grundkonfiguration

Mit flyway-core im Classpath erstellt Spring Boot automatisch das Flyway-Bean und führt Migrationen aus, bevor die EntityManagerFactory initialisiert wird. Minimale Konfiguration in 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

Wichtige Zeilen:

  • spring.jpa.hibernate.ddl-auto=validate: Hibernate überprüft nur, ob Entities dem Schema entsprechen. Es ändert das Schema nicht automatisch. Flyway ist die einzige Quelle der Wahrheit für das Schema.
  • spring.flyway.locations: Verzeichnis mit den Migrationen. Der Standardwert ist bereits classpath:db/migration. Hier wird er zur Verdeutlichung angegeben.
  • spring.flyway.validate-on-migrate=true: Vor der Migration überprüft Flyway die Checksum der bereits ausgeführten Migrationen. Dies ist auch der Standardwert.

Hinweis: Verwenden Sie nicht ddl-auto=update oder create gleichzeitig mit Flyway. Hibernate und Flyway würden beide das Schema ändern, was zu unvorhersehbaren Ergebnissen führt.

4. SQL-Migrationen schreiben

4.1. Benennungskonventionen

Flyway erkennt Migrationen an ihrem Dateinamen:

V1__create_customer_table.sql
│ │  └── Beschreibung (Unterstriche werden als Leerzeichen interpretiert)
│ └── zwei Unterstriche als Trennzeichen
└── V = versionierte Migration, gefolgt von der Versionsnummer

Die Version kann 1, 2, 1.1 oder 20261016.1 sein. Flyway vergleicht Versionen numerisch, nicht alphabetisch. Daher wird V10 nach V9 ausgeführt.

4.2. Erste Migration

Erstellen Sie die Datei 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
);

Danach folgt 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);

Beim Starten der Anwendung wird das Log etwa so aussehen:

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. Die flyway_schema_history-Tabelle

Flyway zeichnet jede ausgeführte Migration in der Tabelle flyway_schema_history auf. Sie können diese direkt abfragen:

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

Bei der nächsten Anwendung sieht Flyway, dass das Schema bereits auf Version 2 ist, und führt nichts erneut aus. Die checksum-Spalte ist der Schlüssel zu Abschnitt 7.

4.4. Wiederholbare Migrationen

Dateien, die mit R__ beginnen, sind wiederholbare Migrationen. Sie haben keine Versionsnummer. Flyway führt diese Datei jedes Mal aus, wenn sich die Checksum ändert, und führt sie immer nach allen versionierten Migrationen aus. Dieser Typ ist ideal für Views, Funktionen oder gespeicherte Prozeduren:

-- 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-Migrationen

Einige Änderungen lassen sich nur schwer mit reinem SQL schreiben, zum Beispiel das Backfill von Daten, die String-Verarbeitungslogik erfordern. In diesem Fall schreiben Sie eine Java-Migration, die von BaseJavaMigration erbt.

Fügen Sie zunächst eine Spalte über SQL in V3__add_customer_email.sql hinzu:

ALTER TABLE customer ADD COLUMN email VARCHAR(150);

Platzieren Sie dann die folgende Klasse im Package db.migration unter 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+", ".");
    }
}

Erklärung:

  • Der Klassenname folgt den gleichen Regeln wie die SQL-Datei: V4__Backfill_customer_email.
  • context.getConnection() gibt die von Flyway verwendete Verbindung zurück, die sich in der gleichen Transaktion der Migration befindet. Der Parameter true von SingleConnectionDataSource verhindert, dass JdbcTemplate diese Verbindung schließt.
  • Flyway scannt das Package db.migration, da es dem Pfad classpath:db/migration entspricht.

Für einen Kunden Nguyễn Văn An mit der ID 1 ist das Ergebnis nguyen.van.an.1@example.com.

Spring Boot registriert auch automatisch alle Beans vom Typ JavaMigration bei Flyway. Dadurch können Sie die Migration als @Component deklarieren. Sie sollten jedoch keine Repositories oder Services in Migrationen injizieren. Geschäftscode ändert sich mit der Zeit, aber Migrationen müssen immer genau gleich ablaufen.

6. Testen von Migrationen

Sie sollten mindestens einen Test haben, der bestätigt, dass alle Migrationen von einem leeren Schema aus ausgeführt werden können und Hibernate erfolgreich validiert. Erstellen Sie 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=

Test:

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");
    }
}

Der erste Test überprüft, dass das Schema auf Version 4 ist und keine ausstehenden Migrationen vorhanden sind. Wenn eine SQL-Datei Syntaxfehler enthält, kann der Context nicht gestartet werden und der Test schlägt sofort fehl. Der zweite Test bestätigt, dass die email-Spalte aus V3 tatsächlich existiert.

Hinweis: H2 im MODE=PostgreSQL emuliert nur einen Teil der PostgreSQL-Syntax. Wenn Ihre Migration spezifische Features wie JSONB, Partial Indexes oder CREATE INDEX CONCURRENTLY nutzt, sollten Sie Tests gegen echtes PostgreSQL mit Testcontainers ausführen.

7. Häufige Fehler und deren Behandlung

7.1. Versionsmigration nach der Ausführung bearbeiten

Dies ist der häufigste Fehler. Angenommen, Sie bearbeiten V2__create_orders_table.sql, nachdem sie bereits auf Staging ausgeführt wurde. Der nächste Start wird folgenden Fehler melden:

Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 2
-> Applied to database : 873416502
-> Resolved locally    : -402981177

Die Regel ist: Migrationen, die bereits in einer gemeinsamen Umgebung ausgeführt wurden, dürfen nicht verändert werden. Um Änderungen vorzunehmen, erstellen Sie eine neue Migration, zum Beispiel V5__alter_orders_status_length.sql.

Wenn die Änderung nur ein Kommentar oder Whitespace ist und Sie sicher sind, dass sich das Schema nicht ändert, können Sie die Checksum mit dem Befehl repair aktualisieren. Beispiel mit dem Flyway Maven Plugin:

mvn flyway:repair -Dflyway.url=jdbc:postgresql://localhost:5432/shop \
  -Dflyway.user=shop -Dflyway.password=secret

repair löscht auch fehlgeschlagene Migrationsdatensätze aus flyway_schema_history. Dies ist wichtig für MySQL, da MySQL keine DDL-Rollbacks innerhalb einer Transaktion unterstützt.

7.2. Doppelte Versionen bei der Teamarbeit

Wenn zwei Entwickler auf verschiedenen Branches beide V5__... erstellen und diese gemergt werden, erhalten Sie den Fehler Found more than one migration with version 5. Es gibt zwei Möglichkeiten, dies zu vermeiden:

  • Verwenden Sie Versionen basierend auf Timestamps, zum Beispiel V20261016_0930__add_order_note.sql.
  • Vereinbaren Sie, Versionsnummern zu reservieren, bevor Sie Dateien erstellen.

Ein anderes Szenario: Branch A merged V7 zuerst, dann merged Branch B V6. Flyway meldet, dass V6 übersprungen (ignored) wird. Sie können spring.flyway.out-of-order=true aktivieren, aber es ist besser, die Version der neuen Datei höher als die aktuelle Version zu setzen.

7.3. Flyway für vorhandene Datenbanken

Wenn eine Production-Datenbank bereits Tabellen hat, aber noch kein flyway_schema_history, weigert sich Flyway auszuführen, da das Schema nicht leer ist. Sie verwenden Baseline:

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

Flyway markiert das aktuelle Schema als Version 1. Danach werden nur Migrationen mit einer Version größer als 1 ausgeführt. V1 sollte dann ein Dump des aktuellen Schemas sein, damit neue Umgebungen von Grund auf aufgebaut werden können.

Hinweis: Aktivieren Sie baseline-on-migrate nur beim ersten Mal. Wenn Sie dies über längere Zeit aktiviert lassen und versehentlich auf die falsche Datenbank mit vorhandenen Tabellen verweisen, wird Flyway baseline durchführen statt einen Fehler zu melden.

7.4. Der clean-Befehl

flyway clean löscht alle Objekte im Schema. Spring Boot 3 setzt standardmäßig spring.flyway.clean-disabled=true. Deaktivieren Sie dieses Flag nicht in Profilen, die Production erreichen können.

7.5. Lange laufende Migrationen

Spring Boot führt Migrationen beim Starten aus. Ein ALTER TABLE auf einer Tabelle mit Dutzenden von Millionen Zeilen kann die Tabelle sperren und Health Checks fehlschlagen lassen. In diesem Fall sollten Sie:

  • Teilen Sie die Änderung in mehrere Schritte auf: Fügen Sie eine nullable Spalte hinzu, backfill in Batches und fügen Sie dann NOT NULL hinzu.
  • Deaktivieren Sie automatische Migration mit spring.flyway.enabled=false und führen Sie Flyway als separaten Schritt in der Deployment-Pipeline aus, bevor Sie die Anwendung ausrollen.

8. Wann man Flyway nicht wie gewohnt verwenden sollte

Flyway ist für die meisten Spring Boot-Anwendungen sehr gut geeignet. Jedoch hat der Ansatz „Migration beim Start” Grenzen:

  • Mehrere Instanzen gleichzeitig ausführen: Flyway verwendet eine Datenbanksperre, sodass keine doppelten Migrationen ausgeführt werden. Aber die verbleibenden Instanzen müssen warten, und die Startzeit erhöht sich.
  • Anwendungskonto hat keine DDL-Berechtigungen: Dies ist eine häufige Sicherheitsanforderung in Production. Sie führen Flyway dann mit einem separaten Konto in der Pipeline aus.
  • Automatisches Rollback erforderlich: Die Community-Version hat kein Undo für Migrationen. Sie müssen Migration „vorwärts” schreiben, um rückgängig zu machen.

9. Fazit

In diesem Artikel haben Sie Flyway in Spring Boot 3 integriert. Sie haben versionierte Migrationen, wiederholbare Migrationen und Java-Migrationen geschrieben, diese mit JUnit 5 und AssertJ getestet und gelernt, wie man häufige Fehler wie Checksum-Nichtübereinstimmungen, doppelte Versionen oder das Baseline älterer Datenbanken handhabt.

Es gibt drei Grundsätze, die Sie sich merken sollten:

  1. Flyway ist der einzige Ort, wo sich das Schema ändert, Hibernate nur validate.
  2. Bereits ausgeführte Migrationen sollten niemals bearbeitet werden.
  3. Sie sollten immer Tests haben, die alle Migrationen von einem leeren Schema aus ausführen.
spring-bootflywaydatabasemigrationpostgresql