Migration "thêm một cột" được coi là vô hại, và phần lớn thời gian nó đúng là vậy: một dòng trong catalog, một mili giây. Rồi một ngày cùng câu lệnh đó làm toàn bộ dịch vụ đứng năm giây, hoặc năm phút, và không ai hiểu vì sao — câu lệnh đâu có đổi. Thứ đổi là hoàn cảnh: một giao dịch ai đó quên đóng, một default không phải hằng số, một bảng đã lớn gấp trăm lần. Phần này đo từng kiểu ALTER TABLE bằng timing phía máy chủ, dựng lại hàng đợi khoá kinh điển với pgbench chạy nền để thấy tps rơi về 0, và đo các lối đi vòng — lock_timeout, CONCURRENTLY, NOT VALID — bằng cùng một thước: một UPDATE một dòng chen vào bị chặn bao lâu.
Khoá, hàng đợi khoá, và lối đi vòng

Hình 1: Mọi ALTER TABLE lấy ACCESS EXCLUSIVE; câu hỏi là giữ bao lâu. Hàng đợi khoá khiến ALTER một mili giây vẫn chặn mọi SELECT. Lối đi vòng tách khoá ngắn khỏi quét dài.
ALTER TABLE lấy khoá ACCESS EXCLUSIVE — xung đột với mọi thứ, kể cả SELECT. Điều đó không tự nó nguy hiểm; nguy hiểm là giữ bao lâu. Thêm cột không default, hay có default là hằng số (PostgreSQL 11+), chỉ sửa catalog: mili giây. Default là biểu thức volatile (random(), clock_timestamp()) thì phải tính giá trị riêng cho từng dòng — viết lại cả bảng dưới khoá. SET NOT NULL, ADD CHECK, ADD FOREIGN KEY trực tiếp phải quét cả bảng để kiểm — cũng dưới khoá. CREATE INDEX lấy khoá SHARE: cho đọc, chặn ghi, suốt lúc dựng.
Hàng đợi khoá là cơ chế biến "vài mili giây" thành sự cố. Phiên 1 mở giao dịch, SELECT một lần rồi để đó — giữ ACCESS SHARE cho tới khi COMMIT. Phiên 2 chạy ALTER TABLE: xin ACCESS EXCLUSIVE, phải chờ phiên 1. Phiên 3, 4, 5… chỉ cần ACCESS SHARE để SELECT, nhưng PostgreSQL xếp khoá theo thứ tự xin: chúng đứng sau phiên 2 trong hàng đợi, dù khoá chúng cần không hề xung đột với phiên 1. Kết quả: một giao dịch quên đóng cộng một ALTER vô hại là mọi truy vấn vào bảng đứng im. lock_timeout là lối ra: ALTER bỏ cuộc sau N giây thay vì giữ cả hàng đợi làm con tin.
Các lối đi vòng đều cùng một ý: tách phần cần khoá độc quyền (ngắn) khỏi phần quét dữ liệu (dài), và cho phần dài chạy dưới khoá nhẹ hơn. CREATE INDEX CONCURRENTLY quét hai lần dưới SHARE UPDATE EXCLUSIVE, cho ghi tiếp. ADD CONSTRAINT … NOT VALID chỉ sửa catalog, rồi VALIDATE CONSTRAINT quét dưới SHARE UPDATE EXCLUSIVE. SET NOT NULL từ PostgreSQL 12 bỏ qua quét nếu đã có CHECK IS NOT NULL hợp lệ — nên ba bước NOT VALID → VALIDATE → SET NOT NULL không có bước nào giữ khoá độc quyền lâu.
Môi trường đo: docs một triệu dòng và pgbench_accounts ba triệu dòng trên pg-prim; thời gian lấy bằng \timing của psql (phía máy chủ, không tính khởi động client); pgbench -P 1 chạy nền để thấy tps từng giây.
Đo thật: mili giây, giây, và ai bị chặn

