Database migration: Flyway, Liquibase và zero downtime
Tiếp đi: quản lý migration database.
Bài cuối của chương JPA. Đến giờ bảng orders, order_items, products, users và product_seq của shop-app "tự có". Lên production thì không được như vậy.
1. Bài toán
Team 4 người, 3 môi trường (dev, staging, prod). Sprint này cần:
- Thêm cột
versioncho products (Bài 4.3.10). - Tạo
product_seq INCREMENT BY 50(Bài 4.3.11). - Đổi tên cột
orders.notethànhorders.customer_note.
Ai chạy SQL? Chạy ở môi trường nào rồi? Theo thứ tự nào?
2. Vấn đề: ddl-auto
| Giá trị | Làm gì | Production |
|---|---|---|
create / create-drop | Drop rồi tạo lại schema | Mất dữ liệu |
update | Thêm bảng/cột còn thiếu | Không xóa/đổi tên cột, không migrate dữ liệu, không review được, có thể lệch giữa các môi trường |
validate | So entity với schema, lệch thì fail khởi động | Dùng được |
none | Không làm gì | Dùng được |
Ví dụ đổi tên field note → customerNote với ddl-auto: update: Hibernate tạo cột mới rỗng, cột cũ vẫn chứa dữ liệu. Code đọc cột mới → dữ liệu "biến mất".
3. Giải pháp: migration có version
Schema thay đổi bằng các script có version, nằm trong git, được tool chạy tự động lúc app khởi động (hoặc trong pipeline), và ghi lại đã chạy script nào.
- Flyway: file SQL thuần, đặt tên theo version. Đơn giản.
- Liquibase: changelog (YAML/XML/SQL) gồm các changeSet, có rollback, có precondition, hỗ trợ nhiều DB.
4. Code từng bước
Bước 1: Dependency (Flyway)
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
Bước 2: Config
spring:
jpa:
hibernate:
ddl-auto: validate # Hibernate chỉ kiểm tra, không sửa schema
flyway:
enabled: true
locations: classpath:db/migration
datasource:
url: jdbc:postgresql://localhost:5432/shop
username: ${DB_USER}
password: ${DB_PASSWORD}
Bước 3: Script versioned
src/main/resources/db/migration/
├── V1__init_schema.sql
├── V2__add_product_version.sql
├── V3__product_sequence.sql
└── V4__add_order_customer_note.sql
-- V1__init_schema.sql
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price NUMERIC(19,2) NOT NULL,
stock INT NOT NULL CHECK (stock >= 0)
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
status VARCHAR(20) NOT NULL,
note TEXT
);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL
);
CREATE INDEX idx_order_items_order ON order_items(order_id);
-- V2__add_product_version.sql
ALTER TABLE products ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
-- V3__product_sequence.sql
CREATE SEQUENCE product_seq START WITH 1 INCREMENT BY 50;
Quy tắc vàng: script đã chạy ở môi trường chung thì không sửa. Flyway lưu checksum; sửa file cũ → validate fail. Muốn đổi thì viết V tiếp theo.
Bước 3b: Cùng thay đổi bằng Liquibase
# src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
- changeSet:
id: 2-add-product-version
author: shop-team
changes:
- addColumn:
tableName: products
columns:
- column:
name: version
type: BIGINT
defaultValueNumeric: 0
constraints:
nullable: false
rollback:
- dropColumn:
tableName: products
columnName: version
spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
Bước 4: Test
./mvnw spring-boot:run
# Migrating schema "public" to version "3 - product sequence"
psql -c "select version, description, success from flyway_schema_history"
# 1 | init schema | t
# 2 | add product version | t
# 3 | product sequence | t
Test tích hợp với Testcontainers PostgreSQL chạy cùng các migration này, nên schema test = schema prod.
5. Cách hoạt động
App khởi động
│
▼
Flyway: khóa bảng flyway_schema_history (nhiều instance không chạy trùng)
│ đọc version hiện tại = 2
│ quét db/migration: V1, V2, V3, V4
│ validate checksum V1, V2
▼
chạy V3 → ghi lịch sử → chạy V4 → ghi lịch sử
│
▼
EntityManagerFactory khởi tạo, ddl-auto=validate kiểm tra entity khớp schema
│
▼
App nhận request
Expand-contract (parallel change) cho zero downtime. Khi rolling deploy, bản cũ và bản mới của app chạy cùng lúc trên cùng database, nên mỗi migration phải tương thích với cả hai. Đổi tên note → customer_note:
Release 1 (EXPAND)
V4: ALTER TABLE orders ADD COLUMN customer_note TEXT; -- nullable, không khóa lâu
App v2: ghi CẢ HAI cột, đọc customer_note (fallback note)
Release 1b (MIGRATE)
V5: UPDATE orders SET customer_note = note
WHERE customer_note IS NULL AND id BETWEEN ? AND ?; -- chạy theo lô (job)
Release 2 (SWITCH)
App v3: chỉ đọc/ghi customer_note
Release 3 (CONTRACT) -- khi chắc không còn instance v2
V6: ALTER TABLE orders DROP COLUMN note;
Một câu RENAME COLUMN thì nhanh, nhưng app cũ đang chạy sẽ lỗi ngay vì không thấy cột note.
6. So sánh
| ddl-auto=update | Flyway | Liquibase | |
|---|---|---|---|
| Định dạng | Không có script | SQL (hoặc Java) | YAML/XML/JSON/SQL |
| Review trong PR | Không | Có | Có |
| Lịch sử | Không | flyway_schema_history | DATABASECHANGELOG |
| Rollback | Không | Viết migration mới (undo là bản trả phí) | Khai báo rollback trong changeSet |
| Đa database | Có | Viết SQL theo DB | Change type trừu tượng |
| Độ phức tạp | Thấp, nguy hiểm | Thấp | Trung bình |
7. Khi nào nên dùng / không nên dùng
ddl-auto: create-dropchỉ cho prototype/test nhanh; production luônvalidatehoặcnone.- Flyway: team quen SQL, một loại DB (PostgreSQL). Đa số dự án Spring Boot chọn cái này.
- Liquibase: hỗ trợ nhiều DB, cần rollback khai báo, precondition, context theo môi trường.
- Migration chạy lâu (backfill hàng triệu dòng, tạo index lớn): tách khỏi startup, dùng
CREATE INDEX CONCURRENTLY(Flyway cần tắt transaction cho script đó). - Không sửa script đã chạy; không
DROP COLUMNcùng release với code ngừng dùng cột.
Lỡ V3 chạy lỗi trên prod giữa chừng thì sao?
Với PostgreSQL thì nhẹ hơn bạn nghĩ: PostgreSQL hỗ trợ transactional DDL, Flyway chạy mỗi script trong một transaction, lỗi là rollback sạch, flyway_schema_history không ghi thành công, app không khởi động. Sửa script (chưa chạy thành công ở đâu thì được sửa) rồi deploy lại.
Với MySQL thì DDL tự commit, có thể dừng giữa chừng; khi đó phải sửa tay rồi flyway repair. Đây là lý do nên giữ mỗi script nhỏ, một thay đổi.
3 instance cùng khởi động thì Flyway có chạy migration 3 lần không?
Không. Flyway lấy lock trên database (advisory lock ở PostgreSQL) trước khi migrate. Instance đầu chạy, hai instance còn lại chờ, rồi thấy version đã mới nhất nên bỏ qua. Dù vậy, nhiều team vẫn chạy migration thành một bước riêng trong pipeline (Kubernetes Job, init container) để startup app nhanh và kiểm soát được thời điểm.
Phỏng vấn hỏi database migration thì trả lời sao?
Q1. Vì sao không dùng ddl-auto=update trên production?
🇻🇳 Tiếng Việt:
updatechỉ thêm bảng và cột thiếu, không xóa hay đổi tên, không migrate dữ liệu, không có lịch sử và không review được, nên các môi trường dễ lệch nhau. Đổi tên field còn tạo cột mới rỗng. Em dùng Flyway hoặc Liquibase với script có version trong git, và để Hibernate ởvalidate.
🇬🇧 English:
updateonly adds missing tables and columns. It never drops or renames, does not migrate data, keeps no history and cannot be reviewed, so environments drift apart. Renaming a field even creates a new empty column. I use Flyway or Liquibase with versioned scripts in git and keep Hibernate onvalidate.
Q2. Flyway và Liquibase khác nhau thế nào?
🇻🇳 Tiếng Việt:
Cả hai đều quản lý schema bằng migration có version và bảng lịch sử. Flyway dùng file SQL đặt tên
V1__desc.sql, đơn giản, rollback bằng migration mới. Liquibase dùng changelog gồm changeSet ở YAML/XML/SQL, có rollback khai báo, precondition và trừu tượng nhiều database. Với một dự án PostgreSQL em thường chọn Flyway.
🇬🇧 English:
Both manage the schema with versioned migrations and a history table. Flyway uses SQL files named like
V1__desc.sql, is simple, and rolls back with a new forward migration. Liquibase uses a changelog of changeSets in YAML, XML or SQL, with declarative rollback, preconditions and database-agnostic change types. For a single PostgreSQL project I usually pick Flyway.
Q3. Đổi tên cột mà không downtime thế nào?
🇻🇳 Tiếng Việt:
Em dùng expand-contract vì khi rolling deploy, phiên bản cũ và mới chạy cùng lúc. Expand: thêm cột mới nullable, code mới ghi cả hai cột. Migrate: backfill dữ liệu theo lô. Switch: code chỉ dùng cột mới. Contract: ở release sau mới drop cột cũ. Mỗi bước đều tương thích ngược.
🇬🇧 English:
I use expand and contract, because during a rolling deploy the old and new versions run at the same time. Expand: add the new nullable column and have the new code write both columns. Migrate: backfill the data in batches. Switch: the code uses only the new column. Contract: drop the old column in a later release. Every step stays backward compatible.
Cách nhớ ngắn gọn
- Prod:
ddl-auto=validate, schema do Flyway/Liquibase quản lý. - Script đã chạy = bất biến; sửa bằng V tiếp theo.
- Flyway = SQL đơn giản; Liquibase = changelog + rollback + đa DB.
- Zero downtime: expand → migrate → switch → contract.
Tổng kết chương Spring Data JPA (4.3.1–4.3.12)
| Bài | Chủ đề | Ý chính cần nhớ |
|---|---|---|
| 4.3.1 | JPA, Hibernate, Spring Data | JPA là chuẩn, Hibernate là implementation, Spring Data JPA là lớp repository bên trên. |
| 4.3.2 | Entity state & persistence context | Transient / managed / detached / removed; dirty checking, first-level cache, flush ≠ commit. |
| 4.3.3 | @Transactional và các bẫy | Proxy: self-invocation, method private không có transaction; checked exception không rollback. |
| 4.3.4 | Propagation & rollback | REQUIRED vs REQUIRES_NEW; rollback-only; rollbackFor. |
| 4.3.5 | Quan hệ entity | Owning side, mappedBy, cascade, orphanRemoval, tránh ManyToMany thô. |
| 4.3.6 | N+1 query | 1 query cha + N query con; JOIN FETCH, EntityGraph, batch fetch, DTO. |
| 4.3.7 | Lazy loading | to-One mặc định EAGER → đặt LAZY; LazyInitializationException; tắt open-in-view. |
| 4.3.8 | Query method, @Query, Specification | Derived → JPQL/native → Specification; projection; @Modifying bỏ qua context. |
| 4.3.9 | Phân trang & sort | Page (có COUNT) vs Slice; OFFSET sâu chậm → keyset; không fetch join collection khi phân trang. |
| 4.3.10 | Locking trong JPA | @Version + retry ngoài transaction; PESSIMISTIC_WRITE + lock timeout; atomic update. |
| 4.3.11 | Batch insert & hiệu năng | IDENTITY tắt batching → SEQUENCE + batch_size + flush/clear; JdbcTemplate, COPY. |
| 4.3.12 | Flyway & Liquibase | Không ddl-auto=update ở prod; migration versioned; expand-contract. |
Tự kiểm tra
Đổi tên field entity với ddl-auto=update chuyện gì xảy ra?
Hibernate tạo cột mới rỗng, cột cũ vẫn giữ dữ liệu; code đọc cột mới nên mất dữ liệu về mặt logic.
Sửa nội dung V2 đã chạy trên staging thì sao?
Checksum lệch, Flyway validate fail khi khởi động. Phải viết V tiếp theo.
Production nên đặt ddl-auto là gì?
validate (hoặc none).
Vì sao không RENAME COLUMN trong rolling deploy?
Instance bản cũ vẫn đang chạy và truy vấn tên cột cũ, sẽ lỗi ngay.
Bốn bước expand-contract?
Expand (thêm cột, ghi cả hai) → migrate (backfill theo lô) → switch (chỉ dùng cột mới) → contract (drop cột cũ ở release sau).