Job Queue Andal di PostgreSQL dengan SKIP LOCKED

Foto oleh Tbatb via Wikimedia Commons (CC BY-SA 4.0)
Ia memperoleh kunci tingkat baris saat memindai, tetapi alih-alih menunggu baris yang sudah dikunci transaksi lain, ia melewatinya secara diam-diam dan berpindah ke kandidat berikutnya. Ini memungkinkan banyak worker meng-query tabel yang sama sekaligus dan masing-masing mengambil baris berbeda yang belum diklaim. Fitur ini tersedia di PostgreSQL sejak versi 9.5.
Anda mengklaim job dengan satu UPDATE yang klausa WHERE-nya menargetkan id yang dikembalikan oleh subquery menggunakan FOR UPDATE SKIP LOCKED. Penguncian, perubahan status, dan pengembalian payload semuanya terjadi secara atomik dalam satu statement, sehingga tidak ada celah bagi worker kedua untuk membaca baris pending yang sama. Baris itu ditandai processing sebelum worker lain bisa melihatnya.
Biasanya salah satu dari dua penyebab: index yang hilang atau salah, atau bloat tabel. Tanpa partial index pada baris pending, pemindaian mengarungi baris yang sudah selesai dan cenderung menjadi sequential scan. Queue juga terus-menerus churn, jadi setel autovacuum secara agresif dan arsipkan job yang selesai secara terjadwal untuk mencegah dead tuple membengkakkan tabel dan index-nya.
Gunakan RabbitMQ, SQS, atau Kafka saat Anda butuh fan-out pub/sub ke banyak konsumen independen, replay pesan berhari-hari, dead-letter queue bawaan, atau throughput berkelanjutan jauh melewati sepuluh ribu job per detik. Untuk pekerjaan berbasis pull di bawah batas itu yang sudah menyentuh database Anda, Postgres dengan SKIP LOCKED lebih sederhana dan menjaga semuanya tetap transaksional.
Anda jelas bisa membuat sendiri; intinya hanyalah pola UPDATE-dengan-subquery-penguncian ditambah sebuah index dan sebuah reaper. Library seperti pg-boss dan River menambahkan penjadwalan, percobaan ulang, penanganan dead-letter, dan pengarsipan untuk Anda, dibangun di atas mekanisme SKIP LOCKED yang sama. Buat sendiri untuk belajar atau untuk queue kecil; pakai library begitu Anda butuh retry, cron job, dan monitoring.

