
Nhiều team backend đến một lúc nào đó đều thử dùng PostgreSQL như một job queue nội bộ. Lý do rất dễ hiểu:
- dữ liệu công việc đã ở sẵn trong database;
- transaction giữa ghi business state và enqueue job khá tự nhiên;
- không cần thêm một hạ tầng queue riêng quá sớm;
- worker có thể poll trực tiếp từ bảng
jobs,outbox,emails,webhooks,reconciliation_tasks.
Vấn đề là ngay khi tăng số worker, pattern ngây thơ kiểu này bắt đầu vỡ:
select *
from jobs
where status = 'queued'
order by priority desc, available_at asc
limit 1;
Sau đó application mới UPDATE status='running' ở câu lệnh tiếp theo. Kết quả quen thuộc là:
- hai worker cùng lấy một job;
- lock wait kéo dài vì worker tranh cùng hàng đầu queue;
- throughput không tăng dù scale thêm worker;
- retry làm queue càng rối;
- có job bị kẹt rất lâu mà dashboard chỉ thấy “hệ thống vẫn đang chạy”.
FOR UPDATE SKIP LOCKED là một primitive rất mạnh để giải bài toán claim job đồng thời trong PostgreSQL. Nhưng nó không phải cây đũa thần. Dùng đúng, anh có thể tăng concurrency đẹp mà không phải dựng distributed lock ngoài luồng. Dùng sai, anh tạo ra queue có vẻ mượt nhưng âm thầm:
- starvation một nhóm job;
- fairness rất kém;
- job bị “bỏ qua hợp lệ” quá lâu;
- monitoring không nhìn ra worker đang rỗng vì queue design sai;
- business nhầm tưởng at-least-once là exactly-once.
Bài này đi thẳng vào góc production: SKIP LOCKED hoạt động thế nào, pattern claim job an toàn, index nào mới đáng thêm, khi nào queue trong PostgreSQL vẫn ổn, khi nào nên rời sang broker riêng, và những anti-pattern dễ làm đội vận hành trả giá.
`SKIP LOCKED` thực sự làm gì?
Trong PostgreSQL, khi anh dùng SELECT ... FOR UPDATE, database sẽ lấy row lock trên các row được chọn để transaction khác không thể đồng thời cập nhật hoặc khóa cùng row đó theo cách xung đột.
Thêm SKIP LOCKED nghĩa là:
- nếu row phù hợp với
WHEREnhưng đang bị transaction khác khóa, - query hiện tại không chờ,
- mà bỏ qua row đó để đi tiếp tìm row khác.
Ví dụ cơ bản:
select id, payload
from jobs
where status = 'queued'
order by priority desc, available_at asc
for update skip locked
limit 10;
Với nhiều worker chạy đồng thời, mỗi worker có thể claim các row khác nhau thay vì chen nhau chờ cùng row đầu tiên.
Đây là lý do SKIP LOCKED thường cho throughput tốt hơn hẳn pattern chỉ dùng FOR UPDATE thuần trong queue workload.
Vì sao job queue ngây thơ thường chết khi concurrency tăng?
Có ba lỗi phổ biến.
1. Tách `SELECT` và `UPDATE` mà không có khóa đúng
Worker A và worker B cùng đọc thấy job 101 đang queued. Cả hai cùng quyết định xử lý. Nếu update diễn ra ở câu lệnh sau mà không có row lock hoặc condition đủ chặt, duplicate processing gần như chắc chắn xảy ra.
2. Dùng `FOR UPDATE` nhưng không `SKIP LOCKED`
Lúc này duplicate giảm, nhưng worker thứ hai, thứ ba, thứ tư dễ bị block ở cùng vùng đầu queue. Hệ quả là scale worker không mang lại throughput tương ứng.
3. Claim xong mới commit quá muộn
Worker lấy lock rồi làm việc nặng quá lâu trong cùng transaction: gọi API ngoài, render file, gửi email, chạy batch phụ. Khi đó lock giữ lâu, queue head bị nghẽn, và SKIP LOCKED chỉ giúp bỏ qua một phần triệu chứng chứ không sửa được transaction design.
Pattern claim job an toàn nhất trong PostgreSQL thường trông như thế nào?
Mục tiêu không phải chỉ “lấy được job”. Mục tiêu là:
- mỗi job được claim bởi tối đa một worker tại một thời điểm;
- transaction claim ngắn;
- worker khác không chờ vô ích;
- có thể retry/requeue rõ ràng;
- state machine của job quan sát được.
Một pattern phổ biến:
with next_jobs as (
select id
from jobs
where status = 'queued'
and available_at <= now()
and deleted_at is null
order by priority desc, available_at asc, id asc
for update skip locked
limit 10
)
update jobs j
set status = 'running',
locked_at = now(),
worker_id = $1,
attempt = attempt + 1
from next_jobs
where j.id = next_jobs.id
returning j.id, j.payload, j.attempt, j.available_at;
Pattern này có vài ưu điểm quan trọng:
- phần chọn job và đổi trạng thái diễn ra trong cùng transaction;
- worker claim xong là có payload để xử lý;
- row đã claim bị loại khỏi lần poll của worker khác;
- transaction claim có thể commit rất nhanh trước khi bắt đầu side effect nặng.
Tôi thường thích commit ngay sau bước claim, rồi mới xử lý công việc ở transaction khác hoặc ngoài transaction. Lý do đơn giản: queue claim là bài toán phân phối ownership, không nên ôm luôn toàn bộ side effect vào cùng lock window.
`SKIP LOCKED` tăng throughput, nhưng fairness không miễn phí
Đây là phần nhiều team bỏ qua nhất.
SKIP LOCKED tối ưu cho việc tiến tiếp, không tối ưu cho việc công bằng tuyệt đối.
Nếu một số row đầu queue thường xuyên bị lock lâu, các worker khác sẽ cứ bỏ qua và xử lý job phía sau. Điều đó tốt cho throughput tổng, nhưng có thể gây:
- starvation cho một số job cụ thể;
- lệch ưu tiên thực tế so với
ORDER BYmong muốn; - tenant nhỏ hoặc job nặng bị chậm dai dẳng nếu partition logic kém;
- dashboard nhìn thấy queue đang trôi nhưng SLA của vài nhóm job lại âm thầm hỏng.
Nói cách khác: SKIP LOCKED giúp anh tránh convoy, nhưng không tự đảm bảo fairness.
Khi nào starvation xảy ra trong thực tế?
1. Có job chạy rất lâu và lock/state không được giải phóng đúng cách
Nếu worker claim job rồi crash giữa chừng, row có thể nằm ở trạng thái running quá lâu. SKIP LOCKED không cứu chuyện đó; nó chỉ khiến worker khác tiếp tục lướt qua vùng hỏng.
2. `ORDER BY` không phản ánh SLA thật
Anh có thể order theo priority desc, available_at asc, nhưng nếu priority bị lạm dụng hoặc quá nhiều job cùng priority, nhóm job cũ có thể bị lùi mãi phía sau bởi job mới đổ vào liên tục.
3. Queue nhiều tenant nhưng chung một head nóng
Một vài tenant lớn bơm job dày liên tục. Nếu không có chiến lược bucket hoặc fairness theo tenant, worker sẽ cứ ăn phần dễ thấy nhất của queue.
Đừng nhầm `SKIP LOCKED` với “exactly-once processing”
Đây là một hiểu lầm nguy hiểm.
SKIP LOCKED chỉ giúp claim đồng thời tốt hơn. Nó không biến hệ thống thành exactly-once. Anh vẫn phải tự xử lý:
- worker crash sau khi side effect ngoài DB đã chạy nhưng trước khi mark
done; - network timeout khiến worker không chắc job đã tác động ra ngoài hay chưa;
- retry job gây duplicate API call, duplicate email, duplicate webhook;
- reconcile giữa business state và trạng thái job.
Vì vậy, queue dùng PostgreSQL với SKIP LOCKED gần như luôn cần ít nhất một trong các lớp sau:
- idempotency key ở downstream side effect;
- business key dedupe;
- outbox/inbox discipline;
- state machine
queued -> running -> done|failed|retryablerõ ràng; - timeout/reaper cho job
runningbị orphan.
Bài toán này nối rất gần với Idempotent Consumer trong Event-Driven Production và Transactional Outbox: claim job an toàn không đồng nghĩa xử lý side effect an toàn.
Index strategy cho queue dùng `SKIP LOCKED`: thêm ít nhưng đúng

