Spring REST API & Data JPA/Flyway core & setup — schema versioning như code
43/46
Bài 43 / 46~14 phútMigration & CapstoneMiễn phí lượt xem

Flyway core & setup — schema versioning như code

Flyway version hoá schema database: mỗi thay đổi là 1 SQL file đánh số, apply đúng 1 lần theo thứ tự, checksum bất biến sau khi apply. Bài này bóc cơ chế flyway_schema_history, naming convention V__migration, setup Boot, và baseline existing DB — cùng lý do tại sao forward-only là lựa chọn thiết kế đúng.

TL;DR: Flyway giải quyết vấn đề ddl-auto=update không version hoá được: mỗi schema change là 1 file SQL tên V<version>__<mo_ta>.sql, apply đúng 1 lần theo thứ tự, checksum được lưu vào flyway_schema_history để audit. Sau khi apply, không được sửa file — checksum bất biến là cơ chế đảm bảo mọi môi trường có cùng lịch sử migration. Boot autoconfigure Flyway qua flyway-core dependency. DB legacy chưa có Flyway dùng baseline-on-migrate: true để onboard mà không phá schema cũ.

ddl-auto=update là anti-pattern trên production vì nó không để lại audit trail và không đảm bảo reproducibility — bài JPA & Hibernate — ddl-auto strategy đã phân tích 6 rủi ro. Bài này bóc đúng 1 thứ: Flyway core và cách nó biến schema DB thành source-controllable artifact, không lan sang CI/CD hay multi-environment pattern (bài tiếp theo).

1. Vì sao schema migration — versioning DB như code

Hình dung team 3 dev cùng nhau làm TaskFlow. Dev A thêm column priority, Dev B thêm table tasks. Nếu mỗi người chỉ sửa entity Java và để ddl-auto=update tự điều, điều gì xảy ra khi deploy production?

Dev A  →  entity Project có field priority  →  Hibernate ADD COLUMN priority
Dev B  →  entity Task mới                  →  Hibernate CREATE TABLE tasks
Dev C  →  rename field  deadline → due_date →  Hibernate KHÔNG rename
                                              →  DROP deadline, ADD due_date
                                              →  DATA LOSS — dữ liệu cột deadline mất

ddl-auto=update có 4 giới hạn cốt lõi khiến nó không phù hợp production:

Giới hạnHệ quả
Không versionKhông biết DB đang ở "phiên bản" nào — không audit trail
Không drop / renameSchema bẩn — cột cũ tồn tại vĩnh viễn
Race condition2 pod startup đồng thời → conflict ALTER TABLE
Không reproducibleSchema phụ thuộc Hibernate version + entity scan order

Migration tool giải quyết bằng 3 nguyên tắc:

  1. Versioned — mỗi change có số version, apply đúng 1 lần, theo thứ tự.
  2. Reproducible — SQL script là source of truth, chạy trên bất kỳ env nào cho cùng kết quả.
  3. Audited — lịch sử mọi thay đổi lưu trong DB, có timestamp và checksum.
App startup
App startup
Flyway scan
Flyway scan
Schema updated
Schema updated

2. Flyway core concepts

2.1 Naming convention — quy tắc đặt tên file bắt buộc

Flyway nhận diện migration script hoàn toàn qua tên file. Đây là convention bắt buộc:

V<version>__<mo_ta>.sql
  ^         ^^
  |         hai gach duoi (double underscore)
  version (1, 1.1, 20260415_1430, ...)

Ví dụ thực tế trong TaskFlow:

src/main/resources/db/migration/
├── V1__init_schema.sql
├── V2__add_project_priority.sql
├── V3__create_tasks_table.sql
└── V4__add_task_assignee.sql
Hai gạch dưới — không phải một

V1__init_schema.sql đúng (double underscore). V1_init_schema.sql sai — Flyway không nhận diện, migration không được apply. Lỗi âm thầm, không throw exception.

Ngoài versioned migration (V), Flyway có 2 loại khác:

PrefixLoạiChạy khi nào
V<version>__<desc>.sqlVersionedMột lần, theo thứ tự version
R__<desc>.sqlRepeatableKhi checksum file thay đổi
U<version>__<desc>.sqlUndoRollback thủ công (Flyway Teams edition — có phí)

R__ dùng cho view, stored procedure — những thứ được thay đổi nhiều lần. Bài này tập trung V__ vì đó là core workflow.

2.2 flyway_schema_history — cơ chế theo dõi

Khi Flyway chạy lần đầu, nó tự tạo table flyway_schema_history trong cùng DB:

-- Flyway tự tao, khong can viet tay
SELECT installed_rank, version, description, checksum, success
FROM flyway_schema_history
ORDER BY installed_rank;

-- installed_rank | version | description          | checksum   | success
-- 1              | 1       | init schema          | -123456789 | true
-- 2              | 2       | add project priority | -987654321 | true
-- 3              | 3       | create tasks table   |  456789012 | true

Mỗi row là một migration đã apply. Các trường quan trọng:

  • version — version string từ tên file (V3__..."3").
  • checksum — CRC32 của nội dung file lúc apply. Đây là trường bất biến sau khi apply.
  • successtrue nếu apply thành công, false nếu fail giữa chừng.

Khi app khởi động, Flyway so sánh danh sách file trong db/migration/ với flyway_schema_history:

flowchart LR
  subgraph Files["db/migration/ (filesystem)"]
    direction TB
    F1["V1__init.sql"]
    F2["V2__priority.sql"]
    F3["V3__tasks.sql"]
  end
  subgraph History["flyway_schema_history (DB)"]
    direction TB
    H1["V1 - checksum aaa - success"]
    H2["V2 - checksum bbb - success"]
  end
  subgraph Action["Flyway decision"]
    direction TB
    A1["V1: da co trong history -> skip"]
    A2["V2: da co trong history -> skip"]
    A3["V3: CHUA co -> APPLY"]
  end
  Files --> Action
  History --> Action

Kết quả: chỉ V3 được apply. V1 và V2 bỏ qua vì đã có trong history. Đây là tính chất idempotent — restart app bao nhiêu lần cũng an toàn.

2.3 Checksum bất biến — tại sao không được sửa file đã apply

Đây là rule quan trọng nhất của Flyway, và có lý do thiết kế rõ ràng:

Tình huống: V2 đã apply trên production (checksum = -987654321). Dev sửa file V2__add_project_priority.sql để thêm 1 dòng nữa. Restart app:

flowchart TB
  subgraph Check["Flyway startup check"]
    direction LR
    C1["Doc V2__add_project_priority.sql (da sua)"]
    C2["Tinh checksum moi: -111111111"]
    C3["So sanh vs history: -987654321"]
    C4["MISMATCH"]
    C5["FlywayException: STARTUP FAIL"]
    C1 --> C2 --> C3 --> C4 --> C5
  end

App không khởi động. Đây là intentional fail-fast — không phải bug của Flyway.

Tại sao checksum phải bất biến? Vì nếu cho phép sửa file đã apply, bạn mất đi tính reproducibility: env A apply V2 gốc, env B apply V2 đã sửa → hai DB có schema khác nhau trong khi Flyway history nói cả hai đều "ở version 2". Audit trail vô nghĩa. Checksum immutability là hợp đồng bảo toàn "cùng history = cùng schema".

Cách đúng: nếu cần thêm thay đổi, viết migration mới V3__... thay vì sửa V2:

✅ DUNG:
V2__add_project_priority.sql   (giu nguyen)
V3__fix_priority_default.sql   (them migration moi)

❌ SAI:
V2__add_project_priority.sql   (da sua noi dung) → checksum mismatch → FAIL

Ngoại lệ hợp lệ duy nhất: migration trên feature branch chưa merge vào main — chưa apply trên môi trường nào khác ngoài local của bạn → sửa thoải mái. Ngay khi merge vào main và apply trên staging/production, file trở thành bất biến.

2.4 Concurrent-safe — DB lock

Khi nhiều pod khởi động đồng thời (rolling deploy Kubernetes), Flyway đảm bảo chỉ 1 pod apply migration:

Pod A startup  →  Flyway acquire DB lock  →  apply V3  →  release lock
Pod B startup  →  Flyway try acquire lock  →  WAIT       →  lock released
                                            →  check history  →  V3 da apply
                                            →  no-op, skip

Cơ chế là advisory lock của PostgreSQL (pg_advisory_lock). Không cần cấu hình — Flyway tự handle.

3. Setup Spring Boot

3.1 Dependency

<!-- pom.xml -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
    <!-- version managed by Spring Boot BOM -->
</dependency>

<!-- PostgreSQL support (Boot 3.4+) -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

flyway-core là Apache 2.0 (miễn phí). flyway-database-postgresql là module mở rộng cho PostgreSQL dialect — cũng miễn phí.

Boot autoconfigure

