NOWAIT và SKIP LOCKED: chia job cho nhiều worker Trung cấp
Thời gian đọc ước tính: 11 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Ứng dụng bán hàng ghi một job vào bảng cho mỗi email xác nhận đơn. Hai worker cùng đọc bảng để gửi. Nếu cả hai SELECT job đầu tiên rồi gửi, khách nhận hai email.
Row-level lock giải quyết việc giữ quyền sửa một row; queue thêm một câu hỏi: khi job đầu đang bị khoá, worker thứ hai nên chờ hay lấy việc khác?
Nếu job 1 và job 2 là hai đơn độc lập, chờ job 1 chỉ làm worker thứ hai nhàn rỗi trong khi queue còn việc. Ngược lại, nếu worker đang đặt đúng ghế số 1, tự chuyển sang ghế 2 là sai. Cách xử lý lock phải theo ý nghĩa nghiệp vụ.
Ba lựa chọn khi row bị khoá
Phần tiêu đề “Ba lựa chọn khi row bị khoá”| Câu lệnh | Gặp row bị khoá | Dùng khi |
|---|---|---|
FOR UPDATE |
Chờ transaction giữ lock kết thúc | Cần đúng row đó và chấp nhận chờ |
FOR UPDATE NOWAIT |
Báo lỗi SQLSTATE 55P03 ngay |
Cần phản hồi sớm để app quyết định thử lại hoặc báo bận |
FOR UPDATE SKIP LOCKED |
Bỏ row đó, tìm row khác | Lấy việc độc lập từ queue |
Cần nhớ:
- Đây là tuỳ chọn của câu lấy row lock, không phải isolation level mới.
- Chúng chỉ áp dụng cho row lock: câu lệnh vẫn lấy table lock
ROW SHAREbình thường và có thể chờ một DDL xung đột. - Sau lỗi
55P03trong transaction tường minh phảiROLLBACK(hoặc về savepoint) trước khi làm tiếp. - Tài liệu gọi kết quả của
SKIP LOCKEDlà “inconsistent view”, không dùng cho mục đích chung. Kết quả rỗng không chứng minh queue trống: có thể mọi job đủ điều kiện đang bị worker khác khoá, hoặc chưa tớiavailable_at. Worker nghỉ ngắn có jitter rồi poll lại. lock_timeoutlà lựa chọn thayNOWAIT: chờ tối đa một khoảng rồi báo lỗi lock không lấy được thay vì báo lỗi ngay.
Mẫu claim an toàn
Phần tiêu đề “Mẫu claim an toàn”Claim là ghi nhận một worker đã nhận job. Thiết kế hai đoạn: transaction ngắn chọn và chuyển job sang running; sau commit mới gọi dịch vụ ngoài.
A khoá job 1 và giữ transaction mở (trong ví dụ để làm rõ hành vi; thực tế transaction claim phải ngắn).
Câu claim (CTE chọn row với locking clause, rồi UPDATE đúng row đó):
BEGIN;WITH picked AS ( SELECT id FROM demo_queue_jobs WHERE status = 'pending' AND available_at <= now() ORDER BY available_at, id LIMIT 1 FOR UPDATE SKIP LOCKED)UPDATE demo_queue_jobs AS jSET status = 'running', claimed_by = 'worker-b', claimed_at = now(), claim_version = j.claim_version + 1FROM pickedWHERE j.id = picked.idRETURNING j.id, j.payload, j.claim_version;COMMIT;Vì sao viết thế này:
- Tài liệu chỉ rõ locking clause không áp dụng xuyên vào
WITH, nên muốn khoá trong CTE phải đặt locking clause bên trong CTE. - Có thể đổi
FOR UPDATEthànhFOR NO KEY UPDATEvì claim không đổi cột khoá, nên không chặn kiểm tra foreign key (xem bảng xung đột). Phần demo đo được ở dưới dùngFOR UPDATE. LIMITdừng khoá khi đủ số row; có thể claim theo lô bằngLIMIT 10vàRETURNING.claim_versiontăng mỗi lần claim: worker cũ không thể ghidonebằng version cũ.- Khi xong việc, ghi kết quả có điều kiện:
UPDATE ... SET status = 'done' WHERE id = $1 AND status = 'running' AND claim_version = $2. Không trả row nghĩa là claim đã mất, đừng ghi đè.
Phải dùng Read Committed
Phần tiêu đề “Phải dùng Read Committed”Worker chạy ở Read Committed (mặc định). Ở Repeatable Read và Serializable, nếu row ứng viên đã bị transaction khác commit thay đổi sau snapshot, câu lệnh ném 40001 thay vì bỏ qua.
Ở Read Committed, ORDER BY kèm locking clause còn có thể trả row lệch thứ tự nếu transaction khác đổi giá trị sắp xếp trước khi row được khoá, nên đừng kỳ vọng FIFO nghiêm ngặt.
Tránh chọn xong rồi nhả lock trước khi ghi claim: SELECT ... SKIP LOCKED chạy riêng ở autocommit thì lock hết ngay khi câu lệnh kết thúc, và worker khác chọn được cùng job.
Worker chết thì sao?
Phần tiêu đề “Worker chết thì sao?”Đang tải diagram…
PostgreSQL không tự đưa job running về pending. Hai tình huống:
- Chết trước commit claim: transaction rollback khi server nhận ra connection đã đứt, job nhận lại được. Việc phát hiện có thể trễ, nên đặt
idle_in_transaction_session_timeoutvà TCP keepalive để lock của worker chết được nhả. - Chết sau commit: row vẫn
running. Cần tiến trình recovery dựa trên lease: quyền xử lý có thời hạn, gia hạn bằng heartbeat. Đừng coi “quá 30 giây” là bằng chứng worker chết; recovery phải cập nhật có điều kiện để không ghi đè heartbeat mới.
Phác thảo (mở rộng ngoài demo: thêm cột attempts int và lease_until timestamptz; câu claim tăng attempts và đặt lease_until = now() + interval '60 seconds'):
-- Worker gia hạn lease định kỳ; UPDATE 0 nghĩa là đã mất claim, dừng xử lýUPDATE jobs SET lease_until = now() + interval '60 seconds'WHERE id = $1 AND status = 'running' AND claim_version = $2;
-- Recovery: chỉ đụng job hết lease, backoff tăng dần, dừng sau 5 lầnUPDATE jobsSET status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END, available_at = now() + interval '10 seconds' * power(2, attempts), claimed_by = NULL, lease_until = NULLWHERE status = 'running' AND lease_until < now();Điều kiện lease_until < now() được kiểm tra lại sau khi lấy row lock ở Read Committed, nên heartbeat commit trước sẽ thắng. Lần claim kế tiếp tăng claim_version, nên worker cũ dù còn sống cũng không ghi được done.
Khoảng trống quan trọng: worker gửi email xong rồi chết trước khi ghi done. Worker mới không biết email đã đi chưa.
- Worker A claim (
claim_version = 1), commit. - A gọi Email API, email được gửi.
- A chết trước
UPDATE done. - Recovery đưa job về
pending, B claim (claim_version = 2) và gửi lại.
claim_version chỉ bảo vệ trạng thái trong database. Chống gửi trùng phải dùng idempotency với key ổn định theo hiệu ứng nghiệp vụ, ví dụ email-order-102, không phải theo version vì retry cùng email phải dùng cùng key. Xem idempotent consumer.
Bảng queue và bloat: rủi ro lớn nhất
Phần tiêu đề “Bảng queue và bloat: rủi ro lớn nhất”Mỗi job đi qua pending, running, done nên mỗi job sinh nhiều version cũ. Cột status nằm trong điều kiện của partial index và available_at nằm trong index, nên các update này tạo thêm index entry.
Nếu VACUUM không theo kịp, index scan của câu claim phải bước qua ngày càng nhiều entry chết, và độ trễ claim tăng dần cho đến khi queue chậm hẳn dù số job pending không nhiều. Một transaction dài chặn VACUUM dọn (xem VACUUM và bloat) làm hiện tượng này nhanh hơn.
Cách phòng:
- Xoá job
donehoặc partition theo thời gian: khi mọi job trong một partition cũ đã xong,DETACH PARTITIONrồiDROP TABLEthay vìDELETEhàng loạt. - Đặt autovacuum riêng cho bảng queue, ví dụ
ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.01). - Không để transaction dài (báo cáo, worker treo) trên cùng database.
- Giám sát:
n_dead_tup, tuổi jobpendingcũ nhất, thời gian trung bình của câu claim. - Partition cũ giữ lại lâu, gần như không còn ghi, vẫn cần freeze (xem wraparound).
Khi nào dùng, khi nào không
Phần tiêu đề “Khi nào dùng, khi nào không”| Dùng queue trong PostgreSQL | Dùng broker (Kafka, RabbitMQ…) |
|---|---|
| Job độc lập, không cần thứ tự theo key | Cần thứ tự nghiêm ngặt theo key hoặc theo partition |
| Cần enqueue cùng transaction với dữ liệu nghiệp vụ | Cần replay, retention dài, nhiều consumer group, fan-out |
| Tải vừa phải; không muốn vận hành thêm hệ thống | Throughput lớn đến mức queue làm database nghẽn |
Rule of thumb của bài (không phải số đo, chưa có nguồn chính thức): vài trăm đến vài nghìn claim mỗi giây thường là vùng thoải mái cho một database cỡ vừa.
Con số thật phụ thuộc phần cứng, schema, index và tốc độ VACUUM, nên hãy đo trên hệ của bạn. Vượt xa vùng đó, hoặc cần replay và fan-out, thì dùng broker.
Đã có thư viện làm sẵn nhiều phần của mẫu này, như pgmq, Graphile Worker, River, pg-boss và Oban; ưu tiên dùng thay vì tự viết recovery và retry.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo từng bước với hai session psql
Chạy từ thư mục gốc repo; file nằm trong examples/src/postgres-skip-locked/. Bảng có tiền tố demo_, chỉ dùng trên database thực hành. Chạy lại setup giữ nguyên trạng thái cũ; muốn sạch hơn dùng demo tự động ở dưới.
docker compose -f examples/docker-compose.yml up -d postgresdocker compose -f examples/docker-compose.yml exec -T postgres \ psql -U hub -d hub -v ON_ERROR_STOP=1 \ < examples/src/postgres-skip-locked/setup.sqlCREATE TABLE IF NOT EXISTS demo_queue_jobs ( id bigint PRIMARY KEY, payload text NOT NULL, status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'done', 'failed')), available_at timestamptz NOT NULL DEFAULT now(), claimed_by text, claimed_at timestamptz, claim_version bigint NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS demo_queue_jobs_pending_idx ON demo_queue_jobs (available_at, id) WHERE status = 'pending';Mở hai terminal (A và B) bằng docker compose -f examples/docker-compose.yml exec postgres psql -U hub -d hub.
-- A: khoá job 1 và giữ transaction mởBEGIN ISOLATION LEVEL READ COMMITTED;SELECT id FROM demo_queue_jobs WHERE id = 1 FOR UPDATE;
-- B: NOWAIT báo lỗi 55P03 ngayBEGIN ISOLATION LEVEL READ COMMITTED;SELECT id FROM demo_queue_jobs WHERE id = 1 FOR UPDATE NOWAIT;ROLLBACK;
-- B: SKIP LOCKED bỏ job 1, trả id = 2 (với dữ liệu ban đầu)BEGIN ISOLATION LEVEL READ COMMITTED;SELECT id FROM demo_queue_jobsWHERE status = 'pending' AND available_at <= now()ORDER BY available_at, id LIMIT 1FOR UPDATE SKIP LOCKED;ROLLBACK;Sau đó chạy câu claim ở phần “Mẫu claim an toàn” ở B (B nhận job 2, payload email-order-102, version 1), rồi ROLLBACK ở A để nhả lock job 1.
Demo tự động tạo schema riêng, dùng hai connection rồi xoá schema của nó:
npx --prefix examples ts-node examples/src/postgres-skip-locked/demo.tsKết quả đã kiểm chứng trên PostgreSQL 17:
NOWAIT: SQLSTATE 55P03SKIP LOCKED: id=2Atomic claim: id=2, claim_version=1Empty result: vẫn còn 3 job pending đang bị lockConcurrent claims: hai worker nhận hai id khác nhauStale completion: UPDATE 0Ở kiểm tra kết quả rỗng, A khoá mọi job pending còn lại nên B không nhận job nào, trong khi câu đếm thường vẫn thấy ba job pending: đây là khác biệt giữa “không lấy được việc lúc này” và “không còn việc”.
Demo là smoke test, không chứng minh an toàn dưới tải: hai connection không đảm bảo thật sự chồng thời gian, và demo chưa có heartbeat, recovery, backoff hay tích hợp Email API.
Checklist production
Phần tiêu đề “Checklist production”- Worker chạy Read Committed; claim và cập nhật
runningtrong một transaction ngắn, gọi dịch vụ ngoài sau commit. - Dùng CTE với
FOR UPDATE SKIP LOCKED(hoặcFOR NO KEY UPDATEkhi bảng có foreign key trỏ tới); ghidonecó điều kiện theoclaim_version. - Có recovery dựa trên lease, giới hạn số lần thử và backoff; xử lý poison job.
- Idempotency key ổn định theo hiệu ứng nghiệp vụ cho mọi side effect.
- Poll có nghỉ và jitter; kết quả rỗng không có nghĩa queue trống.
- Chống bloat: partition hoặc xoá job
done, autovacuum riêng cho bảng queue,idle_in_transaction_session_timeout. - Giám sát: tuổi job pending cũ nhất, thời gian claim,
n_dead_tup, độ dài queue.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Nhiều worker nhận job độc lập mà không chờ cùng row đầu queue.
-
Job và dữ liệu nghiệp vụ ghi trong cùng database transaction.
-
Quan sát queue bằng SQL, không cần vận hành thêm broker cho luồng đơn giản.
< slot="cons"> >
-
Queue thêm tải ghi, quét index, bloat và cleanup vào database nghiệp vụ.
-
Ứng dụng tự lo polling, recovery, retry và hiệu ứng trùng.
-
Bỏ qua lock không bảo đảm FIFO nghiêm ngặt hay công bằng giữa các job.
Nhược điểm
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
ORDER BY id bảo đảm email gửi đúng thứ tự |
Worker B có thể gửi job 2 trước A | Chỉ dùng cho job độc lập; tuần tự hoá khi nghiệp vụ yêu cầu |
| Claim một lần nghĩa là xử lý đúng một lần | Crash sau side effect gây retry trùng | Idempotency theo hiệu ứng |
NOWAIT lỗi thì retry ngay mãi |
Tải tranh lock liên tục | Retry có giới hạn, backoff, deadline |
| Không nhận row nghĩa là queue hết việc | Bỏ sót row đang bị khoá hoặc chưa tới available_at |
Poll lại có khoảng nghỉ và theo dõi trạng thái queue |
| Chạy worker ở Repeatable Read cho “an toàn hơn” | Lỗi 40001 khi row ứng viên đã đổi sau snapshot |
Dùng Read Committed |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- A khoá job 1, job 2 sẵn sàng. Vì sao
NOWAITbáo lỗi khi chọn đúng job 1 nhưngSKIP LOCKEDcó thể lấy job 2? - Sau commit claim, row lock đã nhả. Điều gì ngăn query claim mới nhận lại job vừa nhận?
- Worker gửi email rồi chết trước khi ghi
done. Vì saoclaim_versionchưa đủ để ngăn email bị gửi lại? - Vì sao bảng queue chạy lâu ngày có thể chậm dần dù chỉ có vài job
pending?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- PostgreSQL 17 — SELECT, The Locking Clause: cú pháp, giới hạn của
NOWAIT/SKIP LOCKED,LIMITvà lock trong CTE. - PostgreSQL 17 — Explicit Locking: các loại lock, xung đột và hành vi ở Repeatable Read.
- PostgreSQL 17 — Transaction Isolation: snapshot và hành vi khi row bị cập nhật.
- PostgreSQL 17 — Routine Vacuuming: dọn version cũ và tác động của transaction dài.