Một nhu cầu rất phổ biến: nhiều worker cùng xử lý công việc từ một hàng đợi. Phản xạ thường là dựng một message queue riêng (RabbitMQ, Kafka, SQS). Nhưng trong rất nhiều trường hợp, bạn đã có PostgreSQL, và nó có thể làm hàng đợi công việc ngay trong một bảng — nhờ SELECT ... FOR UPDATE SKIP LOCKED. Đây là một trong những tính năng hữu dụng nhất mà ít người biết, giải quyết sạch bài toán "nhiều worker, không trùng việc, không chờ nhau".

Bài này (phần 11 loạt PostgreSQL) đo thật 4 worker đồng thời vét một bảng job bằng SKIP LOCKED trên pg-lab, xác nhận không job nào bị xử lý trùng hay bỏ sót.

Vấn đề và giải pháp SKIP LOCKED

Nếu nhiều worker cùng lấy việc từ một bảng:

  • Dùng FOR UPDATE thường: worker 2 chờ worker 1 nhả khóa hàng — tắc nghẽn, không song song thật.
  • Không khóa gì: hai worker lấy cùng một job → xử lý trùng → sai.

SKIP LOCKED giải cả hai: mỗi worker khóa job nó lấy, các worker khác bỏ qua hàng đang bị khóa và lấy hàng rảnh kế tiếp — không chờ, không trùng.

SKIP LOCKED hàng đợi công việc trong PostgreSQL: vấn đề nhiều worker lấy cùng bảng jobs, FOR UPDATE thường worker 2 chờ worker 1 nhả khóa hàng tắc nghẽn chậm, không khóa gì 2 worker lấy cùng 1 job xử lý trùng sai, cần mỗi worker lấy 1 job rảnh bỏ qua job đang bị worker khác giữ; SKIP LOCKED bỏ qua hàng đang bị khóa lấy hàng rảnh kế tiếp mỗi worker lặp UPDATE jobs SET done_by W1 WHERE id bằng SELECT id FROM jobs WHERE done_by IS NULL ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1 RETURNING id nên mỗi worker lấy 1 job khác nhau không chờ nhau không trùng; advisory lock khóa mức ứng dụng không gắn với hàng khóa theo một khóa số tùy ý do ứng dụng định nghĩa SELECT pg_try_advisory_lock 12345 thử lấy khóa không chờ true false SELECT pg_advisory_unlock 12345 dùng cho chỉ 1 tiến trình chạy job định kỳ này mà không cần bảng hàng thật

Hình 1: FOR UPDATE thường làm worker chờ nhau; không khóa thì xử lý trùng. SKIP LOCKED cho mỗi worker bỏ qua hàng đang bị khóa, lấy hàng rảnh kế tiếp — không chờ, không trùng. advisory lock cho khóa mức ứng dụng.

-- Moi worker lap vong lay 1 job:
UPDATE jobs SET done_by = 'W1'
WHERE id = (
  SELECT id FROM jobs WHERE done_by IS NULL
  ORDER BY id
  FOR UPDATE SKIP LOCKED   -- bo qua hang worker khac dang giu
  LIMIT 1
)
RETURNING id;

Đo thật: 4 worker vét 300 job

Mình tạo bảng 300 job, chạy 4 worker song song trên pg-lab, mỗi worker lặp lấy job bằng SKIP LOCKED cho tới khi hết. Kiểm kết quả trực tiếp từ database. Kết quả thật:

Bảng kết quả đo thật 4 worker đồng thời với SKIP LOCKED trên pg-lab postgresql 16.15 300 job 4 worker chạy song song kiểm từ DB: kết quả 300 job được chia đều mỗi job đúng 1 lần, tổng job 300 đã làm 300 chưa làm 0 job xử lý trùng 0, worker W1 76 job W2 75 job W3 75 job W4 74 job; 4 worker chạy song song vét sạch 300 job tải chia gần như đều 76 75 75 74 mỗi job được xử lý đúng một lần không trùng không sót không worker nào phải chờ khóa mỗi cái bỏ qua job đang bị giữ và lấy job rảnh kế tiếp. Badge output thật màu xanh

Hình 2: Kết quả thật. 4 worker vét sạch 300 job: tổng 300, đã làm 300, chưa làm 0, trùng 0. Chia đều W1=76, W2=75, W3=75, W4=74. Mỗi job xử lý đúng một lần, không worker nào phải chờ khóa.

  • 300/300 job hoàn thành, 0 trùng, 0 sót: mỗi job có đúng một done_by, không job nào bị hai worker lấy, không job nào bị bỏ qua. SKIP LOCKED đảm bảo tính đúng.
  • Tải chia gần như đều: W1=76, W2=75, W3=75, W4=74. Bốn worker tự động cân bằng — cái nào rảnh thì lấy job tiếp theo, không ai độc chiếm.
  • Không chờ khóa: khác FOR UPDATE thường (worker 2 sẽ treo chờ worker 1), ở đây mỗi worker bỏ qua job đang bị giữ và lấy ngay job rảnh — song song thật sự.