Khi có flyway-core trên classpath và spring.datasource được configure, Boot tự tạo Flyway bean và gọi migrate() trước khi app nhận request đầu tiên. Không cần viết @Bean Flyway thủ công.

3.2 Configuration

# application.yml
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration    # default, co the bo qua
    validate-on-migrate: true            # checksum verify khi startup

  jpa:
    hibernate:
      ddl-auto: validate                 # Flyway quan ly schema, Hibernate chi validate

ddl-auto: validate là pattern chuẩn kết hợp với Flyway:

  • Flyway apply migration → schema được tạo/cập nhật.
  • Hibernate validate entity mapping với schema → fail fast nếu mismatch (ví dụ entity có field mà DB không có column).

Không dùng ddl-auto: update kết hợp Flyway — hai bên cùng sửa schema gây race condition.

3.3 Migration đầu tiên

-- src/main/resources/db/migration/V1__init_schema.sql

CREATE TABLE projects (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    description VARCHAR(500),
    status      VARCHAR(20)  NOT NULL DEFAULT 'PLANNING',
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    CONSTRAINT uk_project_name UNIQUE (name),
    CONSTRAINT chk_project_status
        CHECK (status IN ('PLANNING', 'ACTIVE', 'DONE', 'ARCHIVED'))
);

CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_created ON projects(created_at DESC);

App khởi động lần đầu:

  1. Flyway tạo flyway_schema_history (nếu chưa có).
  2. Phát hiện V1__init_schema.sql chưa có trong history.
  3. Chạy script, tạo table projects và 2 index.
  4. Ghi row vào history: version 1, checksum tính từ nội dung file, success = true.

Khởi động lần hai: V1 đã có trong history → skip.

3.4 Thêm column — migration V2

-- src/main/resources/db/migration/V2__add_project_priority.sql

ALTER TABLE projects
    ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'MEDIUM';

CREATE INDEX idx_projects_priority ON projects(priority);

Best practice khi thêm column NOT NULL:

  • Luôn kèm DEFAULT value để backward-compatible với existing rows.
  • Add index trong cùng migration nếu liên quan — tránh state inconsistent giữa column và index khi migration fail.

4. Baseline existing DB

Tình huống thực tế: TaskFlow đã chạy production 6 tháng với schema 15 table, chưa có Flyway. Cần onboard Flyway mà không xoá data.

Vấn đề: Flyway thấy flyway_schema_history không có → tưởng DB mới hoàn toàn → cố chạy V1 → V1 tạo table đã tồn tại → ERROR.

Giải pháp: baseline-on-migrate: true.

spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1
    baseline-description: "Existing schema as of 2026-06-09"

Cơ chế hoạt động:

flowchart TB
  subgraph FirstDeploy["Lan deploy dau tien (existing DB)"]
    direction TB
    D1["Flyway connect DB"]
    D2["flyway_schema_history khong ton tai"]
    D3["baseline-on-migrate=true: tao table + insert BASELINE row"]
    D4["version=1, type=BASELINE, success=true"]
    D5["So sanh files: V1 da co baseline -> skip<br/>V2 pending -> APPLY"]
    D1 --> D2 --> D3 --> D4 --> D5
  end

Sau deploy đầu tiên:

SELECT version, description, type FROM flyway_schema_history;
-- 1  Existing schema as of 2026-06-09  BASELINE
-- 2  add project priority              SQL

Production DB có schema cũ nhảy thẳng vào Flyway-managed. V1 baseline chỉ là marker — Flyway không chạy nội dung file V1 trên DB này (vì baseline version = 1, mọi script version từ 1 trở xuống được skip).

Workflow onboard đầy đủ:

1. pg_dump --schema-only prod > current-schema.sql
   (snapshot schema hien tai de luu tru)

2. Tao V1__baseline.sql -- copy noi dung current-schema.sql
   (de new env (dev, test) co the apply tu dau)

3. Them V2__... cho change tiep theo

4. Configure baseline-on-migrate: true

5. Deploy: production DB nhan baseline marker, apply V2+
   New env (dev fresh): apply V1 (full schema) + V2+
   -- ca hai deu dat cung schema cuoi
V1 cho new env phai idempotent

File V1__baseline.sql sẽ được apply trên fresh env (test DB, dev mới). Mọi CREATE TABLE phải có IF NOT EXISTS, mọi CREATE INDEX tương tự — phòng trường hợp script chạy 2 lần do restart giữa chừng.

Cơ chế bên dưới — luồng startup Flyway

Khi Spring Boot khởi động và gọi Flyway.migrate(), luồng thực tế như sau:

flowchart TB
  A["Flyway.migrate()"] --> B["Acquire DB advisory lock"]
  B --> C["Ensure flyway_schema_history exists"]
  C --> D["Scan classpath:db/migration/ cho V*.sql"]
  D --> E["Filter: chi lay scripts CHUA co trong history"]
  E --> F{{"Pending scripts?<br/>con scripts chua apply?"}}
  F -->|"Khong"| G["Release lock, no-op"]
  F -->|"Co"| H["Apply theo thu tu version"]
  H --> I["Tinh checksum, ghi history row, success=true"]
  I --> J["Con pending khac?"]
  J -->|"Co"| H
  J -->|"Khong"| G

Ba điểm cốt lõi rút ra từ luồng này:

  1. Lock trước, apply sau — đảm bảo concurrent-safe dù 100 pod khởi động đồng thời.
  2. Filter theo history — chỉ apply pending, idempotent với restart.
  3. Checksum ghi sau khi apply thành công — nếu script fail, success = false trong history, app không start, DBA có thể diagnose rồi chạy flyway repair.

Pitfall của riêng concept này

Nhầm 1 — Sửa migration đã apply:

-- V2__add_project_priority.sql (da apply production)
ALTER TABLE projects ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'MEDIUM';
ALTER TABLE projects ADD COLUMN owner_id BIGINT;   -- THEM VAO SAU KHI DA APPLY

Kết quả: FlywayException: Migration checksum mismatch for migration version 2 → app không khởi động.

✅ Tạo V3__add_project_owner.sql riêng. V2 giữ nguyên bất biến mãi mãi.

Nhầm 2 — Một gạch dưới thay vì hai:

V1_init_schema.sql   ← SAI: Flyway khong nhan dien, bo qua
V1__init_schema.sql  ← DUNG: double underscore

Flyway không throw error — nó chỉ không thấy file. Schema không được tạo, Hibernate validate fail với SchemaExportException. Debug rất confusing vì lỗi báo ở Hibernate chứ không phải Flyway.

Nhầm 3 — Dùng ddl-auto=update cùng Flyway:

spring:
  flyway:
    enabled: true
  jpa:
    hibernate:
      ddl-auto: update   # SAI khi da dung Flyway

Hai bên cùng sửa schema → race condition: Flyway apply V2 thêm column, Hibernate update thấy column chưa đúng type và cũng ALTER → conflict. Luôn dùng ddl-auto: validate khi có Flyway.

Nhầm 4 — Quên baseline-on-migrate khi onboard DB cũ:

spring:
  flyway:
    enabled: true
    # baseline-on-migrate: true  ← QUEN

Kết quả: Flyway thấy DB không có flyway_schema_history → chạy V1 → CREATE TABLE projects trên table đã tồn tại → ERROR: relation "projects" already exists → app không start.

📚 Deep Dive

Tài liệu chính chủ

Flyway:

Spring Boot:

ddl-auto deep dive:

Liên hệ các bài khác

  • JPA & Hibernate — ddl-auto: Bài này thiết lập tại sao ddl-auto=update không đủ production-grade và ddl-auto=none/validate là lựa chọn đúng — Flyway là thứ lấp chỗ trống schema management đó.
  • Migration patterns & CI/CD: Bài tiếp theo mở rộng từ core sang production workflow: forward-only rollback, multi-environment config (dev/, prod/), CI pipeline test migration, và zero-downtime expand-contract pattern.

Tóm tắt

  • ddl-auto=update không phù hợp production vì thiếu versioning, không drop/rename, race condition, không reproducible.
  • Flyway giải quyết bằng migration file V<version>__<mo_ta>.sql — apply đúng 1 lần, theo thứ tự, ghi vào flyway_schema_history.
  • Checksum bất biến sau khi apply — sửa file đã apply gây FlywayException startup fail. Đây là intentional, bảo toàn tính "cùng history = cùng schema" giữa mọi môi trường.
  • Boot autoconfigure qua flyway-core + flyway-database-postgresql dependency. Pattern chuẩn: ddl-auto: validate + Flyway.
  • DB legacy onboard bằng baseline-on-migrate: true — production DB nhận BASELINE marker, skip V1, apply V2+. Fresh env apply V1 full schema + V2+.
  • Concurrent-safe qua DB advisory lock — 100 pod startup đồng thời, chỉ 1 pod apply migration.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Flyway thiết kế checksum bất biến — tức là không cho sửa file migration sau khi đã apply? Hệ quả nếu cho phép sửa là gì?

