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.

Ảnh chụp đoạn mã SQL nền tối minh hoạ SELECT FOR UPDATE và SKIP LOCKED hàng đợi công việc không tranh chấp, FOR UPDATE khóa chủ động các hàng chọn ra BEGIN SELECT id FROM jobs WHERE trang_thai bằng pending ORDER BY id LIMIT 1 FOR UPDATE khóa hàng để xử lý độc quyền nhưng nhiều worker cùng chạy tất cả nhắm hàng đầu tiên nối đuôi chờ, SKIP LOCKED bỏ qua hàng đang bị khóa lấy hàng kế SELECT id FROM jobs WHERE trang_thai bằng pending ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED worker khác đang giữ hàng đầu bỏ qua lấy hàng chưa khóa tiếp theo ngay N worker lấy N job khác nhau không chờ không tranh chấp đây là mẫu hàng đợi công việc chuẩn của PostgreSQL, NOWAIT lỗi ngay thay vì chờ SELECT FOR UPDATE NOWAIT hàng đang bị khóa ném lỗi could not obtain lock ngay thay vì chờ dùng khi muốn thất bại nhanh fail-fast, mẫu worker hàng đợi hoàn chỉnh BEGIN SELECT từ jobs WHERE trang_thai bằng pending ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1 nhận 1 job chưa ai làm xử lý job UPDATE jobs SET trang_thai bằng done WHERE id COMMIT nhả khóa đánh dấu xong worker khác lấy job kế song song

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:

Ảnh chụp bảng kết quả đo thật nền tối SKIP LOCKED lấy job kế trong 0,08s thay vì chờ 3 giây bảng jobs 5 việc pending A giữ khóa job id 1 worker khác lấy job thế nào PostgreSQL 16, FOR UPDATE thường nối đuôi tranh cùng job đầu A đang giữ job id 1 B SELECT LIMIT 1 FOR UPDATE lấy job id 1 sau 3,02 giây chờ A nhả rồi lấy chính job đó nhiều worker đều nhắm hàng đầu serialize tranh chấp cao, FOR UPDATE SKIP LOCKED bỏ qua job bị khóa lấy job kế ngay A đang giữ job id 1 B SELECT LIMIT 1 FOR UPDATE SKIP LOCKED lấy job id 2 sau 0,08 giây bỏ qua job 1 đang khóa lấy job 2, 3 worker SKIP LOCKED cùng lúc 3 job khác nhau 0 tranh chấp worker 3 lay_job 1 worker 1 lay_job 2 worker 2 lay_job 3 mỗi worker một job riêng không ai chờ ai, NOWAIT lỗi ngay khi job bị khóa SELECT id FROM jobs WHERE id 1 FOR UPDATE NOWAIT ERROR could not obtain lock on row in relation jobs thất bại tức thì thay vì chờ, bảng chế độ FOR UPDATE khi job bị worker khác khóa chờ tới khi nhả kết quả 3,02s tranh cùng job SKIP LOCKED bỏ qua lấy job kế 0,08s hàng đợi song song NOWAIT lỗi ngay fail-fast

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 job id=2 trong 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 NOWAIT ném lỗi could not obtain lock on row ngay 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ề

  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: đ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.
  2. 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.
  3. 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.