Backend

99 Ngày Spring — Ngày 34: Flyway — version hóa schema

SSite Admin
30 tháng 08, 2026 9 phút đọc 0 lượt xem
99 Ngày Spring — Ngày 34: Flyway — version hóa schema

Từ Ngày 21 đến nay, mọi bài JPA đều đứng trên một chân co: ddl-auto để Hibernate tự vẽ bảng. Tiện cho học — và là thảm họa được hẹn giờ cho production: cột rác âm thầm, schema không ai review, ba môi trường ba phiên bản. Hôm nay ta trưởng thành hóa tầng schema bằng Flyway: mỗi thay đổi là một file SQL có version, có review, có lịch sử — schema được đối xử đúng như code. Kèm ba quy tắc viết migration sống còn và các tình huống thực tế: baseline, fail giữa chừng, backfill lớn.

Sketchnote Ngày 34: Flyway migration — vì sao ddl-auto nguy hiểm, quy ước V__ và flyway_schema_history, ba quy tắc migration, expand-contract và phân vai với JPA

Vì sao ddl-auto là thảm họa production

# application.yml quen thuộc của dev:
spring:
  jpa:
    hibernate:
      ddl-auto: update      # sửa entity → Hibernate tự sửa bảng. Tiện thật!

# Vì sao "tiện thật" là thảm họa ở production?

# 1. update chỉ THÊM — không bao giờ XÓA/ĐỔI TÊN
#    đổi tên field tieuDe → title: Hibernate tạo CỘT MỚI title,
#    cột tieu_de cũ (và toàn bộ dữ liệu trong đó) nằm lại thành rác — âm thầm

# 2. Không ai REVIEW được thay đổi schema
#    ALTER TABLE chạy lúc app boot — không qua code review, không có diff

# 3. Không có lịch sử, không rollback
#    schema hiện tại là "tổng của mọi lần boot" — không ai biết đường quay lại

# 4. Drift giữa các môi trường
#    dev boot 50 lần, staging 10, production 3 → BA schema khác nhau
#    "chạy ở máy em mà" phiên bản database

# 5. Nhiều instance cùng boot (scale ngang!) cùng ALTER một bảng → race

# Kết luận: schema là CODE — phải được version, review, và chạy CÓ CHỦ ĐÍCH
# ddl-auto chỉ còn một chỗ đứng ở production: validate (kiểm, không sửa)
  • Cái bẫy lớn nhất của update: nó chỉ thêm, không bao giờ xóa/đổi tên — refactor entity xong, cột cũ và dữ liệu trong đó nằm lại thành rác không ai hay; cộng với không review, không lịch sử, không đường lui.

  • Drift là án tử từ từ: mỗi môi trường boot số lần khác nhau → schema khác nhau — bug "chỉ xảy ra trên staging" ra đời từ đây; và khi scale ngang (bài System Design!), nhiều instance cùng boot cùng ALTER là race condition tầng schema.

  • Kết luận nền của cả bài: schema là code — phải được version, review, chạy có chủ đích; ddl-auto chỉ giữ lại một vai trò tử tế: validate.

Flyway — schema có git log của riêng nó

# Flyway — version hóa schema như version hóa code

# 1. Thêm dependency (Boot tự cấu hình phần còn lại):
#    org.flywaydb:flyway-core
#    (PostgreSQL cần thêm: org.flywaydb:flyway-database-postgresql)

# 2. Migration = file SQL trong src/main/resources/db/migration:
db/migration/
├── V1__tao_bang_tasks.sql
├── V2__them_cot_status.sql
├── V3__tao_bang_users_va_fk.sql
└── V4__index_tasks_created_at.sql

# Quy ước tên NGHIÊM NGẶT: V<số>__<mô_tả>.sql  (HAI dấu gạch dưới!)
# V1__init.sql ✅   V1_init.sql ❌ (một gạch — Flyway lờ đi không chạy)

-- V2__them_cot_status.sql  SQL thuần, DB nào hiểu DB đó:
ALTER TABLE tasks ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'TODO';
CREATE INDEX idx_tasks_status ON tasks(status);

# 3. App boot: Flyway so bảng flyway_schema_history với thư mục migration
#    → chạy những version CHƯA áp dụng, theo đúng thứ tự, ghi lại lịch sử
# flyway_schema_history: version | description | checksum | installed_on | success
# → schema giờ có git log của riêng nó — ai, cái gì, khi nào, thành công không

