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=updatehoặccreatecù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ốtruecủaSingleConnectionDataSourcengănJdbcTemplateđóng connection này.- Flyway quét package
db.migrationvì nó tương ứng vớiclasspath: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=PostgreSQLchỉ 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 hayCREATE 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-migratetrong 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=falsevà 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ớ:
- Flyway là nơi duy nhất thay đổi schema, còn Hibernate chỉ
validate. - Migration đã chạy thì không bao giờ sửa.
- Luôn có test chạy toàn bộ migration từ schema rỗng.