Một trong những mẫu thiết kế đẹp nhất của PostgreSQL là dùng chính database làm hàng đợi công việc — nhiều worker cùng lấy job ra xử lý mà không giẫm lên nhau, không cần message broker riêng. Chìa khóa là hai công cụ khóa hàng: FOR UPDATE để khóa chủ động, và SKIP LOCKED để bỏ qua hàng đang bị worker khác giữ. Bài này đo thật vì sao SKIP LOCKED biến một hàng đợi tranh chấp thành hàng đợi song song mượt mà.
FOR UPDATE khóa chủ động — nhưng dễ nối đuôi
SELECT ... FOR UPDATE khóa các hàng được chọn để giao dịch xử lý độc quyền. Với một hàng đợi, worker thường viết: "lấy job pending cũ nhất và khóa nó":
BEGIN;
SELECT id FROM jobs WHERE trang_thai='pending' ORDER BY id LIMIT 1 FOR UPDATE;
Vấn đề: khi nhiều worker cùng chạy đúng câu này, tất cả đều nhắm vào cùng hàng đầu tiên. Worker thứ hai bị chặn, chờ worker thứ nhất xử lý xong. Cả đội worker nối đuôi nhau — hàng đợi serialize, không song song được.

Hình 1: FOR UPDATE khóa hàng chủ động nhưng nhiều worker nhắm cùng hàng đầu sẽ nối đuôi chờ. SKIP LOCKED bỏ qua hàng đang bị khóa, lấy hàng kế — mẫu hàng đợi công việc song song chuẩn của PostgreSQL. NOWAIT để fail-fast.
Đo thật: SKIP LOCKED biến chờ thành song song
Tôi tạo bảng jobs với 5 việc pending, cho session A giữ khóa job id=1, rồi xem worker khác lấy job thế nào:

Hình 2: FOR UPDATE thường: B chờ 3,02s rồi lấy chính job id=1 (tranh cùng job). SKIP LOCKED: B bỏ qua job 1 đang khóa, lấy job id=2 trong 0,08s. Ba worker SKIP LOCKED cùng lúc lấy ba job khác nhau (1, 2, 3) — không ai chờ ai. NOWAIT: lỗi ngay.
- FOR UPDATE thường: worker B chờ 3,02 giây rồi lấy chính job
id=1(sau khi A nhả). Nhiều worker đều nhắm hàng đầu → tranh chấp cao, nối đuôi. - FOR UPDATE SKIP LOCKED: worker B bỏ qua job
id=1đang bị khóa và lấy jobid=2trong 0,08 giây — không chờ. - Ba worker SKIP LOCKED chạy đồng thời: mỗi worker lấy một job khác nhau (worker 3→job 1, worker 1→job 2, worker 2→job 3). Hoàn toàn không tranh chấp — đây chính là hàng đợi song song.
- NOWAIT: khi job đang bị khóa,
FOR UPDATE NOWAITném lỗicould not obtain lock on rowngay lập tức, thay vì chờ — hữu ích khi muốn thất bại nhanh.
Mẫu worker hàng đợi hoàn chỉnh
BEGIN;
SELECT * FROM jobs WHERE trang_thai='pending'
ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1; -- nhận 1 job chưa ai làm
-- ... xử lý job trong ứng dụng ...
UPDATE jobs SET trang_thai='done' WHERE id=...;
COMMIT; -- nhả khóa, đánh dấu xong; worker khác đã lấy job kế song song
Chạy nhiều tiến trình worker cùng mẫu này là có ngay một hàng đợi công việc phân tán, không tranh chấp, không cần cài đặt hệ thống message queue riêng. Đây là lý do nhiều thư viện hàng đợi (như một số backend của Sidekiq, Que, River) dựng thẳng trên SKIP LOCKED.
Đánh đổi cần cân nhắc
SKIP LOCKED có thể bỏ sót hàng — đó là ý đồ, không phải lỗi. SKIP LOCKED cố tình không thấy các hàng đang bị khóa, nên một truy vấn SELECT count(*) ... FOR UPDATE SKIP LOCKED sẽ trả số nhỏ hơn thực tế nếu có hàng đang bị worker khác giữ. Chỉ dùng cho ngữ nghĩa hàng đợi ("lấy một việc bất kỳ chưa ai làm"), tuyệt đối không dùng cho truy vấn cần thấy tất cả hàng khớp.
Vẫn cần index trên cột lọc/sắp xếp. Worker quét WHERE trang_thai='pending' ORDER BY id liên tục; không có index phù hợp thì mỗi lần lấy job là một seq scan, và khi hàng đợi lớn, chi phí này lớn dần. Đánh index trên (trang_thai, id) (hoặc partial index WHERE trang_thai='pending') để lấy job luôn nhanh.
Giao dịch giữ khóa suốt thời gian xử lý job. Trong mẫu trên, khóa hàng được giữ từ lúc SELECT FOR UPDATE tới COMMIT — tức suốt thời gian xử lý job. Nếu xử lý job chậm (gọi API, tính toán nặng), giao dịch dài giữ khóa lâu và giữ cả một kết nối. Với job chậm, cân nhắc mẫu "đánh dấu claimed rồi commit ngay, xử lý ngoài giao dịch" thay vì giữ khóa suốt.
Ba ý mang về
- FOR UPDATE khóa hàng chủ động nhưng nhiều worker nhắm cùng hàng đầu sẽ nối đuôi: đo thật, worker thứ hai chờ 3,02 giây rồi lấy chính job đang bị khóa — hàng đợi serialize, tranh chấp cao.
- SKIP LOCKED bỏ qua hàng đang bị khóa để lấy hàng kế: đo thật, worker lấy job kế trong 0,08 giây thay vì chờ; ba worker chạy đồng thời lấy ba job khác nhau không tranh chấp — đây là mẫu hàng đợi công việc song song chuẩn của PostgreSQL.
- Chọn chế độ theo nhu cầu:
FOR UPDATE(chờ),SKIP LOCKED(bỏ qua, cho hàng đợi),NOWAIT(lỗi ngay, fail-fast) — nhưng SKIP LOCKED cố ý bỏ sót hàng nên chỉ dùng cho ngữ nghĩa hàng đợi, và nhớ đánh index trên cột lọc/sắp xếp.
Phần sau ta lên tầng cao hơn của kiểm soát đồng thời: Phần sau mổ xẻ các mức cô lập giao dịch (isolation level) — Read Committed, Repeatable Read, Serializable — chúng khác nhau thế nào và ảnh hưởng hiệu năng ra sao.