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 UPDATEthườ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.

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:

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 UPDATEthườ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ề
- 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.
- 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.
- 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
- PostgreSQL docs — SELECT ... FOR UPDATE SKIP LOCKED: https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
- PostgreSQL docs — Advisory Locks: https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
- 2ndQuadrant — What is SKIP LOCKED for in PostgreSQL: https://www.2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5/
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.