Queue table rất dễ trở thành bãi index nếu team tối ưu theo cảm tính.
Một truy vấn poll điển hình thường có điều kiện như:
status = 'queued'available_at <= now()deleted_at is null- có thể thêm
tenant_id = ?hoặcqueue_name = ? order by priority desc, available_at asc, id asc
Thay vì full index rất rộng cho toàn bảng lịch sử job, partial index thường hợp lý hơn:
create index concurrently idx_jobs_dispatchable
on jobs (priority desc, available_at asc, id asc)
where status = 'queued' and deleted_at is null;
Nếu worker poll theo tenant hoặc queue:
create index concurrently idx_jobs_dispatchable_by_tenant
on jobs (tenant_id, priority desc, available_at asc, id asc)
where status = 'queued' and deleted_at is null;
Điểm cần tỉnh táo:
- đừng index mọi cột “biết đâu sau này cần”;
- index phải bám query claim nóng nhất;
- giữ predicate của partial index khớp query shape thật;
- luôn verify bằng
EXPLAIN (ANALYZE, BUFFERS)trên workload giống production.
Nếu anh thêm index chỉ để che N+1, query shape sai, hoặc polling không có giới hạn hợp lý, anh đang chữa nhầm bệnh. Phần này liên hệ trực tiếp với PostgreSQL Partial Index trong Multi-Tenant Production và PostgreSQL Query Plan Regression trong Production.
Transaction boundary đúng quan trọng hơn mẹo SQL
Một nguyên tắc rất đáng giữ:
Transaction claim job phải ngắn. Transaction xử lý side effect phải được thiết kế riêng.
Pattern xấu:
SELECT ... FOR UPDATE SKIP LOCKED- gọi HTTP tới service ngoài
- render file lớn
- ghi thêm vài bảng phụ
- cuối cùng mới commit
Pattern ổn hơn:
- claim job rất nhanh
- commit
- xử lý công việc ngoài transaction claim
- cập nhật kết quả
done/failed/retryablebằng transaction ngắn khác
Cách này làm giảm:
- lock hold time;
- contention ở head queue;
- blast radius khi worker bị treo;
- số job bị bỏ qua vì một transaction giữ row quá lâu.
`SKIP LOCKED` và multi-tenant queue
Trong hệ multi-tenant, queue công bằng thường khó hơn queue một nguồn.
Nếu một tenant lớn đẩy nhiều job nặng, toàn bộ worker pool có thể bị hút vào tenant đó. SKIP LOCKED không giải quyết fairness theo tenant. Anh cần chọn rõ một chiến lược:
- poll theo
tenant_idhoặcqueue_nameriêng; - shard worker theo domain công việc;
- áp budget concurrency theo tenant;
- dùng round-robin hoặc weighted fairness ở tầng application;
- tách queue nóng/lạnh thay vì một bảng chung cho mọi thứ.
Nếu không, hệ thống có thể đạt throughput nhìn rất đẹp nhưng một số tenant vẫn cảm thấy “job của tôi lúc nào cũng chậm”.
Observability: nếu không đo đúng, queue rất dễ “chết im lặng”
Queue dùng SKIP LOCKED có một kiểu nguy hiểm riêng: worker vẫn đang chạy, CPU không đỏ, DB vẫn trả query 200, nhưng job SLA đang hỏng dần.
Những metric nên có:
- số job
queued,running,done,failed,retryabletheo loại job; - queue age p50/p95/p99: job đợi bao lâu trước khi được claim;
- execution age p50/p95/p99: job chạy bao lâu;
- số job
runningquá timeout; - retry rate theo nguyên nhân;
- throughput claim/success/failure;
- lag theo tenant hoặc queue_name;
- số worker poll rỗng liên tiếp;
- độ lệch giữa
available_atvà thời điểm thực sự được claim.
Nếu chỉ nhìn tổng số job completed mỗi phút, anh rất dễ bỏ lỡ starvation cục bộ.
Reaper / janitor cho job orphan là bắt buộc
Worker có thể chết sau khi claim. VM có thể restart. Pod có thể bị OOM kill. Network partition có thể làm heartbeat biến mất.
Vì vậy queue production nên có một cơ chế dọn job running bị orphan, ví dụ:
- nếu
runningquávisibility_timeouthoặclocked_atquá lâu; - và worker đó không còn heartbeat;
- thì chuyển job về
queuedhoặcretryablevớiavailable_atmới; - tăng
attemptvà lưu lý do reclaim.
Nhưng reaper cũng phải rất cẩn thận. Nếu timeout quá ngắn, anh sẽ requeue job còn đang chạy hợp lệ và tạo duplicate side effect. Nếu timeout quá dài, queue sẽ kẹt âm thầm.
Những anti-pattern rất hay gặp
1. Dùng `SKIP LOCKED` cho mọi thứ có tính queue
Không phải workload nào cũng nên poll bằng database. Nếu throughput quá cao, payload lớn, nhiều consumer độc lập, cần replay/phân vùng sâu, hoặc retention event dài, broker riêng thường hợp hơn.
2. Không có state machine rõ cho job
Chỉ có queued và done là chưa đủ. Production thường cần ít nhất:
queuedrunningdonefailedretryable- đôi khi
dead_letterhoặccancelled
3. Retry vô hạn
Một job lỗi do bug hoặc dữ liệu xấu mà retry vô hạn sẽ trở thành lò đốt throughput.
4. Poll bằng `OFFSET`
Queue không phải UI pagination. OFFSET trên bảng nóng là cách tệ để tìm việc tiếp theo.
5. Trộn quá nhiều loại workload vào một queue chung
Email, webhook, billing reconciliation, cache warmup, AI processing job và import CSV không nên luôn chia cùng một head queue nếu SLA khác nhau.
Khi nào PostgreSQL queue với `SKIP LOCKED` vẫn là lựa chọn tốt?
Tôi thường thấy nó rất hợp khi:
- workload vừa phải đến trung bình;
- job gắn chặt với dữ liệu nghiệp vụ trong cùng database;
- cần transaction tự nhiên giữa business write và enqueue;
- team muốn giảm hạ tầng trong giai đoạn đầu hoặc trung gian;
- observability và retry discipline đủ tốt.
Nó bắt đầu đuối khi:
- throughput cực cao;
- cần fan-out consumer lớn;
- retention/event replay là yêu cầu cốt lõi;
- queue fairness/phân vùng quá phức tạp;
- database chính đã là bottleneck của hệ thống.
Checklist rollout `SKIP LOCKED` an toàn
- ☐ Viết rõ state machine của job
- ☐ Claim job bằng transaction ngắn và atomic update-returning
- ☐ Side effect ngoài DB không nằm trong transaction claim
- ☐ Có partial/composite index bám đúng query poll nóng nhất
- ☐ Có timeout + reaper cho job orphan
- ☐ Có idempotency hoặc dedupe cho downstream side effect
- ☐ Có metric queue age, running age, retry rate, starvation theo tenant/queue
- ☐ Có giới hạn
attemptvà dead-letter policy - ☐ Có kiểm thử crash giữa các mốc: sau claim, sau side effect, trước mark done
- ☐ Có rule rõ khi nào queue này cần tách sang broker riêng
Kết luận
FOR UPDATE SKIP LOCKED là một primitive rất thực dụng của PostgreSQL cho bài toán phân phối job đồng thời. Nó giúp anh tránh convoy và tăng throughput rõ rệt khi nhiều worker cùng poll một bảng queue. Nhưng nó chỉ giải quyết một phần của bài toán: claim ownership.
Phần khó hơn vẫn nằm ở thiết kế production xung quanh nó:
- state machine rõ;
- transaction boundary ngắn;
- idempotency cho side effect;
- fairness và starvation visibility;
- reaper cho orphan job;
- index strategy đúng với query nóng.
Nếu anh nhớ một điều, hãy nhớ điều này: SKIP LOCKED không làm queue “an toàn” theo mặc định. Nó chỉ cho anh một primitive tốt để xây queue đúng hơn. Còn việc hệ thống xử lý công việc có bền hay không vẫn phụ thuộc vào cách anh thiết kế ownership, retry và observability từ đầu.
Nếu muốn đào sâu hơn ở cùng cluster, nên đọc tiếp PostgreSQL Deadlock Debugging trong Production, Queue-Based Load Leveling trong Backend Production, Idempotent Consumer trong Event-Driven Production và PostgreSQL Partial Index trong Multi-Tenant Production.