Bỏ qua để đến nội dung
Knowledge Hub

NOWAIT và SKIP LOCKED: chia job cho nhiều worker Trung cấp

Thời gian đọc ước tính: 11 phút

Ứ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ụ.

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 SHARE bình thường và có thể chờ một DDL xung đột.
  • Sau lỗi 55P03 trong transaction tường minh phải ROLLBACK (hoặc về savepoint) trước khi làm tiếp.
  • Tài liệu gọi kết quả của SKIP LOCKED là “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ới available_at. Worker nghỉ ngắn có jitter rồi poll lại.
  • lock_timeout là lựa chọn thay NOWAIT: 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.

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.

Hai worker: B bỏ qua job 1 đang bị A khoá, claim job 2 rồi xử lý sau commit
Hai worker: B bỏ qua job 1 đang bị A khoá, claim job 2 rồi xử lý sau commitBước 1/5: 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).thời giantransaction của A còn mở …FOR UPDATE job 1giữ lockWorker Agiữ job 1Worker BSKIP LOCKED
Bước 1/5

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 j
SET status = 'running', claimed_by = 'worker-b', claimed_at = now(),
claim_version = j.claim_version + 1
FROM picked
WHERE j.id = picked.id
RETURNING 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 UPDATE thành FOR NO KEY UPDATE vì 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ùng FOR UPDATE.
  • LIMIT dừng khoá khi đủ số row; có thể claim theo lô bằng LIMIT 10 và RETURNING.
  • claim_version tăng mỗi lần claim: worker cũ không thể ghi done bằ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 đè.

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.

Đang tải diagram…

PostgreSQL không tự đưa job running về pending. Hai tình huống:

  1. 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_timeout và TCP keepalive để lock của worker chết được nhả.
  2. 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ần
UPDATE jobs
SET status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END,
available_at = now() + interval '10 seconds' * power(2, attempts),
claimed_by = NULL, lease_until = NULL
WHERE 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.

  1. Worker A claim (claim_version = 1), commit.
  2. A gọi Email API, email được gửi.
  3. A chết trước UPDATE done.
  4. 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.

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 done hoặc partition theo thời gian: khi mọi job trong một partition cũ đã xong, DETACH PARTITION rồi DROP TABLE thay vì DELETE hà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 job pending cũ 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).
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.

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.

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
docker 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.sql
CREATE 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 ngay
BEGIN 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_jobs
WHERE status = 'pending' AND available_at <= now()
ORDER BY available_at, id LIMIT 1
FOR 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ó:

Terminal window
npx --prefix examples ts-node examples/src/postgres-skip-locked/demo.ts

Kết quả đã kiểm chứng trên PostgreSQL 17:

NOWAIT: SQLSTATE 55P03
SKIP LOCKED: id=2
Atomic claim: id=2, claim_version=1
Empty result: vẫn còn 3 job pending đang bị lock
Concurrent claims: hai worker nhận hai id khác nhau
Stale 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.

  1. Worker chạy Read Committed; claim và cập nhật running trong một transaction ngắn, gọi dịch vụ ngoài sau commit.
  2. Dùng CTE với FOR UPDATE SKIP LOCKED (hoặc FOR NO KEY UPDATE khi bảng có foreign key trỏ tới); ghi done có điều kiện theo claim_version.
  3. Có recovery dựa trên lease, giới hạn số lần thử và backoff; xử lý poison job.
  4. Idempotency key ổn định theo hiệu ứng nghiệp vụ cho mọi side effect.
  5. Poll có nghỉ và jitter; kết quả rỗng không có nghĩa queue trống.
  6. Chống bloat: partition hoặc xoá job done, autovacuum riêng cho bảng queue, idle_in_transaction_session_timeout.
  7. Giám sát: tuổi job pending cũ nhất, thời gian claim, n_dead_tup, độ dài queue.

Ư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

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
  1. A khoá job 1, job 2 sẵn sàng. Vì sao NOWAIT báo lỗi khi chọn đúng job 1 nhưng SKIP LOCKED có thể lấy job 2?
  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?
  3. Worker gửi email rồi chết trước khi ghi done. Vì sao claim_version chưa đủ để ngăn email bị gửi lại?
  4. Vì sao bảng queue chạy lâu ngày có thể chậm dần dù chỉ có vài job pending?