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.
Mengapa queue naif memproses ganda
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 processingKlaim SKIP LOCKED, dalam satu statement
SKIP 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.
Beri index, atau saksikan ia merangkak
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.
Transaksi pendek dan pemulihan dari crash
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.
- Simpan timestamp locked_at saat Anda mengklaim job agar reaper bisa mengetahui berapa lama ia sudah berjalan.
- Lacak penghitung attempts dan batasi percobaan ulang, arahkan kegagalan berulang ke status dead-letter alih-alih berputar selamanya.
- Buat handler job idempoten: pengiriman at-least-once berarti job yang sama bisa berjalan dua kali setelah crash, jadi operasi tulis harus tahan terhadap pengulangan.
// 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.
Kapan justru harus meraih broker sungguhan
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.

