Desain queue bertingkat membantu sistem tetap waras ketika pasokan kerja datang tidak merata: kadang lancar, kadang tersendat, kadang menumpuk sekaligus. Dalam sistem terdistribusi, masalah utamanya bukan hanya throughput, tetapi bagaimana mencegah worker macet, retry storm, cache menjadi usang, dan antrian penting ikut tenggelam oleh pekerjaan yang bisa ditunda.

Analogi rantai pasok oat cocok untuk menjelaskan ini. Ketika pasokan fisik terganggu, gudang, jalur prioritas, buffer cadangan, dan area barang bermasalah harus dipisahkan. Sistem queue juga demikian: normal lane untuk arus rutin, priority lane untuk job kritis, backlog buffer untuk menyerap lonjakan, dan dead-letter queue untuk item yang perlu investigasi. Jika semuanya dicampur dalam satu arus tanpa kontrol, gangguan kecil di hulu dapat berubah menjadi kegagalan besar di hilir.

Artikel ini memakai konteks gangguan pasokan oat sebagai analogi operasional, bukan sebagai pembahasan berita. Fokusnya adalah bagaimana merancang queue yang tahan gangguan dan mudah dioperasikan.

Mengapa worker sering tidak stabil saat pasokan terganggu

Dalam praktiknya, worker gagal stabil bukan karena satu bug tunggal, tetapi karena beberapa efek berantai:

  • Lonjakan job: upstream pulih lalu mengirim backlog besar sekaligus.
  • Worker macet: job yang lambat menahan slot concurrency terlalu lama.
  • Retry storm: job gagal serempak lalu dijadwalkan ulang tanpa backoff yang sehat.
  • Cache usang: worker memproses data berdasarkan snapshot lama ketika sumber data berubah.
  • Distributed locking yang buruk: lock terlalu lama, tidak dilepas, atau terlalu kasar sehingga throughput jatuh.
  • Idempotensi lemah: retry justru menggandakan efek samping, misalnya mengirim notifikasi atau membuat transaksi ganda.
  • Visibilitas rendah: backlog bertambah, tetapi tim tidak tahu queue mana yang menjadi sumber kemacetan.

Masalah-masalah ini mirip dengan gudang yang menerima bahan baku terlambat lalu sekaligus, sementara lini produksi tetap harus berjalan. Tanpa pemisahan jalur dan kebijakan penanganan yang jelas, semua item diperlakukan sama padahal dampaknya berbeda.

Model desain queue bertingkat

1. Normal lane

Normal lane menampung pekerjaan rutin yang boleh menunggu dalam batas wajar, misalnya sinkronisasi data non-kritis, pembaruan indeks pencarian, atau pembuatan laporan periodik. Tujuannya bukan latensi terendah, melainkan throughput stabil.

Karakteristiknya:

  • Concurrency sedang hingga tinggi.
  • Retry diperbolehkan, tetapi dibatasi.
  • Boleh diturunkan prioritasnya saat sistem tertekan.
  • Timeout dan ukuran payload harus ketat agar satu job buruk tidak menahan worker terlalu lama.

2. Priority lane

Priority lane disediakan untuk pekerjaan yang memengaruhi fungsi inti atau pengalaman pengguna secara langsung, misalnya pembaruan stok, pembayaran, atau invalidasi cache penting. Prinsipnya: jangan biarkan job penting menunggu di belakang ribuan job yang bisa ditunda.

Karakteristiknya:

  • Concurrency dialokasikan secara khusus.
  • TTL antrean dan target latensi lebih ketat.
  • Biasanya memiliki observabilitas lebih detail.
  • Tidak boleh dipakai sebagai tempat “semua job penting”, karena akan berubah menjadi normal lane kedua.

3. Backlog buffer

Backlog buffer adalah penyangga saat pasokan masuk lebih cepat daripada kemampuan proses. Ini bukan tempat permanen, melainkan area parkir untuk meratakan lonjakan. Buffer berguna ketika upstream pulih setelah gangguan lalu mengirim pekerjaan dalam jumlah besar yang jika diproses langsung akan melumpuhkan worker utama.

Karakteristiknya:

  • Menerima job yang boleh ditunda.
  • Dipindahkan bertahap ke normal lane berdasarkan kapasitas aktual.
  • Sering dipasangkan dengan rate limiter atau scheduler terpisah.
  • Dapat diisi secara eksplisit saat sistem mendeteksi tekanan tinggi.

4. Dead-letter queue

Dead-letter queue (DLQ) menampung job yang gagal permanen atau sudah melewati batas retry. Tujuannya bukan membuang masalah, tetapi memisahkan pekerjaan yang butuh analisis manusia atau alur kompensasi.

Karakteristiknya:

  • Menyimpan alasan gagal, jumlah percobaan, dan metadata penting.
  • Tidak diproses ulang otomatis tanpa validasi.
  • Harus mudah ditelusuri ke request, tenant, atau entitas bisnis asal.

Contoh arsitektur queue bertingkat

Berikut pola arsitektur yang umum dan cukup aman untuk banyak sistem backend:

Producer / API / Scheduler
        |
        v
+-------------------------+
| Queue Router            |
| - klasifikasi job       |
| - dedup key             |
| - priority policy       |
+-------------------------+
   |          |          |
   v          v          v
Normal     Priority   Backlog Buffer
 Lane        Lane         Lane
   |          |          |
   +-----+----+----------+
         |
         v
   Worker Pools
   - pool normal
   - pool priority
   - pool drain backlog
         |
         v
  External dependencies
  DB / Cache / API / Object storage
         |
         v
       DLQ

Poin penting dari arsitektur ini:

  • Queue router menentukan lane sejak awal berdasarkan jenis job, tenant, SLA, atau status sistem.
  • Worker pool dipisah agar backlog non-kritis tidak menghabiskan seluruh concurrency.
  • Backlog buffer tidak diproses bebas; ia didrain bertahap dengan token bucket, rate limit, atau scheduler.
  • DLQ terpisah agar kegagalan permanen tidak terus mengotori jalur utama.

Queue terpisah vs satu queue dengan prioritas

Kapan memakai queue terpisah

Pilih queue terpisah bila Anda butuh isolasi operasional yang nyata:

  • Beban antara job kritis dan non-kritis sangat berbeda.
  • Butuh concurrency, timeout, dan retry policy yang berbeda jauh.
  • Ingin mencegah head-of-line blocking secara tegas.
  • Butuh dashboard dan alert terpisah per lane.

Kelebihannya adalah isolasi lebih kuat dan tuning lebih mudah. Kekurangannya, kompleksitas operasional naik: lebih banyak queue, worker pool, alarm, dan kebijakan deployment.

Kapan cukup satu queue dengan prioritas

Satu queue dengan penanda prioritas bisa cukup bila:

  • Volume job masih moderat.
  • Perbedaan SLA tidak terlalu ekstrem.
  • Broker atau runtime Anda sudah mendukung prioritas secara wajar.
  • Tim ingin mengurangi kompleksitas infrastruktur.

Namun ada trade-off: prioritas logis tidak selalu memberi isolasi nyata. Jika worker tetap menarik dari antrean yang sama, job lambat masih bisa memonopoli resource proses, koneksi database, atau bandwidth API.

Aturan praktis

Jika kegagalan lane non-kritis dapat merusak jalur kritis, gunakan queue terpisah. Jika hanya butuh urutan konsumsi yang sedikit lebih baik, prioritas dalam satu queue mungkin cukup.

Mencegah starvation pada priority lane

Masalah umum pada desain prioritas adalah starvation: job normal tidak pernah jalan karena lane prioritas terus terisi. Ini sama seperti gudang yang selalu mendahulukan pesanan mendesak sampai pengiriman reguler berhenti total.

Beberapa strategi untuk mencegahnya:

  • Weighted scheduling: misalnya 4 job priority lalu 1 job normal.
  • Reserved concurrency: sebagian worker khusus priority, sebagian lagi khusus normal.
  • Aging: job normal yang terlalu lama menunggu dinaikkan prioritasnya secara terkendali.
  • Admission control: batasi producer yang terus mengisi priority lane tanpa validasi.

Strategi terbaik biasanya kombinasi reserved concurrency dan weighted drain, karena mudah dipahami dan tidak terlalu sensitif terhadap implementasi broker.

Retry yang sehat: backoff, jitter, dan batas gagal

Retry storm terjadi saat banyak job gagal karena satu dependency bermasalah lalu semuanya mencoba lagi pada waktu hampir bersamaan. Ini memperparah outage dan membuat sistem sulit pulih.

Prinsip retry yang sehat:

  • Retry hanya untuk kegagalan transient, bukan kesalahan validasi permanen.
  • Gunakan exponential backoff agar percobaan berikutnya makin jarang.
  • Tambahkan jitter agar retry tidak sinkron.
  • Tetapkan max attempts dan pindahkan ke DLQ bila terlampaui.
  • Pisahkan timeout koneksi, timeout proses, dan timeout keseluruhan job.

Contoh jadwal retry yang aman secara umum: percobaan kedua beberapa detik kemudian, lalu belasan detik, lalu puluhan detik, lalu beberapa menit. Angka tepatnya bergantung pada SLA dan perilaku dependency, jadi hindari mengunci semua job ke satu jadwal statis tanpa observasi.

Pseudocode worker dengan idempotensi dan retry backoff

function handleJob(job):
    if isDuplicate(job.idempotencyKey):
        markSuccess(job, "duplicate ignored")
        return

    lockKey = "job-lock:" + job.entityKey
    lock = acquireLock(lockKey, ttl=short_duration)
    if not lock:
        reschedule(job, backoffWithJitter(job.attempt))
        return

    try:
        currentState = loadFreshState(job.entityKey)
        if not shouldProcess(job, currentState):
            markSuccess(job, "no-op due to current state")
            return

        result = callDependencyWithTimeout(job.payload)
        persistStateAtomically(job.entityKey, result)
        recordIdempotencyKey(job.idempotencyKey)
        invalidateRelevantCache(job.entityKey)
        markSuccess(job)

    catch TransientError:
        if job.attempt >= MAX_ATTEMPTS:
            sendToDLQ(job, reason="transient exhausted")
        else:
            reschedule(job, backoffWithJitter(job.attempt))

    catch PermanentError as e:
        sendToDLQ(job, reason=e.message)

    finally:
        releaseLock(lock)

Pseudocode di atas menekankan beberapa hal penting:

  • Idempotency key mencegah efek samping ganda saat job diproses ulang.
  • Distributed lock dipakai sempit pada entitas yang sama, bukan lock global seluruh lane.
  • loadFreshState mencegah keputusan berbasis cache usang.
  • no-op adalah hasil sah; tidak semua retry harus benar-benar melakukan aksi.

Cache usang, consistency, dan kapan harus membaca ulang

Pada sistem yang menggunakan cache agresif, worker sering memproses keputusan berdasarkan data yang sudah tidak relevan. Misalnya job pembaruan stok berjalan dengan nilai lama karena invalidasi cache terlambat. Dalam analogi pasokan, ini seperti gudang memakai catatan inventaris kemarin saat truk baru saja datang pagi ini.

Beberapa pendekatan yang lebih aman:

  • Baca dari source of truth untuk langkah yang menentukan efek samping penting.
  • Gunakan version check atau updated_at untuk menolak hasil usang.
  • Terapkan compare-and-set bila datastore mendukung operasi atomik.
  • Jangan jadikan cache sebagai sumber kebenaran untuk keputusan kritis.

Trade-off consistency yang perlu diterima:

  • Semakin sering membaca state segar, semakin tinggi latensi dan beban database.
  • Semakin mengandalkan cache, semakin tinggi risiko keputusan usang.
  • Strong consistency tidak selalu perlu untuk semua lane; lane prioritas sering butuh jaminan lebih kuat daripada backlog buffer.

Distributed locking tanpa membunuh throughput

Locking sering dipakai untuk mencegah dua worker mengerjakan entitas yang sama secara bersamaan. Ini berguna, tetapi mudah disalahgunakan.

Praktik yang baik

  • Lock pada granularitas entitas, bukan per queue atau per tenant besar.
  • TTL lock harus lebih pendek dari timeout proses yang realistis.
  • Simpan metadata pemilik lock untuk debugging.
  • Siapkan jalur aman bila lock tidak didapat: tunda, jangan spin terus-menerus.

Kesalahan umum

  • Lock global untuk seluruh tipe job.
  • TTL lock terlalu panjang sehingga kegagalan worker membuat sistem tampak macet.
  • Menganggap lock saja cukup tanpa idempotensi.

Lock mencegah konkurensi yang salah, tetapi tidak menyelesaikan duplikasi akibat retry, redelivery, atau producer yang mengirim ulang. Karena itu, idempotensi tetap wajib.

Rate limit, deduplication, dan admission control

Rate limit

Rate limit penting saat dependency eksternal atau database internal tidak mampu menerima beban penuh. Anda bisa menerapkannya pada beberapa level:

  • Per queue lane.
  • Per tenant.
  • Per jenis operasi.
  • Per dependency eksternal.

Backlog buffer hampir selalu lebih aman bila didrain melalui rate limiter, bukan dibuka penuh setelah gangguan selesai.

Deduplication

Deduplikasi mencegah job identik memenuhi queue akibat bug producer, retry dari client, atau event upstream yang terkirim berulang. Bentuk sederhananya adalah idempotency key yang berasal dari entitas bisnis dan aksi, misalnya shipment:123:update-status:v5.

Perlu diingat:

  • TTL dedup harus cukup lama untuk menutup jendela retry yang realistis.
  • Dedup terlalu agresif bisa menelan update yang sebenarnya berbeda.
  • Jangan memakai hash payload mentah tanpa memahami field mana yang benar-benar menentukan identitas aksi.

Admission control

Saat sistem sudah tertekan, tidak semua job harus diterima langsung ke jalur utama. Admission control dapat:

  • Menolak job non-kritis sementara.
  • Mengarahkan job ke backlog buffer.
  • Menggabungkan beberapa event menjadi satu pekerjaan kompak.
  • Menurunkan resolusi pembaruan, misalnya dari per-item menjadi batch periodik.

Metrik yang wajib dipantau

Desain queue bertingkat hanya berguna bila dapat diamati. Metrik minimum yang perlu ada:

  • Queue depth per lane.
  • Oldest message age atau usia job tertua.
  • Throughput: job masuk vs job selesai.
  • Success rate dan failure rate.
  • Retry rate dan distribusi attempt.
  • DLQ inflow.
  • Processing latency dan end-to-end latency.
  • Lock contention rate.
  • Cache hit/miss untuk jalur yang relevan.
  • Dependency error rate dan timeout rate.

Alert yang paling praktis biasanya berbasis kombinasi, misalnya:

  • Usia job tertua di priority lane melewati ambang tertentu.
  • Retry rate naik tajam bersamaan dengan error dependency.
  • Backlog buffer terus bertambah walau drain aktif.
  • DLQ menerima lonjakan job dari tipe yang sama.

Runbook singkat saat backlog menumpuk

Ketika backlog naik, tujuan pertama bukan “menghabiskan queue secepat mungkin”, melainkan memulihkan stabilitas tanpa memperbesar kerusakan.

  1. Identifikasi lane yang terdampak
    Lihat queue depth, oldest age, dan failure rate per lane. Jangan menganggap semua backlog sama.
  2. Cek dependency
    Apakah database, cache, API eksternal, atau object storage sedang melambat? Backlog sering hanya gejala.
  3. Pause atau throttle producer non-kritis
    Kurangi pasokan baru sebelum menambah worker secara agresif.
  4. Prioritaskan lane kritis
    Naikkan reserved concurrency untuk priority lane bila perlu.
  5. Aktifkan drain backlog buffer bertahap
    Jangan lepaskan seluruh buffer sekaligus; gunakan rate limit.
  6. Kurangi retry storm
    Perbesar backoff, tambah jitter, atau nonaktifkan retry otomatis untuk error permanen yang sudah jelas.
  7. Inspeksi DLQ dan sampel job gagal
    Cari pola: payload buruk, lock contention, timeout dependency, atau cache stale.
  8. Validasi idempotensi sebelum replay
    Jangan me-replay DLQ besar-besaran tanpa memastikan efek samping aman.
  9. Catat keputusan operasional
    Supaya tuning berikutnya berbasis data, bukan tebakan.

Anti-pattern yang sering membuat queue runtuh

  • Satu queue untuk semua hal meski SLA dan durasi job sangat berbeda.
  • Worker autoscale tanpa batas saat bottleneck sebenarnya ada di database atau API eksternal.
  • Retry instan tanpa jitter yang menciptakan gelombang serangan ke dependency sendiri.
  • Payload job terlalu besar sehingga deserialisasi, transfer, dan retry menjadi mahal.
  • Lock global untuk menyelesaikan race condition lokal.
  • Mengandalkan cache untuk keputusan kritis tanpa validasi versi.
  • Tidak ada DLQ, sehingga job gagal berputar terus tanpa visibilitas.
  • Replay DLQ massal tanpa klasifikasi penyebab gagal.
  • Menaruh semua job “penting” ke priority lane sampai lane itu kehilangan makna.

Pola implementasi yang biasanya paling masuk akal

Untuk banyak sistem, desain berikut cukup seimbang antara ketahanan dan kompleksitas:

  • Priority lane terpisah untuk job yang memengaruhi transaksi inti atau pengalaman pengguna langsung.
  • Normal lane untuk arus rutin.
  • Backlog buffer untuk pekerjaan yang dapat ditunda saat terjadi lonjakan atau pemulihan upstream.
  • DLQ dengan metadata penyebab gagal dan alat replay terbatas.
  • Reserved concurrency per lane ditambah weighted scheduling agar tidak terjadi starvation.
  • Idempotency key di setiap job yang punya efek samping.
  • Retry dengan exponential backoff + jitter, hanya untuk error transient.
  • Rate limit pada drain buffer dan dependency sensitif.
  • Observabilitas per lane, bukan hanya metrik worker global.

Penutup

Desain queue bertingkat bukan sekadar membagi antrean, tetapi membagi risiko. Saat pasokan terganggu, sistem yang stabil adalah sistem yang tahu mana pekerjaan yang harus dipercepat, mana yang boleh ditunda, mana yang harus dihentikan dulu, dan mana yang perlu masuk area investigasi.

Dengan memisahkan normal lane, priority lane, backlog buffer, dan dead-letter queue, lalu menambahkan idempotensi, locking yang sempit, retry yang sehat, dan metrik yang tepat, Anda mengubah antrian dari sumber kekacauan menjadi alat pengendali beban. Dalam konteks operasional nyata, itulah perbedaan antara worker yang terus jatuh saat ada gangguan dan worker yang tetap stabil walau pasokan datang tidak ideal.