# 4. Và tắt vòi ddl-auto:
spring:
  jpa:
    hibernate:
      ddl-auto: validate    # entity lệch schema → FAIL NGAY lúc boot, không âm thầm
  • Mô hình gọn: mỗi thay đổi = một file V<số>__<mô_tả>.sql — Flyway so với bảng flyway_schema_history lúc boot, chạy phần còn thiếu theo đúng thứ tự và ghi sổ: schema có lịch sử ai-cái-gì-khi-nào như git log.

  • Chi tiết hay vấp nhất: quy ước tên hai dấu gạch dưới V1__init.sql — một gạch là Flyway lờ đi không báo; và migration là SQL thuần của đúng DB bạn dùng — không tầng trừu tượng nào che nữa.

  • Cặp đôi hoàn hảo: Flyway nắm schema + ddl-auto: validate làm trọng tài — entity lệch schema là fail ngay lúc boot với thông báo rõ ràng, thay vì âm thầm chạy sai.

Ba quy tắc viết migration

-- Ba quy tắc viết migration sống còn

-- QUY TẮC 1: migration đã chạy là BẤT BIẾN (Ngày 29 của database!)
-- Flyway lưu checksum từng file — sửa V2 đã áp dụng là app FAIL lúc boot:
-- "Migration checksum mismatch for version 2"
-- Sai ở V2? → viết V5 sửa lại. Lịch sử chỉ tiến, không viết lại.

-- QUY TẮC 2: mỗi migration NHỎ và một mục đích
-- V7__them_cot_priority.sql        ✅ một việc, đọc phát hiểu
-- V8__doi_moi_thu.sql (200 dòng)   ❌ hỏng giữa chừng là dở dang khó lần

-- QUY TẮC 3: tương thích lùi khi rolling deploy (code CŨ chạy chung schema MỚI)
-- Đổi tên cột tieu_de → title KHÔNG được làm một phát:
--   V9:  ADD COLUMN title; copy dữ liệu; code mới ghi CẢ HAI, đọc title
--   (deploy xong, ổn định)
--   V10: DROP COLUMN tieu_de  — khi không còn code nào đọc nó
-- Tên tuổi: expand → migrate → contract

-- Bonus — Repeatable migration cho view/function (chạy lại MỖI KHI file đổi):
-- R__bao_cao_task_theo_tuan.sql
CREATE OR REPLACE VIEW bao_cao_tuan AS
SELECT date_trunc('week', created_at) AS tuan, count(*) AS so_task
FROM tasks GROUP BY 1;
  • Quy tắc 1 — bất biến (Ngày 29 phiên bản database): file đã chạy có checksum, sửa là app fail boot; sai thì viết version mới sửa lại — lịch sử chỉ tiến, không viết lại, đúng triết lý event sourcing.

  • Quy tắc 3 đáng giá nhất cho hệ thống thật: expand → migrate → contract — rolling deploy nghĩa là code cũ chạy chung schema mới vài phút, nên đổi tên/xóa cột phải tách hai đợt: thêm trước, dọn sau khi không ai còn đọc.

  • R__ (repeatable) cho view/function: chạy lại mỗi khi nội dung file đổi — chỗ đứng riêng cho những object "định nghĩa lại được", khỏi đẻ version mới cho mỗi lần sửa view.

Flyway + JPA — phân vai và tình huống thực tế

# Flyway + JPA — phân vai và các tình huống thực tế

# PHÂN VAI từ hôm nay:
#   Entity (@Entity, @Column)  → mô tả cách CODE nhìn dữ liệu
#   Migration (V*.sql)         → quyết định schema THẬT
#   ddl-auto: validate         → trọng tài: lệch nhau là fail sớm
# Quy trình đổi schema: viết migration TRƯỚC → sửa entity khớp theo → validate gật đầu

# DB đã có sẵn bảng (dự án đang chạy)? — baseline:
spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1        # coi schema hiện tại là V1, chạy từ V2 trở đi

# Migration fail giữa chừng?
# - PostgreSQL: DDL nằm trong transaction → tự rollback, sửa file, chạy lại ✅
# - MySQL: DDL KHÔNG transactional → có thể kẹt dở dang
#   → flyway repair (dọn record fail) + tự dọn phần đã chạy, rồi chạy lại

# Data migration lớn (backfill hàng triệu dòng)?
# → ĐỪNG nhét vào migration boot (giữ lock, boot treo hàng phút)
# → migration chỉ đổi CẤU TRÚC; backfill chạy batch riêng ngoài giờ cao điểm

