Flyway patterns & CI/CD — forward-only, multi-env, repeatable, zero-downtime
Bài này đào sâu 5 pattern Flyway nâng cao: forward-only rollback (tại sao không down-migration ở prod), multi-environment seed (V100+), Java-based migration, repeatable R__ cho view/proc, và expand-contract zero-downtime. Kết nối với CI/CD pipeline và lý do chọn từng pattern.
TL;DR: Flyway community chỉ hỗ trợ forward-only rollback — bug thì tạo migration mới sửa, không bao giờ xoá hoặc sửa migration đã apply. Lý do: audit trail + concurrent-safe + tránh state diverge giữa môi trường. Multi-env dùng subfolder dev/, prod/ với version V100+ để seed tách biệt. View/stored procedure dùng repeatable migration R__ — tự re-run khi file thay đổi. Zero-downtime dùng expand-contract (thêm column nullable trước, backfill, deploy code mới, drop sau) — mỗi bước là migration riêng. CI/CD test migration trên fresh DB trước mỗi deploy.
Bài Flyway core đã bóc naming convention, flyway_schema_history, checksum, và setup Boot. Bài này chỉ tập trung các pattern nâng cao vận hành production thật sự cần.
1. Tại sao forward-only — không có down-migration ở production
Flyway Community (Apache 2.0, miễn phí) không có lệnh undo. Đây là quyết định thiết kế có chủ đích, không phải tính năng thiếu sót.
1.1 Vấn đề với down-migration
Hãy tưởng tượng đội dùng down-migration:
-- V5__add_priority.sql
ALTER TABLE projects ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'MEDIUM';
-- U5__add_priority.sql (Teams edition)
ALTER TABLE projects DROP COLUMN priority;
Khi U5 chạy, mọi dữ liệu đã ghi vào cột priority sau khi V5 apply bị mất vĩnh viễn. Production với dữ liệu thật không thể chấp nhận risk này.
Thêm nữa, kịch bản thực tế phức tạp hơn — giữa lúc apply và lúc phát hiện bug đã có dữ liệu thật nằm trong cột mới:

Down-migration an toàn chỉ khi không có dữ liệu nào được ghi vào schema mới. Trong production với traffic thật, cửa sổ này gần như không tồn tại. Nhánh phải của sơ đồ là nội dung phần tiếp theo.
1.2 Forward-only giải quyết thế nào
Thay vì undo, tạo migration mới đi về phía trước:
-- V5__add_priority.sql (đã apply, KHÔNG được sửa)
ALTER TABLE projects ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'MEDIUM';
-- Bug: column type sai, cần đổi sang INT (priority level 1-5)
-- V6__fix_priority_type.sql (migration mới)
ALTER TABLE projects DROP COLUMN priority;
ALTER TABLE projects ADD COLUMN priority_level INT NOT NULL DEFAULT 3;
Lợi ích forward-only:
| Lợi ích | Lý do |
|---|---|
| Audit trail đầy đủ | flyway_schema_history log mọi thay đổi theo thứ tự thời gian thật |
| Không mất data | V6 kiểm soát data nào migrate, data nào drop |
| Cùng workflow | Apply và "rollback" đều là migrate forward — không cần lệnh khác |
| Concurrent-safe | Flyway DB lock vẫn hoạt động bình thường |
| Test được | V6 test trên staging trước prod giống mọi migration khác |
Quy tắc không ngoại lệ: một khi migration đã merge vào main và apply lên bất kỳ môi trường nào, file đó là bất biến. Sửa file đã apply → checksum mismatch → app không khởi động được.
Flyway tính checksum khi apply và lưu vào flyway_schema_history. Startup tiếp theo so sánh lại — nếu khác → FlywayException: Migration checksum mismatch → app không start. Xem Flyway core phần checksum mismatch để hiểu cơ chế.
2. Multi-environment — tách seed data
Schema production và development cần cùng bảng, nhưng data khác nhau: dev cần sample data để test, prod không nên có data giả.
2.1 Cấu trúc thư mục
src/main/resources/db/migration/
├── V1__init_schema.sql # tất cả môi trường
├── V2__add_priority.sql # tất cả môi trường
├── dev/
│ └── V100__seed_dev_data.sql # chỉ dev/test
└── prod/
└── V100__create_admin.sql # chỉ production
Version V100+ cho env-specific — cách xa V1-V99 để tránh conflict với migration schema chung khi team thêm migration mới.
2.2 Cấu hình theo profile
# application-dev.yml
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/dev
# application-prod.yml
spring:
flyway:
locations:
- classpath:db/migration
- classpath:db/migration/prod
2.3 Seed dev data
-- db/migration/dev/V100__seed_dev_data.sql
INSERT INTO projects (name, description, status, priority_level) VALUES
('TaskFlow Demo', 'Demo project for development', 'ACTIVE', 3),
('Archived Project', 'Old project', 'ARCHIVED', 1),
('Planning Phase', 'New feature planning', 'PLANNING', 5);
INSERT INTO tasks (project_id, title, status) VALUES
(1, 'Setup CI/CD', 'DONE'),
(1, 'Write unit tests', 'IN_PROGRESS'),
(1, 'Deploy to staging', 'TODO');
Dev team khởi động app lần đầu có ngay data mẫu. Production không chạy script này.
Version gap V100 tạo vùng đệm cho migration schema chung (V1-V99). Nếu sau này cần V50, V51... không conflict với V100 seed.
3. Java-based migration — khi SQL không đủ
Hầu hết migration viết SQL thuần. Nhưng một số tình huống cần logic phức tạp hơn:
- Transform data định dạng cũ sang mới (parse JSON, decrypt rồi re-encrypt)
- Gọi external service để lookup reference data
- Logic điều kiện phức tạp không viết được trong SQL
// src/main/java/db/migration/V3__MigrateLegacyFormat.java
package db.migration;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.sql.*;
public class V3__MigrateLegacyFormat extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
Connection conn = context.getConnection();
// select rows needing migration
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(
"SELECT id, legacy_json FROM projects WHERE migrated = false")) {
while (rs.next()) {
long id = rs.getLong("id");
String legacyJson = rs.getString("legacy_json");
// parse + transform (Java logic, not SQL)
String newFormat = transformLegacy(legacyJson);
try (PreparedStatement upd = conn.prepareStatement(
"UPDATE projects SET new_data = ?, migrated = true WHERE id = ?")) {
upd.setString(1, newFormat);
upd.setLong(2, id);
upd.executeUpdate();
}
}
}
}
private String transformLegacy(String legacy) {
// complex Java transform logic here
return legacy.replace("\"status\":0", "\"status\":\"ACTIVE\"");
}
}
Naming class: V<version>__<Description> — dấu hai gạch dưới, PascalCase. Flyway scan classpath tìm class implement BaseJavaMigration.
Khi nào dùng Java migration:
| Tình huống | SQL đủ? | Cần Java? |
|---|---|---|
| Add column, index | Có | Không |
| Rename column (add + copy + drop) | Có | Không |
| Transform JSON/XML phức tạp | Không | Có |
| Logic điều kiện nhiều bước | Khó | Có |
| Call external API/service | Không | Có |
Ưu tiên SQL — dễ review, dễ audit. Java migration chỉ dùng khi SQL thực sự không đủ.
4. Repeatable migration (R__) — view và stored procedure
Schema (table, column, index) thay đổi theo một chiều, mỗi thay đổi cần version mới. Nhưng view và stored procedure thường được chỉnh sửa lặp đi lặp lại — dùng versioned migration sẽ tạo ra V10__update_view.sql, V11__update_view_again.sql, V25__update_view_v3.sql... rất lộn xộn.
Repeatable migration giải quyết vấn đề này: Flyway tính checksum file, chạy lại khi file thay đổi.
4.1 View với R__
-- R__active_projects_view.sql
DROP VIEW IF EXISTS active_projects;
CREATE VIEW active_projects AS
SELECT
p.id,
p.name,
p.status,
p.priority_level,
p.created_at,
COUNT(t.id) AS task_count,
SUM(CASE WHEN t.status = 'DONE' THEN 1 ELSE 0 END) AS done_count
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
WHERE p.status IN ('ACTIVE', 'PLANNING')
GROUP BY p.id, p.name, p.status, p.priority_level, p.created_at;
Khi cần thêm cột vào view: sửa trực tiếp file R__active_projects_view.sql, commit, deploy. Flyway phát hiện checksum thay đổi → DROP VIEW IF EXISTS → CREATE VIEW với định nghĩa mới.
4.2 Stored procedure với R__
-- R__log_project_change_fn.sql
CREATE OR REPLACE FUNCTION log_project_change()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, operation, record_id, changed_at)
VALUES ('projects', TG_OP, NEW.id, NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_project_change ON projects;
CREATE TRIGGER trg_project_change
AFTER INSERT OR UPDATE OR DELETE ON projects
FOR EACH ROW EXECUTE FUNCTION log_project_change();
4.3 Thứ tự chạy
Flyway chạy theo thứ tự:
V1 → V2 → V3 (versioned, theo version) → R__a... → R__b... (repeatable, alphabetical)
Repeatable luôn chạy sau tất cả versioned — đảm bảo view dựa trên schema đã đầy đủ.
Idempotent bắt buộc: repeatable migration phải an toàn khi chạy lại. DROP VIEW IF EXISTS + CREATE VIEW hoặc CREATE OR REPLACE FUNCTION đảm bảo điều này.
5. CI/CD — test migration trên fresh DB
Migration chỉ an toàn khi được test trên môi trường sạch trước mỗi deploy.
5.1 Pipeline chuẩn
- Dev — thêm
V_N.sql, commit, mở PR. - CI — dựng DB rỗng, apply toàn bộ migration từ V1.
- Test — chạy test với
ddl-auto: validateđể Hibernate đối chiếu entity với schema vừa dựng. - Staging rồi production — app tự apply phần pending lúc khởi động.
Khác biệt đáng nhớ giữa bước 2 và bước 4: CI luôn chạy lại từ V1, nên đó là nơi duy nhất phát hiện được một migration cũ đã hỏng; staging và production chỉ chạy phần chênh lệch nên không bao giờ chạm lại V1.
5.2 GitHub Actions
# .github/workflows/ci.yml
jobs:
test-migration:
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
options: --health-cmd pg_isready --health-interval 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run Flyway migration + tests
run: mvn verify
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
SPRING_PROFILES_ACTIVE: dev
mvn verify chạy migration (Flyway apply tất cả V + R), Hibernate validate entity vs schema, sau đó toàn bộ unit/integration test.
5.3 Flyway validate trong CI
# application-test.yml
spring:
flyway:
enabled: true
validate-on-migrate: true # checksum verify — fail nếu migration bị sửa
locations:
- classpath:db/migration
- classpath:db/migration/dev
jpa:
hibernate:
ddl-auto: validate # Hibernate fail nếu entity != schema
validate-on-migrate: true đảm bảo không ai sửa migration đã committed — CI sẽ phát hiện ngay.
6. Zero-downtime — expand-contract
Rolling deploy (Kubernetes, ECS) có nhiều pod chạy đồng thời với code version cũ và mới cùng lúc. Schema change phải tương thích với cả hai version code trong suốt quá trình deploy.
6.1 Tại sao cần expand-contract
Nếu deploy schema change và code mới cùng lúc:
Pod A (old code) → đọc column "status" (cũ) → OK
Pod B (new code) → đọc column "state" (mới) → OK
Pod A → đọc column "state" → column chưa tồn tại → 500 ERROR
Code cũ và mới không cùng hiểu được schema trong transition period.
6.2 Pattern expand-contract — 5 bước
Ví dụ: đổi tên column status sang state trong bảng projects. Đọc theo hàng ngang — ràng buộc duy nhất là mỗi hàng đều phải để cả pod cũ lẫn pod mới chạy được:

Bước 1 — Expand: thêm column mới, nullable (không break old code):
-- V10__add_state_column.sql
ALTER TABLE projects ADD COLUMN state VARCHAR(20);
Bước 2 — Deploy code dual-write: code mới ghi vào cả status lẫn state:
// ProjectRepository — dual-write phase
public void updateStatus(Long id, String value) {
jdbcTemplate.update(
"UPDATE projects SET status = ?, state = ? WHERE id = ?",
value, value, id
);
}
Bước 3 — Backfill: migration copy dữ liệu cũ:
-- V11__backfill_state_from_status.sql
UPDATE projects SET state = status WHERE state IS NULL;
-- Sau backfill, add NOT NULL constraint
ALTER TABLE projects ALTER COLUMN state SET NOT NULL;
Bước 4 — Deploy code read new: code chỉ đọc state, không đọc status nữa. Deploy xong, tất cả pod dùng state.
Bước 5 — Contract: drop column cũ sau ít nhất 1 release cycle (đảm bảo không còn old pod):
-- V12__drop_status_column.sql
ALTER TABLE projects DROP COLUMN status;
6.3 Tại sao mỗi bước là migration riêng
- Bước 1 và 5 là DDL — deploy riêng để kiểm soát.
- Bước 3 (backfill) có thể chạy lâu trên table lớn — cần monitor riêng.
- Nếu gộp tất cả một deployment: schema và code thay đổi đồng thời → không thể rollback code mà giữ schema cũ.
CREATE INDEX Postgres mặc định acquire lock block writes. Trên table triệu row, dùng CREATE INDEX CONCURRENTLY. Flyway không chạy CONCURRENTLY trong transaction — cần config flyway.postgresql.transactional-lock=false hoặc tách ra migration riêng với comment -- flyway:executeInTransaction=false.
Liên hệ các bài khác
- Flyway core: naming convention
V<version>__<desc>.sql,flyway_schema_historytable, checksum mechanism, và setup Boot — nền tảng cần hiểu trước khi áp pattern bài này. - Mini-challenge TaskFlow v2: bài capstone áp dụng đúng các pattern trên — forward-only rollback, dev seed
V100+, repeatable view, expand-contract cho schema TaskFlow thực tế.
Tóm tắt
- Forward-only rollback: bug = tạo V_N+1 sửa về phía trước. Không xoá, không sửa migration đã apply — checksum mismatch sẽ crash app startup.
- Tại sao không down-migration: production có data thật,
DROP COLUMNmất data không thể khôi phục; forward-only giữ audit trail và dùng cùng workflow với apply. - Multi-env: schema chung ở
db/migration/, seed riêng ởdev/,prod/subfolder với versionV100+. Profile Spring chọn đúng locations. - Java migration: dùng khi SQL không đủ (transform phức tạp, external call). Class
V<N>__<Desc>implementBaseJavaMigration. Ưu tiên SQL trước. - Repeatable R__: cho view, stored procedure, trigger — re-run khi checksum file thay đổi. Phải idempotent (
DROP IF EXISTS+CREATEhoặcCREATE OR REPLACE). Chạy sau tất cả versioned migration. - CI/CD: test migration trên fresh DB mỗi PR —
validate-on-migrate: true+ddl-auto: validatephát hiện sai sót trước deploy. - Expand-contract: 5 bước tách biệt (expand → dual-write → backfill → read new → contract), mỗi bước 1 migration riêng. Đảm bảo old + new code coexist an toàn trong rolling deploy.
Tự kiểm tra
- Q1Tại sao Flyway Community không hỗ trợ down-migration và đây là quyết định thiết kế đúng? Liệt kê ít nhất 2 lý do cụ thể.
- Q2Team thêm seed data cho dev environment. Migration
V3__seed_dev.sqlaccidentally apply lên production. Vì sao xảy ra và cách tổ chức đúng? - Q3Vì sao view
active_projectsnên dùng repeatable migrationR__thay vì versionedV__? Điều kiện bắt buộc khi viết repeatable migration là gì? - Q4Giải thích 5 bước expand-contract cho rename column
emailsangcontact_emailtrong bảngusers. Tại sao không thể gộp vào 1 deploy? - Q5CI pipeline test migration bằng fresh PostgreSQL. Kể tên 2 cấu hình Spring/Flyway trong test environment giúp phát hiện lỗi schema sớm nhất, và giải thích cơ chế phát hiện của từng cấu hình.
Bài tiếp theo: Mini-challenge — TaskFlow v2 với Postgres
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên