Skip to main content
BlogBackend

Background Job yang Andal dengan BullMQ dan NestJS

Retry, exponential backoff, dead-letter queue, dan monitoring: blueprint production untuk background job dengan BullMQ, Redis, dan NestJS yang tahan hari buruk.

Matthews Wong

Software Engineer

April 202610 menit baca
Background Job yang Andal dengan BullMQ dan NestJS

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.

Apa yang Diberikan Queue Dibanding Async Call Naif

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:

  • Durabilitas: job dipersist di Redis sejak di-enqueue, jadi restart worker atau deploy tidak menghilangkannya; job yang belum selesai diambil lagi.
  • Kebijakan retry sebagai konfigurasi: attempts dan backoff dideklarasikan per queue atau per job, bukan dirakit manual dengan setTimeout di tujuh belas tempat.
  • Backpressure dan konkurensi: delapan render PDF sekaligus alih-alih tiga ratus, secepat apa pun pengguna mengklik, sehingga mesin tetap hidup.
  • Observability: setiap job punya state yang bisa diperiksa, dihitung, dan digrafikkan; waiting, active, completed, failed, delayed. Promise yang mengambang tidak punya apa-apa.

Setup NestJS yang Benar-Benar Saya Deploy

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.

Retry dan Backoff: Jadikan Kegagalan Membosankan

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:

PercobaanBackoff fixed (2 dtk)Backoff exponential (basis 2 dtk)
1langsung (eksekusi pertama)langsung (eksekusi pertama)
2setelah tunggu 2 dtksetelah tunggu 2 dtk
3setelah tunggu 2 dtksetelah tunggu 4 dtk
4setelah tunggu 2 dtksetelah tunggu 8 dtk
5setelah 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.

Dead-Letter Queue: Tempat Job Menunggu Di-debug

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 reset

DLQ 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.

Empat Pola yang Membayar Sewa di Production

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.

Monitoring: Queue Itu Berbohong Sampai Anda Grafikkan

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:

  • Jumlah waiting dan trennya: backlog yang tumbuh 15 menit berturut-turut berarti consumer kurang atau macet; alert sebelum pengguna merasakan latency.
  • Delta jumlah failed: lebih dari N kegagalan baru dalam 10 menit adalah sinyal insiden, biasanya perubahan API downstream, bukan N kebetulan independen.
  • Heartbeat worker: gauge yang di-update tiap worker setiap 30 detik. Kalau ia basi sementara jumlah waiting naik, prosesnya mati atau tersangkut job beracun, dan perbedaan itu menentukan apakah Anda restart atau menggali.

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.

Checklist Production

  • Default diset per queue: attempts 3 sampai 5, exponential backoff, removeOnComplete dengan batas umur, removeOnFail dipertahankan untuk inspeksi.
  • Setiap processor idempotent dan payload membawa ID, bukan snapshot.
  • Handler dead-letter pada event failed dengan alerting dan prosedur replay yang terdokumentasi.
  • Queue dipisah per domain kegagalan dengan konkurensi eksplisit per worker.
  • Metrik waiting, failed, dan heartbeat digrafikkan dan di-alert; Redis dikonfigurasi dengan persistence dan maxmemory-policy noeviction supaya penyimpanan queue tidak pernah diam-diam membuang job.

Queue Adalah Investasi untuk Operasional yang Membosankan

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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa menggunakan BullMQ dengan Redis dibanding async call biasa atau promise tanpa await?

Promise tanpa await hidup dan mati bersama prosesnya—deploy, crash, atau scale down akan menguapkan pekerjaan itu diam-diam. BullMQ mempersist setiap job di Redis sejak di-enqueue, sehingga restart worker cukup mengambil job itu kembali. Anda juga mendapat kebijakan retry, kontrol konkurensi, dan state per job (waiting, active, completed, failed) yang tidak bisa diberikan oleh promise yang mengambang.

Kapan sebaiknya memilih exponential backoff dibanding fixed backoff?

Exponential backoff adalah default yang tepat untuk apa pun yang memanggil sistem eksternal. Kalau API marketplace tumbang semenit, menggedornya tiap 2 detik hanya membakar rate limit Anda, sedangkan jarak eksponensial—yang menggandakan waktu tunggu per percobaan memakai formula 2^(jumlah_percobaan - 1) × delay_dasar—melewati outage dengan tenang. Fixed backoff disisakan untuk pekerjaan murni internal seperti resize gambar, yang penyebab gagalnya biasanya lonjakan resource sesaat, bukan gangguan layanan downstream.

Apa itu dead-letter queue dan mengapa penting di production?

Dead-letter queue (DLQ) adalah queue terpisah yang menampung job yang telah menghabiskan semua percobaan retry-nya. Tanpanya, kegagalan terminal duduk di set failed selamanya atau di-retry dalam loop tak berujung yang menenggelamkan error sungguhan dalam kebisingan. Memindahkan job yang habis percobaan ke DLQ beserta konteks lengkap memungkinkan Anda menentukan tindakan per entri—replay setelah perbaikan, buang karena usang, atau ubah jadi tiket bug—dan langsung memanggil manusia tanpa harus menunggu pelanggan yang komplain.

Mengapa setiap processor job BullMQ harus idempotent?

Karena BullMQ menjamin pengiriman at-least-once: job di-retry sampai berhasil, sehingga processor Anda akan berjalan lebih dari sekali untuk pekerjaan logis yang sama. Jika handler tidak idempotent—misalnya insert alih-alih upsert, atau mengirim email tanpa mengecek apakah sudah terkirim—setiap retry bisa menimbulkan efek samping duplikat. Menjadikan processor idempotent adalah satu-satunya cara membuat pengulangan tidak berbahaya.

Tiga metrik apa yang harus dipantau per queue dan mengapa?

Tulisan ini merekomendasikan ekspor jumlah waiting beserta trennya, delta jumlah failed, dan gauge heartbeat worker ke Prometheus. Jumlah waiting yang tumbuh selama 15 menit berturut-turut menandakan consumer kurang atau macet. Lebih dari N kegagalan baru dalam 10 menit mengindikasikan perubahan API downstream atau insiden. Heartbeat yang basi sementara jumlah waiting naik berarti proses worker mati atau tersangkut job beracun, yang menentukan apakah Anda perlu restart atau menggali lebih dalam.