Migration database với Flyway trong Spring Boot

1. Tổng quan

Khi ứng dụng thay đổi, schema database cũng phải thay đổi theo. Nếu bạn chạy tay từng câu ALTER TABLE trên mỗi môi trường, sớm muộn dev, staging và production sẽ lệch nhau. Flyway giải quyết vấn đề này: mỗi thay đổi schema là một file migration có version, được áp dụng tự động và đúng thứ tự.

Trong bài này, bạn sẽ cấu hình Flyway cho một ứng dụng Spring Boot 3, viết migration bằng SQL và bằng Java, kiểm thử bằng JUnit 5, và học cách xử lý các lỗi hay gặp như checksum mismatch.

2. Cài đặt dependency

Spring Boot đã quản lý version của Flyway, nên bạn không cần ghi version. Ví dụ dưới dùng 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>

Từ Flyway 10 trở đi (đi kèm Spring Boot 3.3+), hỗ trợ cho từng loại database được tách thành module riêng. Nếu thiếu flyway-database-postgresql, ứng dụng sẽ không khởi động được và báo lỗi:

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

H2 vẫn nằm sẵn trong flyway-core, nên phía test không cần thêm module nào.

3. Cấu hình cơ bản

Chỉ cần có flyway-core trên classpath, Spring Boot sẽ tự tạo bean Flyway và chạy migration trước khi EntityManagerFactory được khởi tạo. Cấu hình tối thiểu trong 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

Các dòng quan trọng:

  • spring.jpa.hibernate.ddl-auto=validate: Hibernate chỉ kiểm tra entity có khớp với schema hay không. Nó không tự sửa schema. Flyway là nguồn sự thật duy nhất cho schema.
  • spring.flyway.locations: thư mục chứa migration. Giá trị mặc định đã là classpath:db/migration. Ở đây ghi rõ cho dễ đọc.
  • spring.flyway.validate-on-migrate=true: trước khi migrate, Flyway kiểm tra checksum của các migration đã chạy. Đây cũng là giá trị mặc định.

Lưu ý: Đừng dùng ddl-auto=update hoặc create cùng lúc với Flyway. Hibernate và Flyway sẽ cùng sửa schema, và kết quả rất khó đoán.

4. Viết migration bằng SQL

4.1. Quy tắc đặt tên

Flyway nhận diện migration qua tên file:

V1__create_customer_table.sql
│ │  └── mô tả (dấu _ được hiểu là khoảng trắng)
│ └── hai dấu gạch dưới phân tách
└── V = versioned migration, theo sau là số version

Version có thể là 1, 2, 1.1 hoặc 20261016.1. Flyway so sánh version theo giá trị số, không theo thứ tự chữ cái. Vì vậy V10 chạy sau V9.

4.2. Migration đầu tiên

Tạo file 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
);

Tiếp theo là 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);

Khi khởi động ứng dụng, log sẽ có dạng:

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. Bảng flyway_schema_history

Flyway ghi lại mọi migration đã chạy vào bảng flyway_schema_history. Bạn có thể truy vấn trực tiếp:

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

Ở lần khởi động sau, Flyway thấy schema đã ở version 2 nên không chạy lại gì cả. Cột checksum là chìa khóa của mục 7.

4.4. Repeatable migration

File bắt đầu bằng R__ là repeatable migration. Nó không có version. Flyway chạy lại file này mỗi khi checksum thay đổi, và luôn chạy sau tất cả versioned migration. Loại này hợp với view, function hoặc stored procedure:

-- 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. Migration bằng Java

Một số thay đổi khó viết bằng SQL thuần, ví dụ backfill dữ liệu cần logic xử lý chuỗi. Khi đó bạn viết migration bằng Java, kế thừa BaseJavaMigration.

Trước hết, thêm cột bằng SQL trong V3__add_customer_email.sql:

ALTER TABLE customer ADD COLUMN email VARCHAR(150);

Sau đó đặt class sau trong package db.migration, tại 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+", ".");
    }
}

Giải thích:

  • Tên class tuân theo quy tắc giống file SQL: V4__Backfill_customer_email.
  • context.getConnection() trả về connection mà Flyway đang dùng, nằm trong cùng transaction của migration. Tham số true của SingleConnectionDataSource ngăn JdbcTemplate đóng connection này.
  • Flyway quét package db.migration vì nó tương ứng với classpath:db/migration.

Với khách hàng Nguyễn Văn An có id 1, kết quả là nguyen.van.an.1@example.com.

Spring Boot cũng tự đăng ký mọi bean kiểu JavaMigration vào Flyway. Nhờ vậy bạn có thể khai báo migration là @Component. Tuy vậy, bạn không nên inject repository hay service vào migration. Code nghiệp vụ thay đổi theo thời gian, còn migration phải chạy giống hệt nhau mãi mãi.

6. Kiểm thử migration

Bạn nên có ít nhất một test xác nhận toàn bộ migration chạy được từ schema rỗng, và Hibernate validate thành công. Tạo 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");
    }
}

