Đổi schema trên môi trường dev thì đơn giản: gõ ALTER TABLE, xong. Nhưng trên production với một bảng hàng triệu dòng đang phục vụ hàng nghìn request mỗi giây, cùng câu lệnh đó có thể khoá cả bảng vài phút — nghĩa là vài phút mọi request treo, tức là downtime. Bí quyết của migration không downtime nằm ở hai câu hỏi cho mỗi thao tác: nó có viết lại cả bảng không, và nó giữ mức khoá nào? Bài này đo thật cả hai cho các thao tác schema phổ biến, và chỉ cách làm an toàn.
Nguyên tắc: viết lại bảng và mức khoá
Một thao tác DDL nguy hiểm khi nó (a) viết lại từng dòng của bảng, hoặc (b) giữ khoá ACCESS EXCLUSIVE (chặn cả đọc lẫn ghi) trong thời gian dài. Thao tác an toàn thì tức thì (chỉ đổi metadata) hoặc dùng khoá nhẹ cho phép truy cập song song.
-- AN TOÀN: thêm cột có default (PG11+ chỉ đổi metadata, không viết lại)
ALTER TABLE t ADD COLUMN c text DEFAULT 'moi' NOT NULL;
-- NGUY HIỂM: đổi kiểu cột viết lại cả bảng + ACCESS EXCLUSIVE
ALTER TABLE t ALTER COLUMN v TYPE bigint;

Hình 1: Các thao tác schema an toàn (ADD COLUMN default, CREATE INDEX CONCURRENTLY, NOT VALID + VALIDATE) và nguy hiểm (đổi kiểu cột viết lại bảng). Mấu chốt là mức khoá và có viết lại bảng không.
Đo thật trên bảng 3 triệu dòng
Bảng mig 3 triệu dòng (104 MB). Đo từng thao tác:

