Expand–contract: đổi schema khi nhiều phiên bản app cùng chạy Trung cấp
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”Bảng orders có cột status, bản app mới muốn đổi thành payment_status. Chạy RENAME COLUMN trước khi deploy xong: các instance cũ truy vấn status và lỗi ngay. Deploy app mới trước: nó truy vấn payment_status khi schema chưa có. Rolling deploy luôn có khoảng hai phiên bản app cùng chạy trên một database, và rollback còn đưa phiên bản cũ quay lại.
Expand–contract giải bài toán này bằng nhiều bước nhỏ, mỗi bước tương thích với cả app cũ lẫn app mới:
- Expand: thêm khả năng mới, không làm app cũ hỏng.
- Migrate: chuyển ghi, dữ liệu, rồi đường đọc.
- Contract: xoá phần cũ khi chắc chắn không còn ai dùng.
Đây không phải câu thần chú “zero downtime”: DDL vẫn có lock, backfill vẫn tốn tài nguyên, sai thứ tự vẫn gây lỗi. Giá trị của nó là làm rõ điều kiện an toàn của từng bước.
Bản đồ 5 pha
Phần tiêu đề “Bản đồ 5 pha”| Pha | Schema | App cũ | App mới | Rollback an toàn? |
|---|---|---|---|---|
| 1. Expand | thêm payment_status (nullable) |
ghi status |
chưa deploy | Có: cột thừa vô hại |
| 2. Dual-write | như trên | còn chạy, ghi status |
ghi cả hai cột, đọc status |
Có, nhưng row do app cũ ghi lúc đó sẽ thiếu payment_status → backfill lại |
| 3. Backfill | như trên | phải đã gỡ hết | như pha 2 | Có |
| 4. Chuyển read | như trên | không | ghi cả hai, đọc payment_status |
Có, nếu vẫn dual-write và kiểm tra mismatch trước khi tiến lại |
| 5. Contract | ngừng ghi status, rồi DROP COLUMN |
không | chỉ dùng cột mới | Không: cần phục hồi cột và dữ liệu |
Ba mốc quyết định, theo thứ tự: hết writer cũ → mismatch bằng 0 → hết rollback window. Đừng đi tiếp khi mốc trước chưa đạt.
statusPha 1 — Expand: thêm payment_status nullable. App cũ không biết cột mới nên vẫn chạy bình thường.
Cách hoạt động từng pha
Phần tiêu đề “Cách hoạt động từng pha”Schema xuất phát:
CREATE TABLE orders ( id bigint PRIMARY KEY, status text NOT NULL);Pha 1 — Expand
Phần tiêu đề “Pha 1 — Expand”ALTER TABLE orders ADD COLUMN payment_status text;Thêm cột nullable không cần rewrite bảng. Nhưng ALTER TABLE mặc định lấy lock ACCESS EXCLUSIVE (tài liệu: “unless explicitly noted”), và nếu nó phải chờ một transaction dài thì mọi query phía sau xếp hàng chờ nó. Vì vậy luôn đặt lock_timeout và retry:
for i in 1 2 3 4 5; do psql -v ON_ERROR_STOP=1 \ -c "SET lock_timeout = '3s'; ALTER TABLE orders ADD COLUMN payment_status text;" \ && break sleep $((i * 2)) # lùi dần, cho blocker kết thúcdonelock_timeout chỉ tính thời gian chờ lock, khác statement_timeout. Không đặt nó trong postgresql.conf vì ảnh hưởng mọi session; đặt theo session của migration. Nếu retry hết vẫn fail: dừng, tìm blocker trong pg_stat_activity/pg_locks, đừng tăng timeout vô hạn.
Pha 2 — Dual-write
Phần tiêu đề “Pha 2 — Dual-write”App mới ghi cả hai cột trong cùng câu lệnh và vẫn đọc status:
UPDATE orders SET status = $1, payment_status = $1 WHERE id = $2;Giữ đường đọc cũ vì app cũ còn đang chạy và chỉ ghi status. Nếu app mới đọc payment_status sớm, nó thấy dữ liệu stale.
Quên một writer (worker, cron, admin tool) là nguồn lệch phổ biến nhất. Có hai cách đồng bộ:
| Cách | Ưu | Nhược |
|---|---|---|
| Dual-write trong app | Rõ ràng trong code, dễ test | Phải kiểm kê mọi writer |
Trigger DB (BEFORE INSERT OR UPDATE gán NEW.payment_status := NEW.status) |
Bắt cả writer cũ, kể cả khi rollback | Logic ẩn trong DB, thêm chi phí mỗi write, phải nhớ xoá ở pha 5 |
Nếu hệ thống có nhiều writer khó kiểm kê, trigger an toàn hơn; khi đó pha 3 không cần chờ hết writer cũ.
Pha 3 — Backfill
Phần tiêu đề “Pha 3 — Backfill”Chỉ chạy khi không còn writer cũ (hoặc đã có trigger). Chia batch theo khoá chính để tránh transaction dài, WAL lớn và replication lag:
UPDATE ordersSET payment_status = statusWHERE id >= 1000 AND id < 2000 AND payment_status IS NULL;Mỗi UPDATE tạo phiên bản row mới nên bảng phình ra; xem VACUUM và bloat và điều tiết tốc độ theo replication lag. Sau cùng, kiểm tra:
SELECT count(*) FROM orders WHERE payment_status IS DISTINCT FROM status;Kết quả phải bằng 0. IS DISTINCT FROM xử lý NULL đúng; <> thì bỏ sót row NULL.
Pha 4 — Chuyển read
Phần tiêu đề “Pha 4 — Chuyển read”Deploy bản đọc payment_status, vẫn ghi cả hai cột. Theo dõi metric mismatch, lỗi query, latency và replication lag; nếu có read replica, cần tính độ trễ replica trước khi kết luận mọi reader đã thấy dữ liệu mới.
Pha 5 — Contract
Phần tiêu đề “Pha 5 — Contract”- Phát hành bản ngừng ghi
status(và xoá trigger nếu có). Sau bước này rollback về app cũ không còn an toàn. - Kiểm kê reader ngoài app chính: báo cáo BI, CDC connector, script vận hành, job theo lịch, downstream đọc replica.
- Một release trước khi drop, cho ORM bỏ qua cột (ví dụ
ignored_columnscủa Rails, hoặc bỏ cột khỏi entity/SELECTtường minh). Lý do: ORM thường cache danh sách cột;SELECT *và prepared statement đã cache có thể lỗi (kiểu như “cached plan must not change result type”) khi cột biến mất. DROP COLUMNở migration riêng, vẫn dướilock_timeout. Lệnh này nhanh vì chỉ ẩn cột, nhưng không giải phóng dung lượng ngay; không gian được thu hồi dần khi row được ghi lại.
NOT NULL và index không làm khoá bảng
Phần tiêu đề “NOT NULL và index không làm khoá bảng”Nếu contract yêu cầu payment_status NOT NULL, đừng chạy thẳng SET NOT NULL trên bảng lớn: mặc định nó scan toàn bảng khi đang giữ lock mạnh. Đường đi an toàn:
-- 1. Thêm ràng buộc, KHÔNG scan (row mới vẫn bị kiểm tra)ALTER TABLE orders ADD CONSTRAINT orders_ps_not_null CHECK (payment_status IS NOT NULL) NOT VALID;
-- 2. Scan bảng nhưng chỉ cần SHARE UPDATE EXCLUSIVE, không chặn đọc/ghiALTER TABLE orders VALIDATE CONSTRAINT orders_ps_not_null;
-- 3. Có CHECK hợp lệ chứng minh không có NULL nên PostgreSQL bỏ qua scanALTER TABLE orders ALTER COLUMN payment_status SET NOT NULL;
-- 4. Dọn ràng buộc tạmALTER TABLE orders DROP CONSTRAINT orders_ps_not_null;Bước 1 vẫn cần lock ngắn, nên đặt lock_timeout như pha 1. Việc bỏ qua scan khi có CHECK hợp lệ được ghi trong tài liệu ALTER TABLE; tối ưu này xuất hiện từ PostgreSQL 12.
Tương tự, nếu cột mới cần index, dùng CREATE INDEX CONCURRENTLY để không chặn ghi:
CREATE INDEX CONCURRENTLY orders_payment_status_idx ON orders (payment_status);Ba lưu ý theo tài liệu: không chạy được trong transaction block; build lâu hơn vì hai lần scan và phải chờ transaction cũ; nếu lỗi (deadlock, vi phạm unique) nó để lại index INVALID vẫn tốn chi phí ghi, phải DROP rồi làm lại. Kiểm tra bằng \d orders.
Rollback: cẩn thận drift
Phần tiêu đề “Rollback: cẩn thận drift”Sau pha 2, rollback về app cũ đưa trở lại một writer chỉ ghi status. Những row nó ghi có payment_status cũ hoặc NULL, nên cột mới lệch.
- Trước khi deploy lại bản mới (hoặc chuyển read), chạy lại câu kiểm tra mismatch và backfill phần lệch.
- Nếu đang ở pha 4 (đọc cột mới) mà phải rollback, bản cũ đọc
statusvốn được dual-write nên vẫn đúng; drift chỉ xuất hiện cho row ghi sau rollback. - Trigger đồng bộ xoá gần như hoàn toàn vấn đề này.
Khi nào dùng / không dùng
Phần tiêu đề “Khi nào dùng / không dùng”| Dùng expand–contract khi | Không cần khi |
|---|---|
| Rolling deploy hoặc canary: nhiều phiên bản app cùng chạy | Bảng nhỏ, chấp nhận cửa sổ bảo trì ngắn có chủ đích |
| Đổi tên/đổi kiểu cột, tách bảng, thêm ràng buộc trên bảng lớn | Chỉ thêm cột nullable mới mà app cũ không cần biết (một pha expand là đủ) |
| Cần rollback app mà không rollback schema | Môi trường một instance, deploy kiểu stop–start |
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Mô phỏng trạng thái một row (mô hình trong bộ nhớ, không mở PostgreSQL nên không chứng minh DDL hay lock):
npx --prefix examples ts-node examples/src/expand-contract/rename-column-model.tsThử thêm một oldWriter chỉ sửa status sau dual-write: nếu reader mới đọc paymentStatus quá sớm, nó thấy giá trị cũ. Đó chính là race mà thứ tự pha phải tránh.
Trade-offs
Phần tiêu đề “Trade-offs”Lợi ích
- Giữ app cũ và mới tương thích khi rolling deploy hoặc rollback.
- Chia migration lớn thành các mốc kiểm chứng được.
- Backfill có giới hạn tải và quan sát được tiến độ.
Chi phí
- Dual-write tạo thêm đường code và nguy cơ lệch nếu sót writer.
- Tạm tốn thêm storage, WAL, IO và thời gian vận hành (nhiều release thay vì một).
- Không loại bỏ lock của DDL, race với writer, hay chi phí rollback sau contract.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist production
Phần tiêu đề “Checklist production”- Mọi lệnh DDL chạy với
SET lock_timeoutở session migration và có retry có lùi dần. - Đã kiểm kê mọi writer và reader (kể cả BI, CDC, cron); hoặc dùng trigger đồng bộ.
- Backfill theo batch, có điều tiết theo replication lag, có metric tiến độ.
- Mismatch bằng 0 trước khi chuyển read; chạy lại sau mỗi lần rollback.
NOT NULLquaNOT VALID→VALIDATE→SET NOT NULL; index quaCONCURRENTLY.- ORM đã bỏ qua cột cũ ít nhất một release trước
DROP COLUMN. - Contract tách thành release riêng, sau khi rollback window kết thúc.
- Diễn tập trên bản sao có kích thước và transaction dài gần thật; xem thêm graceful shutdown để rolling deploy không cắt request giữa chừng.
Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao không đọc payment_status ngay khi cột xuất hiện? (2) Vì sao ALTER TABLE chờ lock có thể chặn cả những query không liên quan đến nó? (3) Sau rollback về app cũ, bạn cần làm gì trước khi chuyển read lần nữa? (4) Vì sao nên bỏ cột khỏi ORM một release trước DROP COLUMN?