Một câu ALTER TABLE chạy thành công chưa có nghĩa là deployment an toàn. Trong production, phiên bản ứng dụng cũ và mới thường cùng tồn tại trong vài phút hoặc lâu hơn. Một migration khóa bảng, đổi tên cột ngay lập tức hoặc backfill hàng triệu bản ghi trong một transaction có thể làm request chậm, cạn connection pool và khiến rollback trở nên bất khả thi.
Mô hình Expand–Migrate–Contract chia thay đổi schema thành nhiều lần triển khai tương thích ngược. Hệ thống tiếp tục phục vụ trong khi dữ liệu và code được chuyển dần sang cấu trúc mới.
Vì sao migration gây downtime?
Downtime thường không đến từ lỗi cú pháp mà từ lock và sự không tương thích giữa nhiều phiên bản:
- Ứng dụng cũ vẫn đọc cột vừa bị đổi tên hoặc xóa.
ALTER TABLEchờ lock phía sau một transaction dài, rồi chặn các query mới.- Tạo index thông thường quét bảng lớn và chặn ghi.
- Backfill khổng lồ tạo WAL, replication lag, table bloat và spike I/O.
- Thêm constraint buộc database quét toàn bộ dữ liệu ngay lúc deploy.
Nguyên tắc cốt lõi: thay đổi database và ứng dụng theo từng bước mà mỗi bước đều chạy được với phiên bản đứng trước và đứng sau nó.
Ba giai đoạn Expand–Migrate–Contract
- Expand: thêm cấu trúc mới nhưng giữ nguyên cấu trúc cũ; code cũ vẫn hoạt động.
- Migrate: ứng dụng bắt đầu ghi/đọc cấu trúc mới, dữ liệu cũ được backfill và kiểm chứng.
- Contract: sau khi không còn consumer cũ, xóa code và schema đã lỗi thời.
Ba giai đoạn nên là các deployment độc lập. Khoảng chờ giữa chúng có thể là vài giờ hoặc vài ngày tùy traffic, khả năng quan sát và chu kỳ phát hành.
Tình huống mẫu: tách họ tên khách hàng
Bảng customers đang có cột full_name. Hệ thống muốn chuyển sang first_name và last_name. Không nên đổi hoặc xóa full_name trong một migration duy nhất vì instance ứng dụng cũ vẫn cần nó.
Giai đoạn 1: Expand schema
ALTER TABLE customers
ADD COLUMN first_name text,
ADD COLUMN last_name text;
Hai cột mới cho phép NULL, vì dữ liệu cũ chưa được chuyển. Deploy này chỉ mở rộng schema, chưa đổi hành vi đọc. Trước khi chạy, đặt timeout ngắn để migration thất bại sớm thay vì xếp hàng rồi gây tắc nghẽn:
SET lock_timeout = '2s';
SET statement_timeout = '15s';
ALTER TABLE customers ADD COLUMN first_name text;
Nếu không lấy được lock trong giới hạn, pipeline nên dừng và thử lại ở thời điểm phù hợp. Đừng tăng timeout mù quáng; hãy tìm transaction giữ lock lâu.
Giai đoạn 2: Deploy code tương thích hai schema
Phiên bản chuyển tiếp có thể ghi cả cấu trúc cũ lẫn mới:
UPDATE customers
SET full_name = :full_name,
first_name = :first_name,
last_name = :last_name
WHERE id = :id;
Khi đọc, ưu tiên cột mới và fallback về cột cũ:
SELECT
id,
COALESCE(first_name || ' ' || last_name, full_name) AS display_name
FROM customers
WHERE id = :id;
Dual-write chỉ nên tồn tại tạm thời. Đặt metric cho số record thiếu cột mới và log mọi trường hợp hai biểu diễn không nhất quán. Nếu nhiều service cùng ghi bảng, tất cả writer phải được nâng cấp trước khi chuyển bước.
Giai đoạn 3: Backfill theo lô
Không update toàn bộ bảng trong một transaction. Hãy xử lý batch nhỏ theo khóa chính, commit giữa các batch và giới hạn tốc độ:
UPDATE customers
SET first_name = split_part(full_name, ' ', 1),
last_name = substring(full_name FROM position(' ' IN full_name) + 1)
WHERE id > :last_id
AND id <= :next_id
AND first_name IS NULL;
Trong thực tế, việc tách tên cần rule nghiệp vụ tốt hơn ví dụ SQL trên. Điểm quan trọng là job phải idempotent: chạy lại không làm hỏng dữ liệu đã hoàn tất.
- Chọn batch theo primary key thay vì
OFFSET. - Đo thời gian batch, row count, replication lag, CPU, I/O và WAL.
- Giảm batch hoặc tạm dừng khi database chịu tải cao.
- Lưu checkpoint để tiếp tục sau lỗi.
- Không giữ transaction mở trong lúc sleep.
Kiểm chứng trước khi chuyển read path
SELECT count(*)
FROM customers
WHERE first_name IS NULL OR last_name IS NULL;
Ngoài đếm NULL, hãy so sánh sample và invariant nghiệp vụ. Shadow read có thể đọc cả hai biểu diễn, trả kết quả mới cho một tỷ lệ traffic nhỏ và ghi metric sai khác mà chưa ảnh hưởng người dùng.
Khi dữ liệu đạt yêu cầu, deploy phiên bản đọc hoàn toàn từ cột mới nhưng vẫn tiếp tục ghi cột cũ trong một khoảng quan sát. Feature flag giúp chuyển lại read path nhanh mà không rollback schema.
Thêm constraint mà không khóa lâu
Với PostgreSQL, có thể dùng CHECK ... NOT VALID để áp dụng rule cho dữ liệu mới mà chưa quét ngay toàn bộ bảng, sau đó validate riêng:
ALTER TABLE customers
ADD CONSTRAINT customers_first_name_present
CHECK (first_name IS NOT NULL) NOT VALID;
ALTER TABLE customers
VALIDATE CONSTRAINT customers_first_name_present;
Sau khi validate, có thể chuyển thành NOT NULL theo khả năng của phiên bản PostgreSQL và kế hoạch đã thử nghiệm. NOT VALID chỉ áp dụng cho một số loại constraint như CHECK và foreign key; không giả định mọi constraint đều hỗ trợ.
Tạo index trên bảng lớn
CREATE INDEX CONCURRENTLY idx_customers_last_name
ON customers (last_name);
CONCURRENTLY giảm việc chặn ghi nhưng chạy lâu hơn, dùng thêm tài nguyên và không được đặt trong transaction block thông thường. Nếu thất bại, PostgreSQL có thể để lại index INVALID; quy trình deploy phải phát hiện và xử lý trước khi chạy lại.
Không tạo index chỉ vì có cột mới. Xác nhận query thực tế bằng EXPLAIN (ANALYZE, BUFFERS) trên môi trường đại diện và theo dõi tác động sau phát hành.
Đổi kiểu dữ liệu an toàn
Đổi trực tiếp kiểu cột có thể rewrite bảng hoặc giữ lock lâu. Với bảng lớn, hãy tạo cột mới, dual-write, backfill rồi đổi read path:
ALTER TABLE orders ADD COLUMN amount_cents bigint;
UPDATE orders
SET amount_cents = round(amount * 100)
WHERE amount_cents IS NULL
AND id > :last_id
AND id <= :next_id;
Sau khi mọi reader và writer dùng amount_cents, cột amount mới được đưa vào giai đoạn Contract.
Giai đoạn cuối: Contract có kiểm soát
Chỉ xóa cấu trúc cũ khi telemetry chứng minh không còn truy cập. Thứ tự điển hình:
- Dừng dual-write vào cột cũ.
- Theo dõi ít nhất một chu kỳ deploy đầy đủ và các worker chạy chậm.
- Xóa code fallback, dashboard và job tạm.
- Đặt lock timeout rồi xóa constraint/index/cột cũ trong deployment riêng.
SET lock_timeout = '2s';
ALTER TABLE customers DROP COLUMN full_name;
Trong PostgreSQL, drop column thường đánh dấu cột là không còn nhìn thấy thay vì lập tức thu hồi toàn bộ dung lượng. Đừng chạy table rewrite chỉ để lấy lại disk nếu chưa đánh giá I/O, lock và cửa sổ bảo trì.
Rollback không chỉ là chạy migration down
Rollback code chỉ an toàn khi schema mới vẫn tương thích với phiên bản cũ. Vì vậy Expand giữ cột cũ và dual-write trong giai đoạn chuyển tiếp. Sau Contract, khôi phục thường khó hơn vì dữ liệu cũ đã ngừng được cập nhật hoặc đã bị xóa.
Mỗi kế hoạch cần trả lời: rollback code về phiên bản nào, dữ liệu nào sẽ mất, backfill có đảo ngược được không, và thời điểm nào được xem là “point of no return”. Backup không thay thế rollback nhanh; restore database lớn có thể lâu hơn SLA.
Checklist trước production
- Thử migration trên bản sao có kích thước và phân bố dữ liệu gần production.
- Kiểm tra loại lock, thời gian giữ lock và khả năng table rewrite.
- Đặt
lock_timeoutvàstatement_timeoutphù hợp. - Bảo đảm code cũ và mới cùng chạy được trong giai đoạn rolling deploy.
- Backfill idempotent, có checkpoint, throttle và dashboard tiến độ.
- Theo dõi error rate, latency, connection pool, replication lag, WAL và disk.
- Có feature flag cho read path và runbook dừng/tiếp tục.
- Contract chỉ diễn ra sau khi xác nhận không còn consumer cũ.
Kết luận
Database migration không downtime là bài toán về khả năng tương thích và vận hành, không chỉ là viết SQL. Expand tạo đường đi mới mà không phá đường cũ; Migrate chuyển code và dữ liệu có quan sát; Contract dọn cấu trúc cũ khi đã có bằng chứng an toàn. Chia nhỏ thay đổi giúp mỗi bước dễ dừng, dễ đo và dễ rollback hơn một migration “tất cả trong một”.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.