Hình 2: ADD COLUMN default: 1,6 ms (metadata). ALTER TYPE: 1.002 ms (viết lại + ACCESS EXCLUSIVE). CREATE INDEX chặn INSERT 0,278s vs CONCURRENTLY 0,056s. ADD CONSTRAINT một phát giữ ACCESS EXCLUSIVE 80ms vs NOT VALID (0,6ms) + VALIDATE (khoá nhẹ).
Thêm cột có default: 1,6 ms — tức thì dù bảng 3 triệu dòng. Từ PG11, PostgreSQL chỉ ghi giá trị default vào metadata, không viết lại từng dòng. Đây là thao tác an toàn nhất.
Đổi kiểu cột int → bigint: 1.002 ms và giữ ACCESS EXCLUSIVE suốt quá trình — vì nó viết lại toàn bộ bảng. Trên bảng khổng lồ, "1 giây" này thành nhiều phút, và cả bảng bị khoá đọc lẫn ghi suốt thời gian đó. Đây là thao tác cần tránh làm trực tiếp; cách an toàn là nhiều bước (thêm cột mới, backfill theo lô, đổi tên).
CREATE INDEX: tôi chạy tạo index trong khi một INSERT cố chạy song song. Với CREATE INDEX thường, INSERT bị chặn 0,278 giây (chờ tới khi index xong). Với CREATE INDEX CONCURRENTLY, INSERT chỉ mất 0,056 giây — không bị chặn. Trên bảng lớn nơi tạo index mất vài phút, khác biệt này là vài phút chặn ghi so với không.
Ràng buộc: NOT VALID rồi VALIDATE
Thêm một CHECK hay khoá ngoại bình thường quét cả bảng để kiểm và giữ ACCESS EXCLUSIVE suốt. Đo thật: ADD CONSTRAINT một phát mất 80,7 ms với ACCESS EXCLUSIVE (chặn mọi thứ suốt 80 ms đó). Cách an toàn tách hai bước:
ALTER TABLE t ADD CONSTRAINT c CHECK (v > 0) NOT VALID; -- 0,6 ms, tức thì
ALTER TABLE t VALIDATE CONSTRAINT c; -- quét, khoá NHẸ
NOT VALID thêm ràng buộc tức thì (0,6 ms) — nó chỉ áp cho dòng mới, không quét bảng, không khoá dài. Sau đó VALIDATE CONSTRAINT quét kiểm các dòng cũ (76,9 ms) nhưng dùng khoá SHARE UPDATE EXCLUSIVE — cho phép đọc VÀ ghi song song. Mấu chốt không phải tổng thời gian (cả hai đều ~80 ms quét) mà là mức khoá: một phát chặn mọi thứ suốt quá trình, còn NOT VALID+VALIDATE chỉ khoá cứng 0,6 ms rồi phần quét lâu dùng khoá nhẹ. Trên bảng khổng lồ quét mất vài phút, đây là khác biệt giữa vài phút downtime và zero downtime.
Đánh đổi cần cân nhắc
CONCURRENTLY chậm hơn và có rủi ro riêng. CREATE INDEX CONCURRENTLY quét bảng hai lần nên chậm hơn bản thường, và nếu thất bại giữa chừng nó để lại một index INVALID phải DROP thủ công rồi làm lại. Nó cũng không chạy được trong một transaction block. Đổi lại nó không khoá ghi — với production đây gần như luôn là đánh đổi đáng giá.
Đặt lock_timeout để không treo cả hệ thống. Một DDL cần ACCESS EXCLUSIVE phải chờ mọi giao dịch đang chạy trên bảng kết thúc trước khi lấy được khoá — và trong lúc chờ, nó chặn mọi request mới (kể cả SELECT) xếp hàng sau nó. Một giao dịch dài vô tình có thể biến một ALTER nhanh thành sự cố. Luôn đặt SET lock_timeout = '2s' trước DDL để nó bỏ cuộc thay vì treo hệ thống, rồi thử lại.
Nhiều công cụ tự động hoá việc này. Đổi kiểu cột an toàn (thêm cột, backfill, đổi tên, cập nhật ứng dụng đọc cả hai) là nhiều bước phối hợp với cả code. Các framework migration hiện đại và công cụ như pg-osc/pgroll giúp tự động hoá các mẫu này. Hiểu nguyên lý (mức khoá, viết lại bảng) giúp bạn dùng chúng đúng và biết khi nào một migration là an toàn.
Ba ý mang về
- Migration không downtime dựa trên hai câu hỏi: thao tác có viết lại bảng không, và giữ mức khoá nào: đo thật, ADD COLUMN default tức thì 1,6 ms (chỉ metadata, PG11+) trong khi đổi kiểu cột mất 1.002 ms viết lại cả bảng với ACCESS EXCLUSIVE (chặn đọc+ghi).
- Dùng CREATE INDEX CONCURRENTLY và NOT VALID + VALIDATE để tránh khoá dài: đo thật CREATE INDEX thường chặn INSERT song song 0,278s còn CONCURRENTLY chỉ 0,056s; ADD CONSTRAINT một phát giữ ACCESS EXCLUSIVE suốt 80 ms còn NOT VALID (0,6 ms) + VALIDATE dùng khoá nhẹ cho ghi song song.
- Trên bảng khổng lồ, mức khoá quyết định downtime: một thao tác quét vài phút giữ ACCESS EXCLUSIVE là vài phút treo cả hệ thống — đặt
lock_timeoutđể DDL không treo chờ khoá, chia thao tác nặng thành nhiều bước ngắn, và với đổi kiểu cột dùng mẫu nhiều bước (thêm cột, backfill, đổi tên).
Phần sau ta gom toàn bộ những gì đã học thành một danh sách kiểm tra thực dụng: Phần sau một checklist tối ưu PostgreSQL — các điểm cần rà từ cấu hình, index, truy vấn tới giám sát, để không bỏ sót khi tối ưu một hệ thống thật.