Bỏ qua để đến nội dung
Knowledge Hub

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

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.

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.

Năm pha đổi tên cột, khớp với bảng trên
Năm pha đổi tên cột, khớp với bảng trênBước 1/5: Pha 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.App cũđọc/ghi statusordersPostgreSQL+cột mớiApp mớistatus
App cũ → ordersstatus
Bước 1/5

Pha 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.

Schema xuất phát:

CREATE TABLE orders (
id bigint PRIMARY KEY,
status text NOT NULL
);
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:

Terminal window
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úc
done

lock_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.

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ũ.

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 orders
SET payment_status = status
WHERE 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.

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.

  1. 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.
  2. 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.
  3. Một release trước khi drop, cho ORM bỏ qua cột (ví dụ ignored_columns của Rails, hoặc bỏ cột khỏi entity/SELECT tườ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.
  4. DROP COLUMN ở migration riêng, vẫn dưới lock_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.

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/ghi
ALTER 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 scan
ALTER TABLE orders ALTER COLUMN payment_status SET NOT NULL;
-- 4. Dọn ràng buộc tạm
ALTER 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.

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 status vố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.
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

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):

Terminal window
npx --prefix examples ts-node examples/src/expand-contract/rename-column-model.ts

Thử 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.

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.
  1. Mọi lệnh DDL chạy với SET lock_timeout ở session migration và có retry có lùi dần.
  2. Đã kiểm kê mọi writer và reader (kể cả BI, CDC, cron); hoặc dùng trigger đồng bộ.
  3. Backfill theo batch, có điều tiết theo replication lag, có metric tiến độ.
  4. Mismatch bằng 0 trước khi chuyển read; chạy lại sau mỗi lần rollback.
  5. NOT NULL qua NOT VALID → VALIDATE → SET NOT NULL; index qua CONCURRENTLY.
  6. ORM đã bỏ qua cột cũ ít nhất một release trước DROP COLUMN.
  7. Contract tách thành release riêng, sau khi rollback window kết thúc.
  8. 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ự 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?