Đổ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;

Ảnh chụp đoạn mã SQL nền tối minh hoạ migration lớn không downtime đổi schema trên bảng đang chạy PostgreSQL 16 mấu chốt là mức khoá và thao tác có viết lại bảng không, an toàn ADD COLUMN có DEFAULT PG11 chỉ đổi metadata ALTER TABLE t ADD COLUMN c text DEFAULT moi NOT NULL tức thì dù bảng khổng lồ không viết lại từng dòng, nguy hiểm đổi kiểu cột viết lại cả bảng cộng khoá ACCESS EXCLUSIVE ALTER TABLE t ALTER COLUMN v TYPE bigint khoá đọc ghi cả bảng an toàn hơn thêm cột mới backfill theo lô đổi tên nhiều bước, an toàn CREATE INDEX CONCURRENTLY không khoá ghi CREATE INDEX idx ON t v chặn ghi tới khi xong CREATE INDEX CONCURRENTLY idx ON t v không chặn chậm hơn quét 2 lần nếu lỗi để lại index INVALID phải dọn, an toàn thêm ràng buộc bằng NOT VALID cộng VALIDATE ALTER TABLE t ADD CONSTRAINT c CHECK v lớn hơn 0 NOT VALID tức thì ALTER TABLE t VALIDATE CONSTRAINT c quét dòng cũ khoá nhẹ NOT VALID chỉ áp cho dòng mới không quét bảng không khoá dài VALIDATE SHARE UPDATE EXCLUSIVE cho đọc và ghi song song, nguyên tắc chung kiểm mức khoá trước tài liệu ALTER TABLE đặt lock_timeout để không treo cả hệ thống chia thao tác nặng thành nhiều bước ngắn

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:

Ảnh chụp bảng kết quả đo thật nền tối chi phí và khoá của DDL bảng mig 3 triệu dòng 104 MB PostgreSQL 16, thao tác schema thời gian và mức khoá ADD COLUMN DEFAULT NOT NULL 1,6 ms không viết lại metadata tức thì ALTER COLUMN v TYPE bigint 1002 ms có viết lại cả bảng ACCESS EXCLUSIVE, CREATE INDEX chặn ghi hay không INSERT chạy song song trong lúc tạo index CREATE INDEX thường 0,278 giây bị chặn tới khi index xong CREATE INDEX CONCURRENTLY 0,056 giây không chặn, thêm CHECK constraint một phát vs NOT VALID cộng VALIDATE ADD CONSTRAINT một phát 80,7 ms ACCESS EXCLUSIVE chặn đọc ghi ADD NOT VALID 0,6 ms tức thì chỉ áp dòng mới VALIDATE CONSTRAINT 76,9 ms SHARE UPDATE EXCLUSIVE cho đọc ghi, mấu chốt không phải tổng thời gian mà là mức khoá một phát chặn mọi thứ suốt 80ms NOT VALID chỉ khoá 0,6ms rồi VALIDATE khoá nhẹ cho ghi song song bảng khổng lồ mà quét mất vài phút thì khác biệt là vài phút downtime hay zero downtime

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ề

  1. 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).
  2. 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.
  3. 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.