SQL & Database — Tư tưởng & Nguyên lý/Schema migration & evolution — versioned migration & expand-contract
34/51
Bài 34 / 51~20 phútSchema designMiễn phí lượt xem

Schema migration & evolution — versioned migration & expand-contract

Versioned migration forward-only, vì sao cần (team/CI/CD/reproducible), rollback vs roll-forward, expand-contract & online DDL zero-downtime. Tool landscape — agnostic.

TL;DR: Schema không bất biến — nó tiến hoá cùng app. Versioned migration biến mỗi thay đổi schema thành một bước có version, forward-only, được commit vào repo và apply theo thứ tự ở mọi environment — nhờ đó schema reproducible, deploy không bị "quên migration". Rollback hiếm khả thi (mất data) nên thực chiến ưu tiên roll-forward (viết migration mới sửa lỗi). Thay đổi schema trên bảng lớn đang chạy cần expand-contractonline DDL (thêm cột nullable → backfill → swap → drop) để tránh lock bảng gây downtime. Nhiều tool (Flyway, Liquibase, Alembic, Atlas, Rails migrations) hiện thực cùng ý tưởng versioned migration. Nguyên lý agnostic, đúng cho mọi RDBMS.

Dev local thêm column priority vào bảng tasks. Commit, push. Code review pass. Merge vào main. CD pipeline deploy production. Nhưng thay đổi schema không được ghi thành một bước migration trong repo — chỉ áp tay trên máy dev. Kết quả: production thiếu column, app ném lỗi column "priority" does not exist, on-call thức lúc 2 giờ sáng.

Schema migration tồn tại để ngăn đúng kịch bản này: biến mỗi thay đổi schema thành một bước có version được commit cùng code, đảm bảo mọi environment chạy đúng tập migration theo đúng thứ tự, và làm cho trạng thái schema reproducible. Bài này giải thích nguyên lý versioned migration, vì sao roll-forward thắng rollback, pattern expand-contract cho zero-downtime, và landscape các tool — tất cả agnostic, không gắn cú pháp một tool nào.

1. Analogy — Sổ cái kế toán không tẩy xoá

Hình dung một sổ cái kế toán: mỗi giao dịch là một dòng được ghi thêm, có số thứ tự, không bao giờ tẩy xoá dòng cũ. Muốn sửa một sai sót, kế toán không xoá dòng cũ mà ghi thêm một bút toán điều chỉnh. Lịch sử đầy đủ luôn được giữ lại — ai cũng dựng lại được trạng thái tài khoản tại bất kỳ thời điểm bằng cách replay các dòng theo thứ tự.

Versioned migration hoạt động hệt như vậy: mỗi thay đổi schema là một "dòng" có version, append vào lịch sử, không sửa migration đã apply. Muốn sửa, viết một migration mới (giống bút toán điều chỉnh). Apply lần lượt theo version → mọi environment đến cùng một trạng thái schema.

Sổ cái kế toánVersioned migration
Mỗi dòng có số thứ tựMỗi migration có version (V001, V002...)
Không tẩy xoá dòng cũKhông sửa migration đã apply
Sửa bằng bút toán điều chỉnhSửa bằng migration mới (roll-forward)
Replay dòng → dựng lại số dưApply migration theo version → dựng lại schema
Sổ là source of truthTập migration là source of truth của schema
💡 Cách nhớ

Migration = sổ cái schema, append-only. Đã apply thì không sửa — muốn đổi thì ghi thêm bước mới. Forward-only + reproducible là cốt lõi; tool chỉ là phương tiện ghi và replay sổ.

2. Versioned migration — nguyên lý và vì sao cần

Mỗi thay đổi schema được viết thành một file/bước có version, commit cùng code, và một bảng metadata trong DB ghi lại những version đã apply:

-- V001__create_users.sql
CREATE TABLE users (
  id    BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email VARCHAR(254) UNIQUE NOT NULL
);

-- V002__add_users_name.sql
ALTER TABLE users ADD COLUMN name VARCHAR(200);

-- V003__add_email_index.sql
CREATE INDEX idx_users_email ON users(email);

Cơ chế chung của mọi migration tool:

  • Mỗi migration có version (thường số tăng dần hoặc timestamp) + tên mô tả.
  • Tool giữ một bảng lịch sử trong DB (vd schema_migrations) ghi version nào đã apply.
  • Khi chạy, tool apply các migration chưa apply theo thứ tự version tăng dần.
  • Re-run là idempotent: migration đã apply thì bỏ qua (nhận diện qua version, thường kèm checksum để phát hiện file bị sửa sau khi apply).