Foto oleh Tbatb via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Job queue PostgreSQL yang dibangun di atas SELECT FOR UPDATE SKIP LOCKED memungkinkan banyak worker menarik job secara konkuren tanpa pernah mengambil baris yang sama dua kali. Klaim tiap job dalam satu UPDATE dengan subquery penguncian, tambahkan partial index pada baris pending, jaga transaksi tetap pendek, dan gunakan broker sungguhan hanya setelah melewati sekitar sepuluh ribu job per detik.
Setiap backend yang saya jalankan pada akhirnya menumbuhkan sebuah queue: kirim email ini, ubah ukuran gambar itu, sinkronkan record ini ke ERP eksternal. Refleksnya adalah meraih Redis, RabbitMQ, atau SQS. Tapi jika PostgreSQL sudah menjadi sumber kebenaran, menambahkan broker berarti satu layanan lagi untuk di-deploy, dimonitor, dan dijaga konsisten dengan database Anda. Untuk banyak sekali beban kerja, Anda tidak memerlukannya. Satu klausa SQL, SKIP LOCKED, mengubah tabel biasa menjadi queue yang bisa dikuras oleh worker paralel yang aman tanpa saling mengganggu.
SKIP LOCKED sudah ada di PostgreSQL sejak versi 9.5, dan itu adalah mekanisme yang sama yang menjadi fondasi library seperti pg-boss dan River. Memahami pola mentahnya terlebih dahulu berarti Anda bisa men-debug library tersebut saat berperilaku aneh, dan membuat sendiri saat dependensi terasa berlebihan. Berikut cara saya membangunnya, kesalahan yang menghabiskan berjam-jam waktu saya, dan batas jujur di mana saya berhenti dan justru membayar untuk broker sungguhan.
Pendekatan yang paling jelas adalah membaca baris pending tertua, lalu menandainya sebagai processing. Masalahnya, membaca dan menandai adalah dua langkah terpisah. Dua worker yang melakukan polling pada milidetik yang sama sama-sama membaca job 42 sebelum salah satunya menandainya, dan keduanya lanjut memprosesnya. Anda mengirim email faktur yang sama dua kali. FOR UPDATE biasa memperbaiki kebenaran tapi menghancurkan throughput: worker kedua terblokir dan menunggu worker pertama menyelesaikan transaksinya, sehingga worker paralel Anda merosot menjadi antrean satu jalur.
-- Naive queue: two workers can claim the same row
BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1; -- Worker A and Worker B both read job 42
UPDATE jobs SET status = 'processing' WHERE id = 42;
COMMIT;
-- Both workers now believe they own job 42 -> double processingSKIP LOCKED melakukan sesuatu yang halus: ketika seorang worker meminta untuk mengunci baris yang sudah dipegang transaksi lain, ia tidak menunggu dan tidak error. Ia berpura-pura baris itu tidak ada dan berpindah ke kandidat berikutnya. Gabungkan itu dengan UPDATE yang digerakkan oleh subquery penguncian, dan setiap worker secara atomik menemukan job yang belum diklaim, menguncinya, menandainya, dan mendapatkan payload-nya kembali dalam satu kali perjalanan. Tidak ada celah bagi worker kedua untuk melihat baris yang sama.
-- Atomic claim: lock, mark, and return in one statement
UPDATE jobs
SET status = 'processing',
locked_at = now()
WHERE id = (
SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, payload;Letakkan FOR UPDATE SKIP LOCKED pada SELECT bagian dalam, bukan UPDATE bagian luar. Subquery itulah yang memindai dan melewati baris yang terkunci; UPDATE luar kemudian bekerja pada satu id yang dikembalikannya. Mengurutkan berdasarkan created_at di dalam subquery memberi Anda FIFO; ganti dengan priority, created_at untuk priority queue.
Ini adalah kesalahan yang paling banyak menghabiskan waktu saya. SKIP LOCKED tetap harus memindai baris untuk menemukan yang tidak terkunci, dan setiap baris terkunci atau yang sudah selesai yang dilewatinya adalah pekerjaan sia-sia. Tanpa index yang tepat, tabel queue yang sibuk merosot menuju sequential scan dan latensi memanjat seiring tabel membesar. Partial index yang hanya mencakup baris pending menjaga pemindaian tetap kecil dan, sebagai bonus, menyusut otomatis saat job selesai.
-- Partial index: only pending rows are indexed
CREATE INDEX idx_jobs_pending
ON jobs (created_at)
WHERE status = 'pending';Queue adalah mesin churn, dan setiap job yang selesai atau dihapus meninggalkan dead tuple. Jika autovacuum tidak sanggup mengejar, tabel dan index-nya membengkak dan bahkan indexed scan pun melambat. Setel autovacuum agar berjalan agresif pada tabel ini, arsipkan atau hapus job yang sudah selesai secara terjadwal, dan jangan pernah biarkan worker membuka transaksinya lebih lama dari yang benar-benar dibutuhkan job.
Ada dua bentuk untuk worker. Yang sederhana menahan satu transaksi tetap terbuka selama seluruh job: kunci baris terlepas otomatis jika worker crash, tapi job yang berjalan lama menahan kunci itu dan memblokir vacuum sepanjang waktu. Yang skalabel meng-commit klaim segera, memproses di luar transaksi apa pun, lalu menandai job selesai dalam transaksi pendek kedua. Itu menjaga kunci tetap singkat, tapi worker yang crash meninggalkan job terdampar di status processing selamanya, jadi Anda butuh sebuah reaper.
// TypeScript worker with node-postgres: claim, run, mark done
async function claimAndRun(pool) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(`
UPDATE jobs
SET status = 'processing', locked_at = now()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, payload
`);
if (rows.length === 0) {
await client.query('COMMIT');
return null; // queue empty this tick
}
const job = rows[0];
await handle(job); // your business logic (must be idempotent)
await client.query(
"UPDATE jobs SET status = 'done' WHERE id = $1",
[job.id]
);
await client.query('COMMIT');
return job.id;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}-- Reaper: requeue jobs stuck in 'processing' past a timeout
UPDATE jobs
SET status = 'pending',
locked_at = NULL,
attempts = attempts + 1
WHERE status = 'processing'
AND locked_at < now() - interval '5 minutes';Satu penyempurnaan lagi: polling terus-menerus memboroskan query saat queue kosong. Pasangkan pola ini dengan LISTEN dan NOTIFY agar producer membangunkan worker begitu job tiba, dan jatuhkan ke polling lambat setiap beberapa detik sebagai jaring pengaman. Itu memberi Anda pengambilan hampir instan tanpa menghajar database dengan SELECT kosong.
Saya menjalankan pola ini dengan senang di produksi, tapi saya tetap jujur soal batasnya. Postgres-sebagai-queue adalah model pull dengan satu konsumen per job. Begitu Anda butuh fan-out ke banyak konsumen independen, replay berhari-hari, atau throughput berkelanjutan jauh melewati sepuluh ribu job per detik, broker khusus akan sepadan dengan biayanya. Berikut trade-off yang saya timbang sebelum menambahkan infrastruktur.
| Dimensi | Postgres SKIP LOCKED | Broker khusus |
|---|---|---|
| Batas throughput | Nyaman hingga sekitar 10rb job/detik, terbebani di atasnya | 100rb+ pesan/detik berkelanjutan |
| Model pengiriman | Berbasis pull, satu konsumen per job | Pub/sub dan fan-out ke banyak konsumen |
| Integritas transaksional | Job dan data bisnis commit secara atomik bersama | Butuh pola outbox agar tetap konsisten |
| Biaya operasional | Tanpa infra baru jika Anda sudah menjalankan Postgres | Layanan tambahan untuk di-deploy, dimonitor, dan di-patch |
| Replay dan retensi | Hanya baris yang Anda simpan; pengarsipan manual | Replay bawaan, dead-letter queue, jendela retensi |
Aturan praktis saya sederhana. Jika pekerjaannya sudah menyentuh Postgres dan throughput saya berada di kisaran ratusan hingga ribuan job per detik, SKIP LOCKED adalah pilihan yang membosankan namun benar, dan ia menghapus satu komponen bergerak dari arsitektur saya. Ketika angka atau semantik pengiriman melampaui itu, saya tidak melawannya; saya menambahkan Kafka atau SQS dan lanjut. Mulailah dengan database yang sudah Anda miliki, dan bayar broker hanya ketika beban kerja benar-benar menuntutnya.