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-contract và online 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án | Versioned 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ỉnh | Sử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 truth | Tập migration là source of truth của schema |
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 —
diffvà blame được như mọi code khác.
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 data —
DROP 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;
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.name → tasks.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
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:
| Tool | Hệ sinh thái điển hình | Cách mô tả thay đổi |
|---|---|---|
| Flyway | JVM (và CLI đa nền tảng) | SQL migration đánh version |
| Liquibase | JVM (và CLI) | Changeset (XML/YAML/JSON/SQL) |
| Alembic | Python / SQLAlchemy | Migration script Python |
| Atlas | Đa nền tảng (CLI) | Khai báo schema desired-state, tool sinh diff |
| Rails migrations | Ruby on Rails | Migration 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.
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
- Designing Data-Intensive Applications (Kleppmann) Ch.4 "Encoding and Evolution" — schema evolution, backward/forward compatibility, vì sao thay đổi schema phải tương thích cả hai chiều khi rolling deploy. Nền tảng agnostic cho expand-contract.
- Martin Fowler — "Evolutionary Database Design" — canonical reference cho versioned migration, database refactoring, và kỷ luật đưa schema vào version control — không gắn engine.
- Use The Index, Luke — vì sao tạo index / thêm constraint trên bảng lớn tốn kém, để hiểu lý do cần online DDL.
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
- Bài 02 — Constraints: thêm NOT NULL/FK/CHECK trên bảng production là tình huống migration điển hình cần expand-contract.
- Bài 03 — Normalization 1NF→3NF: refactor cột chuỗi → junction table chính là một migration expand-contract (drop column cũ ở bước contract).
- Bài 04 — Denormalization tradeoff: thêm cached column + backfill + bật sync là migration nhiều bước backward-compatible.
- Bài 05 — Surrogate vs natural key: đổi PK auto-increment → UUID v7 là migration online dài hơi (dual-write).
- Module 6 — Storage & indexing: vì sao tạo index trên bảng lớn có thể lock — lý do cần online DDL.
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
Q1Versioned 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.
Q2Vì 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.
Q3Rename 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.
Q4Thê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.
Q5Imperative (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
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