Database migration: Flyway, Liquibase và zero downtime

4 câu hỏi~25 phútTừ khóa: schema migration, ddl-auto, validate, Flyway, V1__init.sql, flyway_schema_history, Liquibase changelog, expand-contract

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:

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-dropDrop rồi tạo lại schemaMất dữ liệu
updateThêm bảng/cột còn thiếuKhô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
validateSo entity với schema, lệch thì fail khởi độngDùng được
noneKhô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.

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=updateFlywayLiquibase
Định dạngKhông có scriptSQL (hoặc Java)YAML/XML/JSON/SQL
Review trong PRKhôngCóCó
Lịch sửKhôngflyway_schema_historyDATABASECHANGELOG
RollbackKhôngViết migration mới (undo là bản trả phí)Khai báo rollback trong changeSet
Đa databaseCóViết SQL theo DBChange type trừu tượng
Độ phức tạpThấp, nguy hiểmThấpTrung bình

7. Khi nào nên dùng / không nên dùng

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:

update chỉ 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:

update only 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 on validate.

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


Tổng kết chương Spring Data JPA (4.3.1–4.3.12)

BàiChủ đềÝ chính cần nhớ
4.3.1JPA, Hibernate, Spring DataJPA là chuẩn, Hibernate là implementation, Spring Data JPA là lớp repository bên trên.
4.3.2Entity state & persistence contextTransient / managed / detached / removed; dirty checking, first-level cache, flush ≠ commit.
4.3.3@Transactional và các bẫyProxy: self-invocation, method private không có transaction; checked exception không rollback.
4.3.4Propagation & rollbackREQUIRED vs REQUIRES_NEW; rollback-only; rollbackFor.
4.3.5Quan hệ entityOwning side, mappedBy, cascade, orphanRemoval, tránh ManyToMany thô.
4.3.6N+1 query1 query cha + N query con; JOIN FETCH, EntityGraph, batch fetch, DTO.
4.3.7Lazy loadingto-One mặc định EAGER → đặt LAZY; LazyInitializationException; tắt open-in-view.
4.3.8Query method, @Query, SpecificationDerived → JPQL/native → Specification; projection; @Modifying bỏ qua context.
4.3.9Phân trang & sortPage (có COUNT) vs Slice; OFFSET sâu chậm → keyset; không fetch join collection khi phân trang.
4.3.10Locking trong JPA@Version + retry ngoài transaction; PESSIMISTIC_WRITE + lock timeout; atomic update.
4.3.11Batch insert & hiệu năngIDENTITY tắt batching → SEQUENCE + batch_size + flush/clear; JdbcTemplate, COPY.
4.3.12Flyway & LiquibaseKhô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).