Checksum bất biến là hợp đồng bảo toàn tính "cùng history = cùng schema": nếu hai env đều có flyway_schema_history ghi version 2 với cùng checksum, hai DB chắc chắn có cùng schema tại version 2.

Nếu cho phép sửa file đã apply: env A apply V2 gốc (checksum X), dev sửa V2, env B apply V2 mới (checksum Y) — cả hai đều nói "ở version 2" nhưng schema thực tế khác nhau. Audit trail vô nghĩa, bug production rất khó reproduce vì schema dev và staging lệch nhau âm thầm.

Flyway chọn fail-fast — throw FlywayException khi phát hiện checksum mismatch — thay vì để inconsistency âm thầm lan ra. Đây là quyết định thiết kế ưu tiên correctness over convenience.

Q2
Phân biệt flyway_schema_history với schema thật của app. Flyway dùng table đó để làm gì khi app restart?

flyway_schema_history là metadata table của Flyway — lưu lịch sử các migration đã apply (version, checksum, timestamp, success). Nó không chứa business data — chỉ là "sổ cái" theo dõi lịch sử schema.

Khi app restart, Flyway đọc table này để biết DB đang ở trạng thái nào. Sau đó scan db/migration/chỉ apply các script chưa có trong history. Nếu V1, V2, V3 đã có → skip cả 3, chỉ apply V4 nếu tồn tại.

Tính chất này khiến Flyway idempotent: restart app bao nhiêu lần, deploy bao nhiêu pod, schema chỉ thay đổi đúng 1 lần cho mỗi migration version.

Q3
Team đang onboard Flyway cho app đã chạy production 1 năm (20 table, không có flyway_schema_history). Nếu chỉ thêm flyway-core dependency và tạo V1__init_schema.sql mà không config baseline-on-migrate, điều gì xảy ra khi deploy?

App sẽ fail khởi động. Flyway thấy flyway_schema_history không tồn tại → tưởng DB hoàn toàn mới → cố chạy V1__init_schema.sql → script có CREATE TABLE projects → PostgreSQL throw ERROR: relation "projects" already exists → Flyway đánh dấu migration success=false → app không start.

Fix đúng: thêm baseline-on-migrate: true + baseline-version: 1. Khi đó Flyway detect không có history → insert BASELINE row cho version 1 (không chạy V1 script) → so sánh files: V1 đã có baseline, skip → chỉ apply V2+ nếu có.

File V1 vẫn cần giữ để fresh env (dev local, test) có thể apply full schema từ đầu. Hai env đạt cùng schema cuối: production qua baseline + V2+, dev fresh qua V1 + V2+.

Q4
Developer tạo file V3_add_tasks_table.sql (một gạch dưới). App khởi động, không có lỗi, nhưng table tasks không được tạo. Tại sao? Sửa thế nào?

Flyway nhận diện migration script hoàn toàn qua tên file theo pattern V<version>__<desc>.sql với double underscore. File V3_add_tasks_table.sql (single underscore) không khớp pattern → Flyway bỏ qua hoàn toàn, không throw warning hay error.

App khởi động thành công vì Flyway không thấy migration pending — nó đơn giản không nhận ra file đó là migration. Hibernate validate sau đó báo lỗi SchemaExportException hoặc app chạy nhưng query tới tasks throw Table "tasks" does not exist.

Fix: đổi tên file thành V3__add_tasks_table.sql (double underscore). Nếu app đang chạy local, Flyway sẽ detect V3 là pending và apply ngay lần restart tiếp.

Q5
Tại sao pattern ddl-auto: validate + Flyway tốt hơn chỉ dùng Flyway một mình (với ddl-auto: none)?

ddl-auto: none — Hibernate không làm gì với schema. App vẫn chạy ngay cả khi entity mapping và DB schema lệch nhau (ví dụ entity thêm field nhưng quên viết migration). Lỗi chỉ xuất hiện khi query thực sự chạy đến column đó — có thể là lúc production, không phải startup.

ddl-auto: validate — Hibernate so sánh entity mapping với schema hiện tại ngay khi khởi động. Nếu entity có field priority nhưng DB column chưa tồn tại (migration bị quên), app không start ngay lập tức với message rõ ràng.

Kết hợp: Flyway đảm bảo schema đúng version, Hibernate validate đảm bảo entity Java match schema đó. Hai lớp fail-fast bổ sung nhau — lỗi lộ ra lúc deploy chứ không phải lúc user đang dùng.

Bài tiếp theo: Migration patterns & CI/CD

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

Đặt 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