Bốn lý do versioned migration là bắt buộc cho team thực:

  • Team: nhiều dev đổi schema song song — version + lịch sử trong repo cho thấy ai đổi gì, theo thứ tự nào, tránh xung đột âm thầm.
  • CI/CD: pipeline apply đúng tập migration ở mọi environment (dev → staging → prod) — không còn "quên chạy migration" hay "máy dev khác production".
  • Reproducible: từ một DB rỗng, replay toàn bộ migration → dựng lại đúng schema hiện tại. Dùng cho test, môi trường mới, disaster recovery.
  • Audit: lịch sử schema nằm trong version control — diff và blame được như mọi code khác.
📝 Note dialect — DDL transactional

Một chi tiết khác nhau giữa engine: có engine cho phép DDL chạy trong transaction (rollback được nếu migration fail giữa chừng), có engine thì DDL auto-commit từng statement (fail giữa chừng để lại schema nửa vời). Khi viết migration, biết engine của mình thuộc loại nào để chia migration thành các bước an toàn nếu cần. Bản thân ý tưởng versioned migration không đổi.

3. Rollback vs roll-forward — vì sao forward thắng

Khi một migration gây lỗi ở production, có hai hướng xử lý:

  • Rollback (đảo ngược): chạy thao tác ngược để quay về schema cũ. Vấn đề: nhiều thay đổi không đảo ngược được mà không mất dataDROP COLUMN đã xoá data, DROP TABLE đã mất bảng. Viết "down migration" cho mọi bước vừa tốn công vừa nguy hiểm (down migration cũng có thể có bug).
  • Roll-forward (sửa tới): giữ nguyên lịch sử, viết một migration mới sửa vấn đề. Giống bút toán điều chỉnh — không đụng vào quá khứ.
-- Migration loi: V010 vo tinh dat NOT NULL ma chua backfill
-- V010__set_status_not_null.sql
ALTER TABLE tasks ALTER COLUMN status SET NOT NULL;  -- fail: con row status NULL

-- ROLL-FORWARD: viet V011 sua thay vi rollback V010
-- V011__backfill_then_not_null.sql
UPDATE tasks SET status = 'todo' WHERE status IS NULL;
ALTER TABLE tasks ALTER COLUMN status SET NOT NULL;
Pitfall — dựa vào rollback như lưới an toàn

Coi rollback là "nút undo" là nguy hiểm: với thao tác destructive (drop column/table), data đã mất — rollback không khôi phục được. Nhiều migration tool và nhiều team chủ trương forward-only. Lưới an toàn thật sự là: test migration trên staging có data thật, và thiết kế thay đổi theo expand-contract (Section 4) để mỗi bước nhỏ đều an toàn và backward-compatible.

Thực chiến: thiết kế để không cần rollback. Mỗi migration nhỏ, backward-compatible, test trước trên staging. Khi có sự cố, roll-forward bằng migration mới.

4. Expand-contract — backward-compatible migration

Thay đổi schema phá vỡ (rename column, đổi type, thêm NOT NULL) gây hai vấn đề: (1) app version cũ và mới chạy song song trong lúc deploy, schema phải tương thích cả hai; (2) thao tác trên bảng lớn có thể lock. Expand-contract giải cả hai bằng cách tách thành các bước nhỏ backward-compatible.

Ví dụ: rename tasks.nametasks.title không downtime.

EXPAND (tương thích ngược):
  1. Them column moi `title` (nullable) -- khong dung gi cu
  2. Backfill: copy `name` -> `title` theo batch (tranh lock lon)
  3. App ghi ca hai cot (double-write), doc tu `title`

MIGRATE:
  4. Verify `title` day du, app on dinh tren cot moi

CONTRACT (don dep):
  5. App ngung ghi `name`
  6. Drop column `name` (sau khi chac chan khong con ai doc)

Mỗi bước đều để schema ở trạng thái mà cả app cũ lẫn app mới đều chạy được — nên deploy theo kiểu rolling (thay từng instance) không gãy. Đây là điểm mấu chốt: thay đổi lớn được chia thành chuỗi thay đổi nhỏ tương thích ngược.