# Test migration bằng Testcontainers (Ngày 10 phần Spring... và khóa Kafka buổi 10):
# @SpringBootTest với Postgres container thật → Flyway chạy TOÀN BỘ V1→Vn từ đầu
# → CI bắt lỗi migration TRƯỚC khi nó chạm production — lưới an toàn cuối cùng
  • Phân vai mới từ hôm nay: migration quyết định schema thật, entity chỉ mô tả cách code nhìn dữ liệu, validate bắt lệch — quy trình đổi schema luôn là: migration trước, entity theo sau.

  • Ba tình huống thực chiến: DB có sẵn → baseline; fail giữa chừng → PostgreSQL tự rollback (DDL transactional) còn MySQL cần flyway repair + dọn tay; backfill lớn tách khỏi migration boot — migration đổi cấu trúc, dữ liệu chạy batch riêng.

  • Lưới an toàn cuối: test migration bằng Testcontainers — CI dựng Postgres thật, Flyway chạy V1→Vn từ đầu: lỗi thứ tự, lỗi syntax, lỗi thiếu default đều lộ trước khi chạm production.

Checklist schema trưởng thành

  • Production: ddl-auto: validate + Flyway — không ngoại lệ; dev nghịch nhanh có thể update nhưng tốt nhất là đồng nhất luôn cho đỡ lệch thói quen.

  • Mỗi PR đổi schema chứa: file V* mới + entity cập nhật — reviewer đọc SQL thật, không đoán qua annotation.

  • Đặt tên mô tả rõ: V7__them_cot_priority_vao_tasks.sql — bảng history tự thành tài liệu.

  • Đổi cột đang dùng: luôn expand → migrate → contract; không bao giờ DROP/RENAME một phát khi đang rolling deploy.

  • Backfill dữ liệu lớn: job riêng, ngoài giờ cao điểm, idempotent (chạy lại không hỏng — Ngày 32 System Design… à nhầm, buổi 7 khóa System Design 😉).

Bài tập nhỏ

  • Chuyển checkpoint Ngày 30 sang Flyway: viết V1__tao_bang_tasks.sql từ schema hiện có (copy từ log ddl-auto sinh ra), bật validate, xác nhận boot sạch.

  • Viết V2 thêm cột priority kèm index — rồi cố tình sửa V2 sau khi chạy để thấy lỗi checksum, và sửa đúng cách bằng V3.

  • Thực hành expand→contract: đổi tên mo_tadescription qua 2 migration + 1 lần đổi code, mô phỏng rolling deploy.

  • Tạo R__thong_ke_theo_status.sql (view) — sửa nội dung và xác nhận Flyway tự chạy lại.

  • Viết test Testcontainers dựng Postgres và boot app — đổi một migration thành SQL sai để thấy CI đỏ đúng chỗ.

Kết luận

Bốn ý gói bài: ddl-auto: update tiện dev nhưng không review, không lịch sử, tự drift — production chỉ còn validate; Flyway biến schema thành code với V__ + flyway_schema_history — git log của database; ba quy tắc: bất biến, nhỏ gọn, expand → contract cho rolling deploy; và phân vai chuẩn — migration quyết schema, entity mô tả, Testcontainers gác cổng CI. Ngày 35 ta bước xuống một tầng trừu tượng: JdbcTemplate & JdbcClient — khi nào bỏ JPA viết SQL tay, và API JdbcClient mới gọn đến bất ngờ. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 36: Queue, Deque & Stack

FIFO xếp hàng và LIFO chồng đĩa, hai bộ method offer/poll/peek, ArrayDeque mảng vòng một mình hai vai (và bản án cho class Stack) — cùng PriorityQueue trên binary heap với bài toán top-K.

1 thg 9, 20269 phút6
99 Ngày Spring — Ngày 36: Caching với @Cacheable

Tốc độ lớn nhất là không truy vấn: cache abstraction với @Cacheable, key SpEL và hai luật sống còn, bộ đôi @CacheEvict/@CachePut giữ cache thật, TTL với Caffeine — và bốn cái bẫy caching.

1 thg 9, 202610 phút4
99 Ngày Java — Ngày 35: TreeMap & LinkedHashMap

Cây đỏ-đen giữ key luôn sắp xếp với họ câu hỏi "gần nhất" của NavigableMap, sợi chỉ giữ thứ tự chèn của LinkedHashMap — và ảo thuật LRU cache trong mười dòng với removeEldestEntry.

31 thg 8, 20269 phút0