Zero-downtime Migration — đổi schema khi app đang chạy
Đổi schema trên database đang chạy production là bài toán mà ai làm backend rồi cũng dính: anh DROP COLUMN xong, deploy code mới, mọi thứ xanh — tới lúc rollback thì app cũ đọc vào cột không còn tồn tại và 500 hàng loạt. Em từng thức 3h sáng vì đúng trò này.
Nguyên nhân gốc không nằm ở SQL, mà ở chỗ code và schema deploy không đồng thời. Trong lúc rolling deploy, version cũ và version mới chạy song song vài phút (có khi vài giờ). Nghĩa là ở mọi thời điểm, schema phải tương thích với cả hai version code. Không có cách nào khác ngoài tách một lần đổi thành nhiều bước — gọi là expand – contract (parallel change).
Ví dụ: tách users.name thành first_name + last_name
Bước 1 — Expand. Thêm cột mới, nullable, chưa gắn constraint gì:
SET lock_timeout = '3s';
ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name text;
Postgres 11+ thì ADD COLUMN ... DEFAULT 'x' (hằng số) là metadata-only, không rewrite bảng. Nhưng DEFAULT gen_random_uuid() hay DEFAULT now() thì rewrite cả bảng — bảng 50 triệu dòng là treo. Cứ để nullable rồi backfill, xong hãy SET NOT NULL.
Bước 2 — Dual-write. App ghi cả cột cũ lẫn cột mới, đọc thì ưu tiên cột mới và fallback về cột cũ:
func writeName(ctx context.Context, db *pgxpool.Pool, id int64, legacy string) error {
first, last := splitName(legacy)
_, err := db.Exec(ctx, `
UPDATE users
SET name = $2, first_name = $3, last_name = $4
WHERE id = $1`, id, legacy, first, last)
return err
}
Bước này là chỗ dễ ăn đòn nhất: bỏ sót một đường ghi (job cron, admin script, endpoint cũ) là có dòng dữ liệu lệch, và lệch âm thầm.
Bước 3 — Backfill theo batch. Đừng chạy một UPDATE khổng lồ. Nó giữ row lock lâu, WAL phình, replication lag tăng vọt, autovacuum chạy đuối và bloat cả bảng:
for {
tag, err := db.Exec(ctx, `
UPDATE users u
SET first_name = split_part(u.name, ' ', 1),
last_name = split_part(u.name, ' ', 2)
WHERE u.id IN (
SELECT id FROM users
WHERE first_name IS NULL
ORDER BY id
LIMIT 1000
FOR UPDATE SKIP LOCKED
)`)
if err != nil { return err }
if tag.RowsAffected() == 0 { return nil }
time.Sleep(200 * time.Millisecond) // thở cho replica theo kịp
}
FOR UPDATE SKIP LOCKED để batch này không chặn traffic đang update cùng dòng; batch 1000 dòng + sleep 200ms là con số em hay dùng cho bảng vài chục triệu dòng.
Bước 4 — Contract. Sau khi đã chắc chắn không còn version code nào đọc cột name (và hết đường rollback về version đó), mới drop:
ALTER TABLE users ALTER COLUMN first_name SET NOT NULL;
DROP INDEX CONCURRENTLY idx_users_name; -- index cũ, nếu còn
ALTER TABLE users DROP COLUMN name;
Mấy cái bẫy em dính rồi
CREATE INDEX thường lock write cả bảng. Phải dùng CREATE INDEX CONCURRENTLY, và nó không chạy được trong transaction. Với golang-migrate, anh phải tách file .sql riêng và set -- +migrate NoTransaction, không là nó treo ở "waiting for lock" mãi.
Lock queue là hàng đợi FIFO — cái này mới hiểm. Một ALTER TABLE đứng chờ lock sẽ block mọi query phía sau nó, kể cả SELECT bình thường. Bảng không có tải vẫn "treo" vì bị thằng ALTER kẹt phía trước. Đối sách: luôn SET lock_timeout = '3s', fail rồi retry, đừng nằm chờ.
Đừng drop cột trong cùng tuần. Em thường để cột cũ sống 2 tuần, đo bằng log/observability xem còn ai gọi field đó không rồi mới tiễn.
Rollback là rollback code, không phải rollback schema. Đã đi tới bước drop thì đường lùi duy nhất là restore backup (mất data). Nên bước contract nên nằm ở một PR/deploy riêng, cách xa bước expand.
Checklist
- Expand và contract khác lần deploy, cách nhau nhiều ngày
- Mọi DDL đều có
lock_timeout+ retry - Index dùng
CONCURRENTLY, chạy ngoài transaction - Backfill theo batch + sleep, không dùng một câu
UPDATEkhổng lồ - Không drop cột khi version cũ vẫn còn khả năng rollback
Đổi schema chậm hơn, nhưng ngủ ngon hơn. Anh có migration nào từng phải rollback giữa đêm chưa?