flowchart LR
  E1["Expand:<br/>them cot moi nullable"] --> E2["Backfill theo batch"]
  E2 --> E3["Double-write,<br/>doc cot moi"]
  E3 --> M["Verify on dinh"]
  M --> C1["Contract:<br/>ngung ghi cot cu"]
  C1 --> C2["Drop cot cu"]

5. Online / zero-downtime DDL — tránh lock bảng lớn

Một số DDL khoá bảng đủ lâu để gây downtime trên bảng lớn. Nguyên lý chung (chi tiết và mức độ tuỳ engine):

  • Thêm column nullable, không default tốn kém: thường là metadata-only, instant. An toàn nhất khi expand.
  • Thêm column NOT NULL có default: tuỳ engine và version — có engine làm metadata-only (instant), engine/version cũ phải rewrite toàn bảng (lock lâu). Cách an toàn portable: thêm nullable → backfill → set NOT NULL sau.
  • Tạo index trên bảng lớn: build index có thể lock write. Nhiều engine có chế độ build index không khoá write (tên/cú pháp là dialect) — ưu tiên dùng cho bảng đang có traffic.
  • Thêm FOREIGN KEY: validate ràng buộc phải quét toàn bảng, giữ lock. Pattern an toàn: thêm constraint ở trạng thái "chưa validate" rồi validate riêng thành bước sau (cơ chế tên gọi tuỳ engine).
  • Backfill data: chia thành nhiều batch nhỏ trong nhiều transaction, không một UPDATE khổng lồ giữ lock lâu và làm phình transaction log.
-- Vi du nguyen tac: them cot NOT NULL an toan tren bang lon
-- (3 buoc thay vi 1 lenh co the rewrite/lock toan bang)
ALTER TABLE tasks ADD COLUMN priority VARCHAR(20);             -- 1. nullable, instant
-- 2. Backfill theo batch (lap tung khoang id, moi batch 1 transaction):
UPDATE tasks SET priority = 'normal' WHERE priority IS NULL
  AND id BETWEEN 1 AND 10000;                                  -- ... lap cac batch
ALTER TABLE tasks ALTER COLUMN priority SET NOT NULL;          -- 3. set NOT NULL sau backfill
Pitfall — DDL an toàn trên engine này có thể lock trên engine/version khác

Cùng một DDL (vd thêm column NOT NULL có default) có thể là metadata-only trên engine/version mới nhưng rewrite toàn bảng (lock hàng giờ) trên version cũ. Đừng giả định — kiểm tra behavior của đúng engine + version production. Test migration trên staging có data volume tương đương production, đặt giới hạn thời gian giữ lock (lock timeout) để migration fail nhanh thay vì treo, và áp thao tác risky trong maintenance window khi không tránh được lock.

6. Landscape tool — cùng một ý tưởng, nhiều hiện thực

Các tool dưới đây đều hiện thực cùng nguyên lý versioned migration — khác nhau ở định dạng, hệ sinh thái, và một số tính năng. Bảng để bạn nhận diện khi gặp, không thiên vị tool nào:

ToolHệ sinh thái điển hìnhCách mô tả thay đổi
FlywayJVM (và CLI đa nền tảng)SQL migration đánh version
LiquibaseJVM (và CLI)Changeset (XML/YAML/JSON/SQL)
AlembicPython / SQLAlchemyMigration script Python
AtlasĐa nền tảng (CLI)Khai báo schema desired-state, tool sinh diff
Rails migrationsRuby on RailsMigration DSL (Ruby)

Hai trường phái cách viết:

  • Imperative: dev viết từng bước thay đổi (đa số tool: Flyway, Liquibase, Alembic, Rails). Kiểm soát chính xác, nhưng dev tự lo pattern an toàn.
  • Declarative: dev mô tả trạng thái schema mong muốn, tool tự tính diff và sinh bước (vd Atlas). Ít viết SQL tay hơn, nhưng cần review SQL do tool sinh.
📝 Chọn tool theo hệ sinh thái, không theo 'mới nhất'

Tiêu chí thực tế: tool tích hợp sẵn với framework/ngôn ngữ của team (Rails → Rails migrations, Python → Alembic, JVM → Flyway/Liquibase), team đã quen workflow nào, và có cần lint/CI gate built-in không. Đừng đổi tool chỉ vì "mới hơn" nếu tool hiện tại không có pain point cụ thể — switching cost (migrate lịch sử, retrain) thường lớn hơn lợi ích.

7. CI/CD gate — migration là một phần của pipeline

Versioned migration phát huy giá trị khi nằm trong pipeline:

