Đổi schema database không downtime — Expand & Contract thực chiến
Hồi mới làm backend, mình từng chứng kiến cảnh nửa đêm bị gọi dậy chỉ vì một câu ALTER TABLE. Team nối thêm một cột vào bảng orders đang có mấy triệu dòng, tưởng chuyện nhỏ, ai ngờ Postgres khoá cả bảng lại, toàn bộ request ghi vào đứng hình mất gần 10 phút. Đúng kiểu "đổi một cột, sập cả service".
Sau lần đó mình mới hiểu: migrate schema trên production không phải chuyện chạy lệnh SQL là xong, mà là một bài toán về thời điểm, về lock, và về cách phối hợp giữa database với code. Bài này mình kể lại cách làm mà từ đó tới giờ team mình vẫn xài — pattern Expand & Contract.
Ảnh: Kampus Production — Pexels
Vì sao ALTER TABLE lại nguy hiểm?
Trong Postgres, hầu hết lệnh ALTER TABLE đều cần Access Exclusive Lock — tức khoá cả bảng, không cho đọc cũng không cho ghi. Với bảng nhỏ thì vài chục mili giây, không ai để ý. Nhưng bảng lớn thì khác:
ADD COLUMNcó giá trị mặc định (non-volatile) từ Postgres 11 thì chỉ đổi metadata, nhanh thật — nhưng vẫn chờ lock nếu có giao dịch dài đang chạy.ADD CONSTRAINT(kể cảNOT NULL) phải quét toàn bộ bảng để validate dữ liệu cũ.CREATE INDEXkhông chặn ghi (dùngCONCURRENTLYđược), nhưngDROP COLUMN,ALTER COLUMN TYPEthì chặn hết.
Vấn đề tệ nhất: lệnh ALTER chờ lock, các query khác thì xếp hàng đợi phía sau, rồi queue dài dần, timeout dây chuyền. Cái chết không đến từ câu SQL, mà từ hàng đợi nó tạo ra.
Expand & Contract — ba phase an toàn
Ý tưởng cốt lõi: không bao giờ đổi schema và code trong cùng một thời điểm. Tách thành 3 phase, mỗi phase deploy độc lập:
Phase 1: Expand — thêm cái mới, giữ cái cũ
-- Thêm cột mới, nullable, không có default cứng
ALTER TABLE orders ADD COLUMN status_v2 text;
Chưa có constraint gì nặng nề. Deploy code mới: ghi song song cả cột cũ và cột mới.
# Trong model/service
def create_order(order):
row = db.execute(
"INSERT INTO orders (status, status_v2, ...) VALUES (%s, %s, ...)",
order.status, order.status, ...
)
Lúc này column mới chưa phải nguồn dữ liệu chính thức, chỉ là "đang tập viết".
Phase 2: Backfill dữ liệu cũ theo batch
Đừng chạy UPDATE orders SET status_v2 = status một phát — câu đó khoá hàng loạt, chặn ghi cả bảng. Thay vào đó, backfill theo batch nhỏ:
-- Chạy lặp lại nhiều lần, mỗi lần vài nghìn dòng
UPDATE orders
SET status_v2 = status
WHERE status_v2 IS NULL
AND id IN (
SELECT id FROM orders
WHERE status_v2 IS NULL
ORDER BY id
LIMIT 5000
);
Mỗi batch giữ lock ngắn, nhường chỗ cho traffic bình thường chen vào giữa. Với bảng 10 triệu dòng, batch 5000 dòng thì mất ~2000 lần lặp — chạy background job, không phải một câu SQL to.
Phase 3: Contract — chuyển đọc sang cột mới, rồi bỏ cột cũ
Khi backfill xong, deploy code đọc từ status_v2, bỏ hết tham chiếu tới status. Xác nhận không còn query nào dùng cột cũ (grep codebase, xem log slow query), rồi mới dọn dẹp:
ALTER TABLE orders DROP COLUMN status;
Lúc này DROP COLUMN không ảnh hưởng ai, vì không còn ai đọc nó. Nếu lỡ tay đổi code sai, bạn vẫn còn cột cũ để rollback — chính cái "an toàn" này làm pattern đáng giá.
Mấy cái bẫy mình gặp thật
- NOT NULL là cái bẫy ngọt ngào nhất. Đừng thêm
NOT NULLcùng lúc với cột mới — bảng cũ có dòng null là fail ngay. Thứ tự đúng: thêm nullable → backfill → mớiALTER COLUMN SET NOT NULL. - Default cứng làm code mới nghĩ cột đã có dữ liệu. Nếu app vừa đọc vừa ghi, đặt default lúc Expand thì dữ liệu mới sinh ra đã "hợp lệ" trong khi cột chưa backfill — dễ nhầm lẫn. Ưu tiên nullable + backfill trước, default tính sau.
- Quên migration cho read replica. Nếu có replica cho read-only, schema trên replica phải đồng bộ trước khi code mới lên — không thì query chết ngay trên đường đọc.
- Kiểm tra lock thật sự, đừng đoán. Trước khi chạy migration, nhìn
pg_stat_activityxem có giao dịch dài đang chạy không. Một transaction mở 30 phút có thể "treo" lệnh ALTER của bạn mà bạn không hiểu vì sao.
Khi nào thì khỏi cần pattern này?
Expand & Contract khá nhiều bước, nên chỉ xài khi bảng thật sự lớn hoặc hệ thống không cho phép downtime. Bảng vài chục nghìn dòng, hay service chạy trong giờ bảo trì — cứ ALTER TABLE thẳng, đừng lằng nhằng. Kỹ thuật nào cũng có chi phí, chọn đúng chỗ mới đáng.
Mình để ý nhiều team (kể cả mình hồi trước) cứ thấy migration là sợ, nhưng thật ra chỉ cần hiểu cơ chế lock và tách được "đổi schema" khỏi "đổi code" là bài toán gần như tự giải. Còn bạn, team bạn từng dính phải vụ ALTER TABLE chặn production chưa? Chia sẻ nghe chơi, mình tin không chỉ mình mình gặp đâu.
Ảnh: Georgie Devlin — Pexels
📋 Phụ lục thuật ngữ
- Expand & Contract — pattern migration 3 phase: thêm cột mới (expand), backfill dữ liệu, rồi mới bỏ cột cũ (contract)
- Access Exclusive Lock — khoá mạnh nhất trong Postgres, chặn mọi thao tác đọc/ghi trên bảng
- Backfill — quá trình điền dữ liệu cho cột mới từ dữ liệu cũ đã tồn tại
- Batch — chia công việc lớn thành nhiều phần nhỏ, mỗi phần giữ lock trong thời gian ngắn
- Read replica — bản sao database phục vụ query đọc, giảm tải cho bản chính