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:
Deploy V5 (8:00) → 200 request ghi priority → phát hiện bug (8:30) → chạy U5 → DROP COLUMN → 200 row mất data
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.
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 đủ.
flowchart LR V1["V1__init_schema"] --> V2["V2__add_priority"] --> V3["V3__add_tasks"] V3 --> RA["R__active_projects_view"] V3 --> RB["R__log_project_change_fn"]
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
flowchart LR Dev["Dev: add V_N.sql<br/>commit + PR"] --> CI["CI: fresh DB<br/>apply all migrations"] CI --> Test["Run tests<br/>Hibernate validate"] Test --> Staging["Deploy staging<br/>app applies pending"] Staging --> Prod["Deploy prod<br/>app applies pending"]
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.
flowchart TB S1["Buoc 1: Expand<br/>Them column state nullable<br/>Migration V10"] --> S2 S2["Buoc 2: Dual-write<br/>Code ghi ca status va state<br/>Deploy code v2"] --> S3 S3["Buoc 3: Backfill<br/>Copy data status sang state<br/>Migration V11"] --> S4 S4["Buoc 4: Read new<br/>Code chi doc state<br/>Deploy code v3"] --> S5 S5["Buoc 5: Contract<br/>Drop column status<br/>Migration V12"]
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);
Old code đọc status — vẫn hoạt động. New code chưa deploy.
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
);
}
Cả old pod và new pod cùng chạy — old pod chỉ đọc status, new pod đọc state. Không ai bị lỗi.
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ể.▸
Lý do 1 — Data loss không thể khôi phục: down-migration thường cần DROP COLUMN hoặc DELETE. Trong production với traffic thật, dữ liệu đã được ghi vào schema mới trong khoảng thời gian từ lúc apply đến lúc phát hiện bug. Chạy undo sẽ xoá dữ liệu thật — không thể undo của undo.
Lý do 2 — Forward-only an toàn và kiểm soát được hơn: migration mới (V_N+1) được test trên staging trước prod, giống mọi migration khác. Down-migration thì ngược lại — cần test cả path "apply rồi undo" và "state sau undo đúng không". Phức tạp gấp đôi, risk gấp đôi.
Lý do 3 — Audit trail: forward-only giữ lịch sử đầy đủ trong flyway_schema_history — V5 add, V6 revert, V7 fix tiếp. Down-migration xoá khỏi history, mất audit.
Kết luận: Flyway Teams có undo migration nhưng khuyến cáo chỉ dùng trong trường hợp đặc biệt, không phải quy trình thường ngày. Community edition bỏ tính năng này là hợp lý — khuyến khích đúng pattern từ đầu.
Q2Team thêm seed data cho dev environment. Migration V3__seed_dev.sql accidentally apply lên production. Vì sao xảy ra và cách tổ chức đúng?▸
V3__seed_dev.sql accidentally apply lên production. Vì sao xảy ra và cách tổ chức đúng?Vì sao xảy ra: thiếu tách biệt location theo profile. Nếu tất cả migration trong cùng classpath:db/migration/, Flyway apply hết không phân biệt môi trường.
Tổ chức đúng:
Tách thư mục:
db/migration/ # schema chung — mọi môi trường
db/migration/dev/ # chỉ dev/test
db/migration/prod/ # chỉ productionCấu hình 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/prodVersion gap: seed dùng V100__seed_dev.sql thay vì V3 — tránh conflict với migration schema chung V1-V99 khi team thêm migration mới.
Thêm CI check: chạy test với SPRING_PROFILES_ACTIVE=prod để verify production config không load dev migration.
Q3Vì sao view active_projects nên dùng repeatable migration R__ thay vì versioned V__? Điều kiện bắt buộc khi viết repeatable migration là gì?▸
active_projects nên dùng repeatable migration R__ thay vì versioned V__? Điều kiện bắt buộc khi viết repeatable migration là gì?Tại sao R__ cho view: view thay đổi nhiều lần trong vòng đời app (thêm cột, sửa filter, join thêm bảng). Mỗi lần sửa nếu dùng versioned migration sẽ tạo ra V10__update_view.sql, V15__update_view_v2.sql... Lâu dài lịch sử migration đầy file chỉ để sửa view — khó đọc, khó audit schema thật sự.
Repeatable migration R__active_projects_view.sql là source of truth duy nhất — sửa file, deploy, Flyway tự re-run khi checksum thay đổi.
Điều kiện bắt buộc — idempotent:
Repeatable migration có thể chạy lại nhiều lần → phải an toàn khi chạy lại. Hai pattern:
-- Pattern 1: DROP IF EXISTS + CREATE
DROP VIEW IF EXISTS active_projects;
CREATE VIEW active_projects AS ...;
-- Pattern 2: CREATE OR REPLACE (cho function/procedure)
CREATE OR REPLACE FUNCTION log_change() RETURNS TRIGGER AS $$ ... $$ LANGUAGE plpgsql;Nếu không idempotent, lần 2 chạy sẽ fail vì view/function đã tồn tại.
Thứ tự chạy: R__ luôn chạy sau tất cả V__ — đảm bảo view dựa trên schema đã đầy đủ (các bảng trong view đã được tạo bởi versioned migration trước).
Q4Giải thích 5 bước expand-contract cho rename column email sang contact_email trong bảng users. Tại sao không thể gộp vào 1 deploy?▸
email sang contact_email trong bảng users. Tại sao không thể gộp vào 1 deploy?5 bước expand-contract:
Bước 1 — Expand (migration V_N):
ALTER TABLE users ADD COLUMN contact_email VARCHAR(255);Column nullable — old code đọc email vẫn OK. New code chưa deploy.
Bước 2 — Deploy code dual-write: code mới ghi vào cả email lẫn contact_email. Rolling deploy: pod cũ đọc email, pod mới đọc contact_email — không ai lỗi.
Bước 3 — Backfill (migration V_N+1):
UPDATE users SET contact_email = email WHERE contact_email IS NULL;
ALTER TABLE users ALTER COLUMN contact_email SET NOT NULL;Copy data cũ sang column mới, đảm bảo không có null.
Bước 4 — Deploy code read new: code chỉ đọc contact_email. Sau deploy hoàn tất, không còn pod nào đọc email.
Bước 5 — Contract (migration V_N+2):
ALTER TABLE users DROP COLUMN email;Tại sao không gộp 1 deploy: trong rolling deploy (Kubernetes), pod cũ và pod mới cùng chạy. Nếu drop column ngay lần deploy đầu, pod cũ vẫn đang chạy đọc column email đã bị xoá → 500 error. Tách bước đảm bảo ở mọi thời điểm, mọi pod đều làm việc được với schema hiện tại.
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.▸
Cấu hình 1 — flyway.validate-on-migrate: true:
Flyway tính checksum mỗi migration script khi apply, lưu vào flyway_schema_history. Khi validate-on-migrate: true, mỗi lần startup Flyway so sánh checksum file hiện tại với checksum đã lưu. Nếu ai đó sửa migration đã committed → checksum mismatch → FlywayException → CI fail ngay.
Cơ chế phát hiện: ai sửa migration đã apply (vi phạm quy tắc forward-only) sẽ bị CI bắt trước khi merge.
Cấu hình 2 — spring.jpa.hibernate.ddl-auto: validate:
Sau khi Flyway apply migration, Hibernate so sánh entity class với schema thực tế trong DB. Nếu entity có field mà schema không có column tương ứng (hoặc ngược lại), type mismatch, nullable mismatch → SchemaManagementException → Spring context không khởi động → CI fail.
Cơ chế phát hiện: developer thêm field vào entity Java nhưng quên viết migration SQL — Hibernate phát hiện mismatch. Hoặc migration SQL sai type → Hibernate bắt.
Hai cấu hình bổ sung nhau: Flyway validate migration file integrity; Hibernate validate entity-schema consistency. Cả hai đều cần để test migration đầy đủ.
Bài tiếp theo: Mini-challenge — TaskFlow v2 với Postgres
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