Lock queue: vì sao một ALTER TABLE đang chờ làm mọi SELECT phải xếp hàng Trung cấp
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”Migration thêm cột vào bảng orders: ALTER TABLE orders ADD COLUMN note text. Không có DEFAULT nên PostgreSQL chỉ sửa metadata, không ghi lại bảng; trên máy dev chạy vài mili giây.
Trên production, một report đã SELECT từ orders trong transaction chưa kết thúc. Migration đứng chờ. Ngay sau đó mọi API đọc orders cũng chờ, connection pool cạn.
Report chỉ đọc, API chỉ đọc, hai bên không xung đột với nhau. Vì sao một DDL “nhanh” mà chưa chạy được lại chặn cả các SELECT vốn không liên quan tới report?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
DDL cần ACCESS EXCLUSIVE trên bảng nóng |
lock_timeout ngắn + retry có backoff |
Migration chạy lại được an toàn |
| Có session giữ lock lâu trên bảng | Tìm và xử lý blocker trước | Được phép huỷ session đó |
| DDL ghi lại bảng (đổi kiểu, default volatile) | Tách theo expand–contract | App chịu được giai đoạn chuyển tiếp |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Mỗi câu lệnh lấy table-level lock trên bảng nó đụng tới. Đủ 8 mode ở bài table-level locks; ở đây chỉ cần hai:
| Mode | Ai lấy | Xung đột với |
|---|---|---|
ACCESS SHARE |
SELECT thường |
Chỉ ACCESS EXCLUSIVE |
ACCESS EXCLUSIVE |
Phần lớn dạng ALTER TABLE, DROP TABLE |
Mọi mode |
Ba điều quyết định sự cố:
- Lock giữ đến hết transaction: report đọc xong mà chưa
COMMITvẫn giữACCESS SHARE. - Theo tài liệu
ALTER TABLE, lệnh lấyACCESS EXCLUSIVE“unless explicitly noted”;ADD COLUMNkhông nằm trong các ngoại lệ. - Request phải chờ đi vào hàng đợi lock (wait queue) của object đó.
Luật của hàng đợi
Phần tiêu đề “Luật của hàng đợi”Chương Explicit Locking không mô tả thứ tự hàng đợi. Hai nguồn chính thống nói rõ:
- Tài liệu
pg_blocking_pids(): một process chặn process khác nếu nó giữ lock xung đột (hard block), hoặc đang chờ một lock xung đột và đứng trước trong hàng đợi (soft block). src/backend/storage/lmgr/README(source PostgreSQL 17): request chỉ được cấp ngay khi không xung đột với lock đang giữ lẫn request đang chờ; khi nhả lock, waiter chỉ được đánh thức nếu không xung đột với waiter đứng trước.
README tóm lại: “conflicting requests are granted in order of arrival”. Nhờ vậy DDL không bị starvation bởi luồng SELECT liên tục chen lên trước.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Ai giữ lock, ai chờ ai, và cái gì đổi khi migration bỏ cuộc?
Report mở transaction, đọc orders rồi ngồi im: vẫn giữ ACCESS SHARE đến khi kết thúc transaction.
Trạng thái sau từng bước:
| Bước | Giữ lock | Đang chờ | Thay đổi |
|---|---|---|---|
| 1 | Report: ACCESS SHARE |
— | Report thành idle in transaction |
| 2 | Report | Migration (ACCESS EXCLUSIVE) |
Hard block bởi Report |
| 3 | Report | Migration, API | API soft block bởi Migration |
| 4 | Report | API | Migration lỗi 55P03, rời hàng |
| 5 | Report, API | — | API được cấp ACCESS SHARE |
| 6 | API (đến khi xong) | — | Report commit |
Không có lock_timeout (mặc định 0) thì bước 4 không xảy ra: mọi SELECT đến sau xếp hàng cho tới khi report kết thúc và migration chạy xong.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”lock_timeoutáp dụng riêng cho từng lần xin lock, cả tường minh lẫn ngầm; mặc định0(tắt); hết hạn thì huỷ câu lệnh với55P03.- Đặt
lock_timeouttheo session/transaction migration, không trongpostgresql.conf(ảnh hưởng mọi session). - Nếu
statement_timeoutkhác 0 và ≤lock_timeout,lock_timeoutvô nghĩa vìstatement_timeoutluôn nổ trước. - Gọi
pg_blocking_pids()quá dày có chi phí: nó cần truy cập độc quyền ngắn vào lock manager. - Mỗi lần retry, API vẫn có thể bị chặn tới
lock_timeout. Chọn giá trị theo độ trễ API chấp nhận được. - Chi tiết
statement_timeout,idle_in_transaction_session_timeout,transaction_timeout,log_lock_waits: xem timeout và giám sát lock.
Quy trình migration an toàn
Phần tiêu đề “Quy trình migration an toàn”Đang tải diagram…
Tìm blocker trước khi chạy:
SELECT pid, application_name, state, now() - xact_start AS xact_age, left(query, 60) AS last_queryFROM pg_stat_activityWHERE pid IN ( SELECT pid FROM pg_locks WHERE relation = 'orders'::regclass AND granted)ORDER BY xact_start;Huỷ blocker là quyết định nghiệp vụ. Với session idle in transaction, pg_cancel_backend(pid) không nhả lock (đã thử: lock vẫn granted); pg_terminate_backend(pid) đóng connection và rollback transaction.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo trên PostgreSQL 17.11 (examples/docker-compose.yml), schema demo_lock_queue, bảng orders 1.000 row, ba session psql chạy nền:
- report (t=0s):
BEGIN; SELECT count(*) FROM demo_lock_queue.orders;rồi đểidle in transaction12 giây. - migration (t=1s):
ALTER TABLE demo_lock_queue.orders ADD COLUMN note text; - api (t=2s):
SELECT count(*) FROM demo_lock_queue.orders;
Session thứ tư quan sát lúc t=3s:
SELECT pid, application_name AS app, state, pg_blocking_pids(pid) AS blocked_byFROM pg_stat_activityWHERE application_name IN ('report', 'migration', 'api');
SELECT a.application_name AS app, l.mode, l.grantedFROM pg_locks l JOIN pg_stat_activity a USING (pid)WHERE l.relation = 'demo_lock_queue.orders'::regclass; pid | app | state | blocked_by-------+-----------+---------------------+------------ 17673 | report | idle in transaction | {} 17680 | migration | active | {17673} 17708 | api | active | {17680}
app | mode | granted-----------+---------------------+--------- report | AccessShareLock | t migration | AccessExclusiveLock | f api | AccessShareLock | fAPI bị chặn bởi migration (pid 17680), không phải report: lock nó xin tương thích với report nhưng granted = f. Đó là soft block.
Timing hai lần chạy (PID và mili giây đổi mỗi lần):
| Lần chạy | Migration | API SELECT |
|---|---|---|
lock_timeout = 0 (mặc định) |
Thành công sau 10922 ms, khi report commit |
Trả 1000 row sau 9914 ms |
lock_timeout = '3s' |
canceling statement due to lock timeout sau 3001 ms |
Trả 1000 row sau 2002 ms, report chưa commit |
Lần hai cho thấy hàng đợi thông ngay khi migration rời đi, dù report còn giữ lock. SQLSTATE (xem bằng VERBOSITY verbose) là 55P03.
Retry có backoff, chạy thử khi report giữ lock 5 giây:
for attempt in 1 2 3 4 5; do if psql -v ON_ERROR_STOP=1 \ -c "SET lock_timeout = '1s'" \ -c "ALTER TABLE demo_lock_queue.orders ADD COLUMN note text"; then echo "attempt $attempt: ok"; break fi sleep_s=$(( 2 ** (attempt - 1) )) echo "attempt $attempt: lock timeout, sleep ${sleep_s}s"; sleep "$sleep_s"doneERROR: canceling statement due to lock timeoutattempt 1: lock timeout, sleep 1sERROR: canceling statement due to lock timeoutattempt 2: lock timeout, sleep 2sattempt 3: okBản tối giản: chưa có jitter, chưa lọc riêng 55P03 (lỗi cú pháp cũng bị retry), chưa báo động khi hết lượt.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
lock_timeoutgiới hạn thời gian một DDL có thể chặn cả bảng. -
Retry có backoff cho migration cơ hội chạy khi blocker ngắn đã xong.
-
pg_blocking_pids()chỉ ra cả soft block.
< slot="cons"> >
-
Mỗi lần thử vẫn chặn API tối đa bằng
lock_timeout. -
Blocker kéo dài thì hết lượt retry, migration không chạy.
-
Huỷ blocker có thể làm mất việc của report hoặc batch.
-
Migration phải chạy lại được an toàn.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Mọi migration có DDL đặt
lock_timeoutngắn trong cùng session/transaction. - Retry chỉ khi SQLSTATE
55P03, có giới hạn lượt, backoff tăng dần và jitter; hết lượt thì dừng và báo. - Trước khi chạy, tìm transaction đang giữ lock trên bảng đích.
- Đặt
statement_timeoutlớn hơnlock_timeoutđể chặn DDL chạy lâu bất thường. - Không gộp DDL với backfill; DDL ghi lại bảng tách theo expand–contract.
- Đặt
idle_in_transaction_session_timeoutcho role ứng dụng và report để transaction treo không giữ lock mãi.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Sai lầm hay gặp: đặt timeout cho câu DDL nhưng gộp cả backfill vào cùng transaction.
Một transaction migration giữ ACCESS EXCLUSIVE suốt quá trình backfill
Hay gặp
BEGIN;SET LOCAL lock_timeout = '3s';ALTER TABLE orders ADD COLUMN note text;UPDATE orders SET note = ''; -- giữ ACCESS EXCLUSIVE đến COMMITCOMMIT;Nên viết
BEGIN;SET LOCAL lock_timeout = '3s';ALTER TABLE orders ADD COLUMN note text;COMMIT;-- Backfill riêng, theo lô nhỏ, mỗi lô một transactionVì sao
- Lock của
ALTERgiữ đếnCOMMIT, nênUPDATEcả bảng chặn mọi SELECT suốt thời gian chạy. lock_timeoutchỉ giới hạn thời gian chờ, không giới hạn thời gian giữ.
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| SELECT không xung đột với SELECT nên không bao giờ chờ nhau | API treo sau DDL đang chờ | Hiểu soft block; đặt lock_timeout cho DDL |
| Report đọc xong là nhả lock | idle in transaction giữ ACCESS SHARE vô hạn |
Commit sớm, idle_in_transaction_session_timeout |
Chỉ cần statement_timeout |
≤ lock_timeout thì lock_timeout vô nghĩa |
lock_timeout < statement_timeout |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- API xin
ACCESS SHARE, tương thích với lock của report. Vì sao demo vẫn thấygranted = fvàblocked_bytrỏ tới migration? - Trong lần chạy
lock_timeout = '3s', vì sao API nhận kết quả sau khoảng 2 giây dù report chưa commit? - Đặt
statement_timeout = '2s'vàlock_timeout = '5s'cho migration thì timeout nào xảy ra trước, và vì sao?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- PostgreSQL 17 — Explicit Locking: Table-Level Locks: mode của từng lệnh, lock giữ đến hết transaction.
- PostgreSQL 17 — Session Information Functions: định nghĩa hard block và soft block của
pg_blocking_pids(). - PostgreSQL 17 — lock_timeout: default, phạm vi, quan hệ với
statement_timeout. - PostgreSQL 17 — ALTER TABLE: lock mặc định, khi nào
ADD COLUMNghi lại bảng. - PostgreSQL source — lmgr README: luật cấp lock theo thứ tự đến và cách đánh thức waiter.