Hình 2: Catalog-only là 1–8 ms, viết lại bảng 739 ms, quét 241 ms. Hàng đợi khoá: 5 giây tps đọc bằng 0, lock_timeout cắt còn 2. CONCURRENTLY và VALIDATE giữ khoá nhẹ, UPDATE qua trong 1–2 ms.
| phép đo | kết quả |
|---|---|
| ADD COLUMN text / NOT NULL DEFAULT 'x' / DEFAULT now() | 7,6 / 1,1 / 1,0 ms |
| ADD COLUMN DEFAULT random() (volatile, 1M dòng) | 739 ms, bảng 145 → 339 MB |
| SET NOT NULL trực tiếp (quét 1M dòng) | 241 ms |
| 1 giao dịch mở 8 s + ALTER vô hại | ALTER chờ 5.531 ms; tps đọc 0 trong 5 giây |
cùng kịch bản, lock_timeout = 2s |
ALTER huỷ sau 2.077 ms; tps đọc 0 trong 2 giây |
| CREATE INDEX / CONCURRENTLY: khoá giữ, UPDATE 1 dòng chen vào | ShareLock, 112,5 ms / ShareUpdateExclusive, 1,6 ms |
| ADD CHECK trực tiếp / NOT VALID + VALIDATE: khoá, UPDATE chen vào | AccessExclusive, 37,5 ms / ShareUpdateExclusive, 0,8 ms |
| ADD CHECK dưới tải ghi: trực tiếp / NOT VALID + VALIDATE | 4.978 ms, ghi 0 trong 5 s / 1 + 302 ms, không giây nào 0 |
| SET NOT NULL: trực tiếp / sau CHECK đã VALID | 333 ms / 26 ms |
Default hằng số là catalog; default volatile là viết lại bảng. Thêm cột text mất 7,6 ms; thêm cột NOT NULL DEFAULT 'x' mất 1,1 ms — từ PostgreSQL 11 giá trị default hằng được ghi vào catalog và trả về khi đọc, không chạm trang nào. DEFAULT now() cũng 1,0 ms: now() là stable (một giá trị cho cả câu lệnh), không phải volatile, nên vẫn đi đường catalog — một chỗ nhiều người đoán sai, kể cả bài này trước khi đo. DEFAULT random() thì khác: 739 ms viết lại một triệu dòng dưới ACCESS EXCLUSIVE, và bảng từ 145 MB thành 339 MB vì bản cũ chưa được dọn. SET NOT NULL trực tiếp 241 ms — quét để chắc không có NULL. Hai lệnh cuối kéo tps đọc nền từ 80.000 xuống 19.000–51.000 trong năm giây; với bảng 300 GB, cùng hai câu lệnh là hàng chục phút không ai đọc được gì.
Một giao dịch mở cộng một ALTER một mili giây: năm giây không một SELECT nào chạy. Phiên 1 giữ ACCESS SHARE tám giây. ALTER TABLE docs ADD COLUMN c4 int — thứ bình thường xong trong 1 ms — chờ 5.531 ms. Trong lúc chờ, bốn client pgbench chỉ chạy SELECT … WHERE id = …: tps từ 80.000 rơi thẳng về 0 trong năm giây liền, rồi 44.000, rồi hồi. log_lock_waits ghi đúng bức tranh: một ALTER chờ AccessExclusiveLock, bốn SELECT chờ AccessShareLock — sau ALTER. Không có truy vấn nào chậm; chúng không chạy. Đây là sự cố "migration vô hại làm sập dịch vụ" ở dạng tinh khiết nhất, và thủ phạm không phải ALTER mà là giao dịch quên đóng ở một dịch vụ khác.
lock_timeout biến tổn thất không giới hạn thành tổn thất hai giây. Cùng kịch bản với SET lock_timeout = '2s' trước ALTER: canceling statement due to lock timeout sau 2.077 ms; tps đọc về 0 đúng hai giây rồi 80.000. ALTER thất bại — và đó là kết quả tốt: script migration thử lại sau vài giây, trong khi dịch vụ chỉ mất hai giây thay vì chờ tới khi ai đó tìm ra giao dịch treo. Mọi migration trên bảng đang có tải nên bắt đầu bằng SET lock_timeout ngắn và một vòng retry; đó là một dòng, và nó là khác biệt giữa "chậm hai giây" và "sự cố".
CREATE INDEX chặn ghi; CONCURRENTLY thì không — đo bằng một UPDATE chen vào. Không tải nền, CREATE INDEX trên ba triệu dòng mất 512 ms và giữ ShareLock; một UPDATE một dòng chạy giữa chừng chờ 112,5 ms — tới khi index xong. CREATE INDEX CONCURRENTLY mất 888 ms (hai lần quét), giữ ShareUpdateExclusiveLock, và UPDATE chen vào đi qua trong 1,6 ms — bằng lúc không có DDL. Dưới pgbench ghi, khác biệt thành tps: CREATE INDEX 2,9 giây với ba giây ghi bằng 0. Giá của CONCURRENTLY là chậm hơn 70%, và nếu thất bại giữa chừng nó để lại index INVALID phải DROP rồi làm lại — đổi lấy việc không ai nhận ra bạn đang dựng index.
NOT VALID rồi VALIDATE: cùng một quét, khoá khác hẳn. ADD CHECK md5(body) <> '' trực tiếp trên docs: 481 ms giữ AccessExclusiveLock, UPDATE chen vào bị chặn 37,5 ms (phần còn lại của quét). Tách làm hai: NOT VALID 1 ms, rồi VALIDATE CONSTRAINT 453 ms giữ ShareUpdateExclusiveLock — UPDATE chen vào 0,8 ms. Dưới tải ghi vào pgbench_accounts, chênh lệch lộ hết: ADD CHECK trực tiếp mất 4.978 ms với ghi bằng 0 trong năm giây, trong đó quét chỉ ~115 ms còn lại là chờ lấy khoá sau các writer đang chạy — rồi giữ khoá đó qua lượt quét. NOT VALID + VALIDATE: 1 ms + 302 ms, không giây nào tps về 0. SET NOT NULL đi cùng đường: trực tiếp 333 ms dưới khoá độc quyền; qua CHECK IS NOT NULL NOT VALID → VALIDATE (322 ms, ghi được) → SET NOT NULL chỉ còn 26 ms vì PostgreSQL 12+ tin CHECK đã hợp lệ và bỏ quét.
Đánh đổi cần cân nhắc
Thời gian giữ khoá không phải thời gian câu lệnh. ADD CHECK trực tiếp "mất 5 giây" nhưng quét chỉ 115 ms; 4,8 giây còn lại là xếp hàng sau writer — và trong lúc xếp hàng, mọi câu lệnh đến sau xếp hàng sau nó. Hai số đo khác nhau: thời gian DDL chạy, và thời gian người khác bị chặn. Phần này dùng UPDATE một dòng chen vào làm thước đo thứ hai; trong vận hành, đó là pg_stat_activity.wait_event_type = 'Lock' và log_lock_waits = on.
Lối đi vòng tốn nhiều bước hơn và có trạng thái dở dang. CONCURRENTLY để lại index INVALID khi hỏng; NOT VALID để lại ràng buộc chưa kiểm cho tới khi ai đó VALIDATE; ba bước SET NOT NULL là ba lần deploy nếu công cụ migration không hỗ trợ. Đổi lại là không còn khái niệm "cửa sổ bảo trì" cho thay đổi schema thông thường. Với bảng nhỏ (dưới vài chục MB) đường thẳng vẫn hợp lý — quét vài ms không đáng ba bước.
Phạm vi đo. Bảng 1–3 triệu dòng nằm gọn trong cache nên "quét cả bảng" là 115–480 ms; tỉ lệ giữa các cách mới là thứ mang sang bảng 300 GB, không phải số tuyệt đối. Tps ghi nền trên máy ảo dao động nên cột không-tải là số sạch để so khoá. Chưa chạm: đổi kiểu cột (luôn viết lại, cần cột mới + backfill + đổi tên), thêm FK NOT VALID với bảng tham chiếu đang bận, và pg_repack.
Ba ý mang về
- Thời gian khoá, không phải thời gian lệnh: ADD COLUMN 1 ms vẫn làm đọc về 0 trong 5 giây nếu một giao dịch khác đang mở;
SET lock_timeoutngắn + retry là dòng đầu tiên của mọi migration trên bảng có tải. - Biết lệnh nào quét hay viết lại: default hằng và
now()là catalog (1 ms);random()là viết lại (739 ms); SET NOT NULL, CHECK, FK trực tiếp là quét dưới khoá độc quyền. Trên bảng lớn, ba cái sau là downtime. - Tách khoá ngắn khỏi quét dài: CONCURRENTLY (UPDATE qua 1,6 ms thay vì chờ 113 ms), NOT VALID → VALIDATE (0,8 ms thay vì chặn), và SET NOT NULL sau CHECK VALID (26 ms thay vì 333) — cùng một kết quả, không giây nào về 0.
Nguồn
- PostgreSQL 16 — Explicit Locking, bảng xung đột khoá: https://www.postgresql.org/docs/16/explicit-locking.html
- PostgreSQL 16 — ALTER TABLE (ghi chú về rewrite,
NOT VALID,VALIDATE,SET NOT NULL): https://www.postgresql.org/docs/16/sql-altertable.html - PostgreSQL 16 — Building Indexes Concurrently: https://www.postgresql.org/docs/16/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY
- PostgreSQL 16 —
lock_timeout,log_lock_waits: https://www.postgresql.org/docs/16/runtime-config-client.html#GUC-LOCK-TIMEOUT - Braintree — Safe Operations For High Volume PostgreSQL: https://medium.com/paypal-tech/postgresql-at-scale-database-schema-changes-without-downtime-20d3749ed680
Phần sau đặt PgBouncer trước cụm: mỗi kết nối PostgreSQL tốn bao nhiêu, transaction pooling tăng thông lượng ra sao với hàng nghìn client, và những tính năng gãy khi đi qua pool — prepared statement, SET, advisory lock.