Đây là một hàng đợi công việc hoàn chỉnh, đúng đắn, chỉ với một câu SQL — không cần hạ tầng message queue riêng.

Advisory lock: khóa mức ứng dụng

Ngoài khóa hàng, PostgreSQL có advisory lock — khóa theo một con số tùy ý do ứng dụng định nghĩa, không gắn với hàng nào:

SELECT pg_try_advisory_lock(12345);  -- thu lay khoa 12345, khong cho (true/false)
-- ... lam viec doc quyen ...
SELECT pg_advisory_unlock(12345);

Hữu ích cho "chỉ một tiến trình được chạy tác vụ này cùng lúc" (ví dụ một cron job chạy trên nhiều instance ứng dụng, chỉ muốn một instance thực thi) — mà không cần dựng bảng/hàng thật. Nó là khóa theo quy ước (advisory): PostgreSQL không ép, các bên phải đồng ý dùng cùng con số khóa.

Đánh đổi cần cân nhắc

SKIP LOCKED gọn và đủ tới quy mô vừa, nhưng không thay message queue ở thông lượng cực cao. Ưu điểm lớn: hàng đợi nằm cùng database với dữ liệu nghiệp vụ, nên lấy job và cập nhật dữ liệu có thể trong cùng một giao dịch (atomic) — một đảm bảo mà message queue riêng khó cho. Và nó đơn giản: không thêm hạ tầng. Nhưng SKIP LOCKED dựa trên polling (worker lặp truy vấn) và khóa hàng, nên ở thông lượng hàng triệu job/giây, một message queue chuyên dụng (Kafka, SQS) scale tốt hơn. Tới hàng nghìn job/giây, SKIP LOCKED thường là lựa chọn gọn và đủ — đừng thêm Kafka nếu PostgreSQL đã làm được.

Phải xử lý job xong đúng cách để không lấy lại. Trong demo, mình đánh dấu done_by để job không còn được chọn (WHERE done_by IS NULL). Trong thực tế, bạn cần: hoặc DELETE job sau khi xử lý xong, hoặc đánh dấu trạng thái (pending → processing → done) trong cùng giao dịch với việc lấy. Nếu worker chết giữa chừng (sau khi lấy, trước khi xong), cần cơ chế đòi lại job treo (ví dụ reset các job "processing" quá lâu về "pending") — giống claim-with-timeout. Hàng đợi SQL không miễn phí phần xử lý lỗi này.

Nhớ ORDER BY và cân nhắc chỉ số. Không có ORDER BY, thứ tự lấy job không xác định. Có ORDER BY id (hoặc theo priority, created_at) cho thứ tự dự đoán được. Và truy vấn lấy job nên dùng được index trên cột lọc/sắp (ví dụ index một phần WHERE done_by IS NULL) để không phải quét toàn bảng mỗi lần poll — ở quy mô lớn, quét tuần tự mỗi lần lấy job sẽ giết hiệu năng.

Ba ý mang về

  1. SKIP LOCKED biến một bảng thành hàng đợi công việc đúng đắn. Đo thật: 4 worker song song vét 300 job, chia đều 76/75/75/74, mỗi job đúng một lần, 0 trùng 0 sót, không worker nào chờ khóa. Mỗi worker bỏ qua hàng đang bị khóa và lấy hàng rảnh kế tiếp.
  2. Không phải lúc nào cũng cần message queue riêng. Hàng đợi trong PostgreSQL cho phép lấy job atomic cùng giao dịch nghiệp vụ, đơn giản, đủ tới hàng nghìn job/giây. Chỉ cần Kafka/SQS khi thông lượng cực cao (hàng triệu/giây). advisory lock cho "chỉ 1 tiến trình chạy tác vụ" mà không cần bảng.
  3. Hàng đợi SQL cần xử lý lỗi và index đúng. Đánh dấu/xóa job trong cùng giao dịch lấy; có cơ chế đòi lại job treo khi worker chết; luôn ORDER BY và dùng index (partial index trên trạng thái) để poll không quét toàn bảng.

Nguồn

Phần sau là bài tổng kết cả loạt: nối mọi cơ chế đồng thời của PostgreSQL (MVCC, isolation, khóa, deadlock, vacuum, WAL) thành một khung tư duy và checklist tránh bug concurrency cho kỹ sư backend.