1. Dev viet migration moi (them cot/bang/index) + commit cung code
2. CI: chay migration tren DB tam (tu rong, replay het) -> dam bao apply duoc
3. CI: review/lint migration -> bat DDL nguy hiem (drop column, index lock...)
4. Reviewer approve thay doi schema trong PR
5. CD: apply migration -> staging -> smoke test
6. CD: apply migration -> production (sau khi staging pass)

Apply staging trước production cho phép phát hiện timing issue (lock duration quá lâu, data validation fail) mà CI không bắt được vì staging có data thực. Một số tool có lint built-in để chặn DDL nguy hiểm trong PR; nếu không, viết check thủ công hoặc dùng review checklist.

8. Applied — TaskFlow tiến hoá schema

Vài thay đổi thật của TaskFlow, áp dụng nguyên lý trên:

-- Them tasks.priority (NOT NULL, default 'normal') tren bang lon:
   Expand: ADD COLUMN nullable -> backfill batch -> SET NOT NULL.
   Tranh ADD COLUMN NOT NULL DEFAULT mot phat (co the rewrite/lock tuy engine).

-- Refactor tags chuoi -> junction table (xem bai 03):
   Expand: tao bang tags/task_tags -> backfill -> app double-write.
   Contract: drop column tags cu sau khi verify (roll-forward, khong rollback).

-- Doi PK auto-increment -> UUID v7 cho sharding (xem bai 05):
   Online: them cot id_v7 -> backfill batch -> dual-write -> migrate FK tung bang
   -> switch read -> drop cot cu. Khong rollback, chi forward.

-- Them FOREIGN KEY tasks.project_id -> projects(id) tren bang co data:
   Them constraint o trang thai chua validate -> validate rieng (tranh lock quet full).

Mọi thay đổi trên đều: (1) là một migration có version trong repo; (2) chia nhỏ backward-compatible; (3) test trên staging trước; (4) roll-forward nếu lỗi. Đó là kỷ luật migration — quan trọng hơn việc dùng tool nào.

9. Deep Dive — Schema migration & evolution

📚 Deep Dive — Schema migration

Ghi chú: DDIA Ch.4 cho lý thuyết compatibility (gốc của expand-contract); Fowler cho framework kỷ luật migration; Use The Index Luke cho intuition cost của DDL trên bảng lớn. Cú pháp DDL online cụ thể tra tài liệu engine bạn dùng.

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

11. Tóm tắt

  • Versioned migration: mỗi thay đổi schema là một bước có version, commit cùng code, apply theo thứ tự ở mọi environment — schema reproducible, không "quên migration".
  • Vì sao cần: team đổi song song, CI/CD đồng bộ env, dựng lại schema từ đầu, audit qua version control.
  • Roll-forward thắng rollback: nhiều thay đổi không đảo ngược được mà không mất data — sửa bằng migration mới, không sửa quá khứ.
  • Expand-contract: tách thay đổi phá vỡ thành chuỗi bước backward-compatible (thêm cột nullable → backfill → double-write → swap → drop) để rolling deploy không gãy.
  • Online/zero-downtime DDL: thêm cột nullable rồi backfill batch rồi set NOT NULL; build index không khoá write; thêm FK chưa validate rồi validate riêng — tránh lock bảng lớn.
  • Landscape tool: Flyway, Liquibase, Alembic, Atlas, Rails migrations — cùng ý tưởng versioned migration, khác hệ sinh thái và imperative vs declarative. Chọn theo stack, không theo "mới nhất".
  • Nguyên lý agnostic, đúng ở mọi RDBMS. Cú pháp DDL online cụ thể là dialect.

12. Tự kiểm tra

Tự kiểm tra
Q1
Versioned migration giải quyết vấn đề gì mà 'áp DDL bằng tay' không giải được? Nêu cơ chế.

Áp DDL bằng tay không có nguồn sự thật và không tự đồng bộ giữa các environment: dev đổi schema trên máy mình, nhưng staging/production có thể bị quên hoặc áp khác thứ tự → schema lệch nhau, app gãy (vd column does not exist).

Versioned migration biến mỗi thay đổi thành một bước có version, commit vào repo, và một bảng lịch sử trong DB ghi version nào đã apply. Cơ chế: tool đọc lịch sử, apply các migration chưa apply theo thứ tự version tăng dần, idempotent khi re-run (bỏ qua version đã apply, thường kèm checksum phát hiện file bị sửa).

