Background Job yang Andal dengan BullMQ dan NestJS

Foto oleh Tiger Lily

Foto oleh Tiger Lily
Setiap backend akhirnya menumbuhkan pekerjaan yang tidak seharusnya terjadi di dalam request HTTP: merender PDF invoice 40 halaman, mengirim email massal, sinkronisasi stok ke API marketplace yang membatasi rate Anda. Versi pertamanya selalu await di dalam controller, dan akhirnya selalu sama: pengguna menatap spinner 20 detik, lalu gateway timeout, lalu submit ganda. Background job bukan optimisasi. Melewati durasi pekerjaan tertentu, ia satu-satunya arsitektur yang benar.
Stack saya untuk ini di dunia Node.js adalah BullMQ di atas Redis, disambungkan ke NestJS. BullMQ adalah library queue matang yang dibangun di atas Redis dengan operasi atomik berbasis skrip Lua, dan ia menutupi empat hal yang benar-benar dituntut production: retry dengan backoff, parkir kegagalan, kontrol konkurensi, dan observability. Tulisan ini adalah setup yang saya deploy untuk beban kerja ERP, dengan pelajaran operasional yang dilewatkan panduan quickstart.
Melempar promise tanpa await di dalam request handler terasa seperti background job, tapi ia hidup dan mati bersama prosesnya. Deploy, crash, atau scale down, dan pekerjaannya menguap diam-diam. Queue berbasis Redis memberi job kehidupannya sendiri:
Integrasi resmi NestJS memakai BullModule dari paket rasa bullmq. Konfigurasi koneksi tinggal di root; tiap feature module mendaftarkan queue-nya sendiri dengan opsi job default sehingga kebijakan retry dimiliki queue, bukan tercecer di para producer:
// app.module.ts — connection once, queues per feature
BullModule.forRoot({
connection: { host: process.env.REDIS_HOST, port: 6379 },
})
BullModule.registerQueue({
name: 'invoices',
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 86400, count: 5000 },
removeOnFail: false, // keep failures for inspection
},
})
// invoices.processor.ts
@Processor('invoices', { concurrency: 8 })
export class InvoicesProcessor extends WorkerHost {
async process(job: Job<GenerateInvoiceDto>) {
switch (job.name) {
case 'generate-pdf':
return this.pdf.render(job.data.invoiceId)
case 'send-email':
return this.mailer.sendInvoice(job.data.invoiceId)
}
}
}Tiga default di snippet itu bekerja luar biasa keras. Pasangan attempts dan exponential backoff menangani kegagalan transien secara otomatis. Kebijakan removeOnComplete dengan batas umur menjaga memori Redis datar alih-alih tumbuh selamanya, yang merupakan cara paling umum tim tanpa sengaja memenuhkan instance Redis. Dan removeOnFail bernilai false mengawetkan kegagalan terminal, karena kegagalan yang tidak bisa diperiksa adalah kegagalan yang tidak bisa diperbaiki.
BullMQ me-retry job yang gagal saat attempts lebih dari satu, dan setelan backoff mengatur jaraknya. Strategi bawaannya fixed, yang menunggu delay sama setiap kali, dan exponential, yang menggandakan waktu tunggu per percobaan memakai formula terdokumentasi dua pangkat jumlah percobaan dikurangi satu, dikalikan delay dasar. Dengan delay dasar 2 detik dan lima attempts, jadwalnya menyimpang seperti ini:
| Percobaan | Backoff fixed (2 dtk) | Backoff exponential (basis 2 dtk) |
|---|---|---|
| 1 | langsung (eksekusi pertama) | langsung (eksekusi pertama) |
| 2 | setelah tunggu 2 dtk | setelah tunggu 2 dtk |
| 3 | setelah tunggu 2 dtk | setelah tunggu 4 dtk |
| 4 | setelah tunggu 2 dtk | setelah tunggu 8 dtk |
| 5 | setelah tunggu 2 dtk (total tunggu ~8 dtk) | setelah tunggu 16 dtk (total tunggu ~30 dtk) |
Exponential adalah default yang benar untuk apa pun yang memanggil sistem eksternal: kalau API marketplace tumbang semenit, menggedornya tiap 2 detik hanya membakar rate limit Anda, sedangkan jarak eksponensial melewati outage itu dengan tenang. Backoff fixed saya sisakan untuk pekerjaan murni internal seperti resize gambar, yang penyebab gagalnya biasanya lonjakan resource sesaat. Anda juga bisa mendaftarkan strategi backoff kustom untuk kasus khusus, seperti membaca header retry-after dari respons 429 dan mematuhinya persis.
Retry berarti processor Anda akan berjalan lebih dari sekali untuk pekerjaan logis yang sama; itu kontrak yang Anda tanda tangani. Setiap handler harus idempotent: render PDF ke path yang deterministik, upsert alih-alih insert, cek apakah pengiriman email sudah tercatat sebelum mengirim. Queue menjamin job berjalan setidaknya sekali sampai sukses; hanya handler Anda yang bisa membuat pengulangan tidak berbahaya.
Sebagian kegagalan bukan transien. Payload cacat, record terhapus, bug di handler: semuanya akan gagal di percobaan kelima persis seperti percobaan pertama. Tanpa rencana, mereka duduk di set failed selamanya atau, lebih buruk, di-retry dalam loop tak berujung yang menenggelamkan error sungguhan dalam kebisingan. Pola yang berhasil adalah dead-letter queue: saat job menghabiskan attempts-nya, handler event worker memindahkannya ke queue terpisah dengan konteks lengkap dan memanggil manusia:
// Listen for jobs that exhausted all attempts
@OnWorkerEvent('failed')
onFailed(job: Job, err: Error) {
if (job.attemptsMade >= (job.opts.attempts ?? 1)) {
// terminal failure → park it in a DLQ for humans
this.dlqQueue.add('dead-letter', {
source: 'invoices',
jobName: job.name,
data: job.data,
error: err.message,
failedAt: new Date().toISOString(),
})
this.alerts.notify('Invoice job dead-lettered: ' + job.id)
}
}
// Replay later, after the bug is fixed:
// pull from DLQ → re-add to the original queue with attempts resetDLQ menjadi inbox triase dengan tiga hasil per entri: replay setelah perbaikan, buang karena usang, atau ubah jadi tiket bug. Di satu deployment ERP, pola tunggal ini memangkas waktu rata-rata kami menyadari integrasi rusak dari hitungan hari, saat pelanggan komplain, menjadi hitungan menit, saat alert berbunyi. Jalur replay sama pentingnya dengan jalur parkir: jaga payload job kecil dan mandiri, ID alih-alih objek utuh, supaya dead letter berumur seminggu tetap bisa diputar ulang dengan benar terhadap data terkini.
ID di payload, bukan objek
Enqueue ID invoice, bukan invoice-nya. Worker mengambil state segar saat eksekusi, jadi job yang tertunda retry tidak bertindak atas snapshot basi. Memori Redis juga jadi terprediksi.
Deduplikasi lewat job ID
BullMQ memperlakukan jobId kustom sebagai unik selama job-nya masih ada: menambah ID sama lagi akan diabaikan. Pakai ID deterministik seperti sync-stock-sku123 untuk meringkas pemicu duplikat jadi satu eksekusi.
Job delayed dan repeatable
Pengingat pembayaran tiga hari sebelum jatuh tempo adalah delayed job. Rekonsiliasi stok tiap malam adalah repeatable job dengan pola cron. Keduanya menggantikan rangkaian rapuh cron eksternal plus HTTP dengan satu sistem konsisten.
Queue terpisah per radius ledakan
Email, PDF, dan sinkronisasi eksternal masing-masing dapat queue dengan konkurensinya sendiri. Satu integrasi yang macet jadi tidak bisa membuat pembuatan invoice kelaparan, dan Anda bisa menjeda satu queue saja saat insiden.
Queue yang tampak baik-baik saja di development punya dua mode kegagalan production yang harus Anda lihat datang: backlog yang tumbuh, saat producer melampaui consumer, dan kematian worker yang sunyi, saat tidak ada yang mengkonsumsi sama sekali. Saya mengekspor tiga angka per queue ke Prometheus dan memasang alert di Grafana:
Jalankan worker BullMQ sebagai proses terpisah dari API Anda, bahkan di VPS yang sama. Worker yang mengunyah render PDF berat CPU tidak boleh bersaing dengan latency request, dan Anda mendapat deploy serta restart yang independen. Di Docker Compose atau Swarm itu cuma service kedua yang memakai image sama dengan command berbeda.
Perbedaan antara sistem background job yang Anda percaya dan yang Anda takuti bukan di jalur bahagia; tutorial mana pun bisa mengantar job dari producer ke processor dalam 20 baris. Bedanya ada di hari buruk: API marketplace tumbang sejam, deploy mendarat di tengah job, payload beracun tiba jam 2 pagi. Attempts dan backoff menyerap kegagalan transien, dead-letter queue menangkap yang permanen, handler idempotent membuat retry tidak berbahaya, dan metrik memberi tahu Anda semuanya sebelum pengguna Anda. Siapkan empat hal itu sejak hari pertama dan BullMQ akan membalas Anda dengan bertahun-tahun job yang tidak pernah Anda pikirkan lagi.
Sumber dan bacaan lanjutan