Test đầu tiên kiểm tra schema đã lên version 4 và không còn migration nào chờ chạy. Nếu một file SQL có lỗi cú pháp, context sẽ không khởi động và test fail ngay. Test thứ hai xác nhận cột email từ V3 thực sự tồn tại.

Lưu ý: H2 ở MODE=PostgreSQL chỉ giả lập một phần cú pháp PostgreSQL. Nếu migration dùng tính năng riêng như JSONB, partial index hay CREATE INDEX CONCURRENTLY, bạn nên chạy test trên PostgreSQL thật bằng Testcontainers.

7. Lỗi hay gặp và cách xử lý

7.1. Sửa migration đã chạy

Đây là lỗi phổ biến nhất. Giả sử bạn sửa V2__create_orders_table.sql sau khi nó đã chạy trên staging. Lần khởi động tiếp theo sẽ báo:

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

Quy tắc là: migration đã chạy ở bất kỳ môi trường chung nào thì không được sửa. Muốn thay đổi, bạn tạo migration mới, ví dụ V5__alter_orders_status_length.sql.

Nếu thay đổi chỉ là comment hoặc khoảng trắng và bạn chắc chắn schema không đổi, bạn có thể cập nhật checksum bằng lệnh repair. Ví dụ với Flyway Maven plugin:

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

repair cũng xóa các bản ghi migration thất bại khỏi flyway_schema_history. Điều này quan trọng với MySQL, vì MySQL không rollback được DDL trong transaction.

7.2. Trùng version khi làm việc nhóm

Hai developer cùng tạo V5__... trên hai nhánh khác nhau thì merge xong sẽ gặp lỗi Found more than one migration with version 5. Có hai cách phòng tránh:

  • Dùng version theo timestamp, ví dụ V20261016_0930__add_order_note.sql.
  • Thống nhất “giữ chỗ” version trước khi tạo file.

Còn một trường hợp khác: nhánh A merge V7 trước, sau đó nhánh B merge V6. Flyway sẽ báo V6 bị bỏ qua (ignored). Bạn có thể bật spring.flyway.out-of-order=true, nhưng tốt hơn là đổi version của file mới cho lớn hơn version hiện tại.

7.3. Áp dụng Flyway cho database đã có sẵn

Với database production đã có bảng nhưng chưa có flyway_schema_history, Flyway sẽ từ chối chạy vì schema không rỗng. Bạn dùng baseline:

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

Flyway đánh dấu schema hiện tại là version 1. Sau đó nó chỉ chạy các migration có version lớn hơn 1. Lúc này V1 nên là bản dump schema hiện tại, để môi trường mới vẫn dựng được từ đầu.

Lưu ý: Chỉ bật baseline-on-migrate trong lần chuyển đổi đầu tiên. Nếu để bật lâu dài và vô tình trỏ vào sai database có sẵn bảng, Flyway sẽ baseline luôn thay vì báo lỗi.

7.4. Lệnh clean

flyway clean xóa toàn bộ object trong schema. Spring Boot 3 mặc định đã đặt spring.flyway.clean-disabled=true. Bạn đừng tắt cờ này ở bất kỳ profile nào có thể chạm tới production.

7.5. Migration chạy lâu

Spring Boot chạy migration lúc khởi động. Một câu ALTER TABLE trên bảng hàng chục triệu dòng có thể khóa bảng và làm health check thất bại. Trong trường hợp này, bạn nên:

  • Tách thay đổi thành nhiều bước: thêm cột nullable, backfill theo batch, rồi mới thêm NOT NULL.
  • Tắt auto-migrate bằng spring.flyway.enabled=false và chạy Flyway như một bước riêng trong pipeline deploy, trước khi rollout ứng dụng.

8. Khi nào không nên dùng Flyway kiểu mặc định

Flyway rất hợp với phần lớn ứng dụng Spring Boot. Tuy vậy, cách “migrate khi khởi động” có giới hạn:

  • Chạy nhiều instance cùng lúc: Flyway dùng lock trên database nên không chạy trùng migration. Nhưng các instance còn lại phải chờ, và thời gian khởi động tăng lên.
  • Tài khoản ứng dụng không có quyền DDL: đây là yêu cầu bảo mật phổ biến ở production. Khi đó bạn chạy Flyway bằng tài khoản riêng trong pipeline.
  • Cần rollback tự động: bản Community không có undo migration. Bạn phải viết migration “tiến lên” để hoàn tác.

9. Kết luận

Trong bài này, bạn đã tích hợp Flyway vào Spring Boot 3. Bạn đã viết versioned migration, repeatable migration và Java migration, kiểm thử chúng bằng JUnit 5 và AssertJ, và biết cách xử lý các lỗi phổ biến như checksum mismatch, trùng version hay baseline database cũ.

Có ba nguyên tắc bạn nên nhớ:

  1. Flyway là nơi duy nhất thay đổi schema, còn Hibernate chỉ validate.
  2. Migration đã chạy thì không bao giờ sửa.
  3. Luôn có test chạy toàn bộ migration từ schema rỗng.
spring-bootflywaydatabasemigrationpostgresql