Hệ quả: mọi environment hội tụ về cùng schema; từ DB rỗng replay toàn bộ migration dựng lại đúng schema hiện tại (reproducible); lịch sử schema nằm trong version control để diff/blame/audit. Nguyên lý này độc lập engine và tool.

Q2
Vì sao thực chiến ưu tiên roll-forward thay vì rollback? Cho ví dụ thao tác không rollback được.

Rollback giả định mọi thay đổi đảo ngược được, nhưng nhiều thao tác destructive không khôi phục được data: DROP COLUMN đã xoá toàn bộ giá trị cột; DROP TABLE đã mất bảng; một UPDATE huỷ hoại data không có bản gốc để khôi phục. "Down migration" cho mọi bước vừa tốn công vừa có thể có bug riêng.

Roll-forward giữ nguyên lịch sử (không sửa migration đã apply) và viết một migration mới sửa vấn đề — giống bút toán điều chỉnh trong sổ cái. An toàn hơn vì không đụng quá khứ và mỗi bước mới cũng được review/test.

Thực chiến: thiết kế để không cần rollback — migration nhỏ, backward-compatible (expand-contract), test trên staging có data thật trước khi apply prod. Khi sự cố, roll-forward.

Q3
Rename cột name -> title trên bảng lớn không downtime. Mô tả các bước expand-contract và vì sao mỗi bước an toàn.

Expand: (1) thêm column title nullable — metadata-only, không đụng app cũ; (2) backfill name → title theo batch nhỏ — tránh giữ lock lâu; (3) app ghi cả hai cột (double-write) và đọc từ title.

Migrate: (4) verify title đầy đủ và app ổn định trên cột mới.

Contract: (5) app ngừng ghi name; (6) drop name sau khi chắc chắn không còn ai đọc.

Vì sao an toàn: ở mọi bước, schema tương thích với cả app version cũ (vẫn đọc/ghi name) lẫn app version mới (đọc/ghi title). Trong rolling deploy, hai version chạy song song mà không gãy. Rename trực tiếp một phát sẽ làm app version cũ (chưa kịp deploy) lỗi ngay vì cột biến mất.

Q4
Thêm column NOT NULL có default trên bảng 50 triệu row — vì sao có thể gây downtime, và cách làm an toàn portable?

Tuỳ engine và version: có engine làm ADD COLUMN NOT NULL DEFAULT ... dạng metadata-only (instant), nhưng engine/version cũ phải rewrite toàn bộ bảng để ghi default vào mỗi row — với 50 triệu row có thể lock bảng hàng giờ, gây downtime. Bạn không nên giả định behavior mà phải kiểm tra đúng engine + version production.

Cách an toàn portable (đúng ở mọi engine): tách thành 3 bước — (1) ADD COLUMN nullable (metadata-only, instant); (2) backfill giá trị default theo batch nhỏ, mỗi batch một transaction, tránh giữ lock lâu và phình transaction log; (3) ALTER COLUMN SET NOT NULL sau khi backfill xong.

Kèm theo: test trên staging có data volume tương đương, đặt lock timeout để migration fail nhanh thay vì treo, và chạy trong maintenance window nếu vẫn còn thao tác risky không tránh được.

Q5
Imperative (Flyway/Alembic) vs declarative (Atlas) migration — khác nhau ở đâu? Cả hai có cùng nguyên lý nào?

Imperative: dev viết từng bước thay đổi (vd ALTER TABLE ... ADD COLUMN ...). Kiểm soát chính xác từng DDL, nhưng dev tự chịu trách nhiệm áp pattern an toàn (expand-contract, online DDL).

Declarative: dev mô tả trạng thái schema mong muốn, tool tự tính diff giữa hiện tại và mong muốn rồi sinh các bước. Ít viết SQL tay hơn, nhưng phải review SQL do tool sinh vì không phải lúc nào cũng tối ưu/an toàn cho production traffic.

Điểm chung — cùng nguyên lý versioned migration: cả hai sinh ra các bước có version, commit vào repo, apply theo thứ tự ở mọi environment, và giữ lịch sử để reproducible + audit. Khác biệt chỉ là cách mô tả thay đổi (viết bước vs mô tả đích), không phải bản chất. Chọn theo hệ sinh thái team và nhu cầu lint/CI, không theo "mới hơn".

Bài tiếp theo: Module 6 — Storage & indexing: Tại sao cần index

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