Saat worker bisa memproses pekerjaan sangat cepat, masalah utamanya sering bergeser dari compute ke kebenaran data dan stabilitas operasional. Dalam praktiknya, bottleneck muncul pada backlog queue, retry yang berulang, efek samping ganda, lock yang saling berebut, cache yang kedaluwarsa bersamaan, dan dependensi hilir yang lebih lambat daripada laju konsumsi job.

Itulah inti dari desain queue di sistem terdistribusi: bukan sekadar membuat worker cepat, tetapi memastikan job diproses dengan aman di bawah model at-least-once delivery, tetap idempoten, mudah diobservasi, dan bisa dipulihkan saat terjadi gangguan. Jika konteks operasional Anda mirip layanan berthroughput tinggi—misalnya terinspirasi dari diskusi seperti “1000 tokens per second” sebagai sinyal beban besar—maka yang perlu didesain bukan hanya kapasitas worker, tetapi juga kontrak pemrosesan datanya.

Mengapa queue cepat sering gagal di produksi

Gejala produksi pada sistem queue berthroughput tinggi biasanya terlihat jelas sebelum insiden besar terjadi:

  • Backlog terus naik, meski jumlah worker sudah ditambah.
  • Latency p95/p99 job memburuk, walau median masih terlihat sehat.
  • Duplikasi efek samping, seperti notifikasi terkirim dua kali atau sinkronisasi data menulis ulang status lama.
  • Retry storm, yaitu lonjakan retry karena satu dependency lambat atau gagal sementara.
  • Dead letter queue (DLQ) penuh, tetapi tim tidak punya prosedur replay yang aman.
  • Database hotspot, terutama pada baris atau indeks yang sama karena banyak worker mengakses kunci identik.
  • Cache stampede, saat banyak worker sekaligus melakukan cache rebuild untuk key yang sama.

Akar masalahnya biasanya bukan “worker kurang cepat”, melainkan kombinasi dari hal berikut:

  • Queue diasumsikan exactly-once, padahal praktik umum di sistem terdistribusi adalah at-least-once delivery.
  • Job tidak idempoten, sehingga satu pesan yang sama dapat menghasilkan efek samping berkali-kali.
  • Retry dilakukan tanpa backoff dan jitter, menyebabkan kegagalan sesaat berubah menjadi kemacetan massal.
  • Ordering diasumsikan global, padahal queue paralel cenderung hanya mampu menjaga urutan pada partisi atau kunci tertentu.
  • Lock dipakai terlalu luas, sehingga throughput naik tetapi kontensi juga naik.
  • Invalidasi cache tidak terkoordinasi dengan penulisan data, menghasilkan pembacaan usang atau lonjakan trafik ke database.

Prinsip dasar desain queue di sistem terdistribusi

1. Anggap delivery bersifat at-least-once

Jangan merancang alur seolah satu job hanya akan diterima sekali. Dalam banyak broker dan sistem worker, pesan dapat diproses ulang karena:

  • worker crash setelah menjalankan efek samping tetapi sebelum ack,
  • visibility timeout habis,
  • broker atau consumer mengalami reconnect,
  • operator melakukan replay dari DLQ.

Konsekuensinya: handler job harus aman bila dipanggil lebih dari sekali.

2. Idempotensi adalah perlindungan utama

Idempotensi berarti menjalankan job yang sama berkali-kali tetap menghasilkan keadaan akhir yang sama. Ini paling penting untuk operasi yang memiliki efek samping keluar, misalnya:

  • mengirim notifikasi,
  • mengubah status pesanan,
  • menyinkronkan profil ke sistem lain,
  • membangun ulang cache turunan.

Pola paling umum adalah menyimpan idempotency key yang unik per efek samping. Kunci ini bisa dibentuk dari kombinasi seperti event_type + entity_id + version atau request_id yang dibawa dari upstream.

// Pseudocode handler notifikasi yang idempoten
function processNotificationJob(job) {
  key = "notif:" + job.userId + ":" + job.template + ":" + job.eventVersion

  // insert jika belum ada, gagal jika sudah ada
  inserted = db.insertIgnore("processed_messages", {
    idempotency_key: key,
    created_at: now()
  })

  if (!inserted) {
    log("duplicate job skipped", { key })
    return
  }

  sendNotification(job.userId, job.template, job.payload)
}

Dengan pola ini, jika job dikirim ulang, sistem akan mendeteksi bahwa efek samping tersebut sudah pernah dilakukan.

3. Pisahkan throughput dari konsistensi

Semakin tinggi throughput, semakin besar godaan untuk memproses semuanya secara paralel. Masalahnya, tidak semua domain cocok untuk paralelisme bebas. Contohnya:

  • Notifikasi promosi umumnya toleran terhadap urutan yang longgar.
  • Perubahan saldo, stok, atau state machine order biasanya membutuhkan kontrol urutan yang lebih ketat.

Pertanyaan desainnya adalah: data mana yang wajib konsisten segera, dan data mana yang boleh eventually consistent?

Pola desain yang paling sering dipakai

Idempotency store

Simpan jejak bahwa sebuah efek samping sudah diproses. Implementasinya bisa berupa tabel database dengan unique constraint atau penyimpanan key-value dengan TTL. Pilih database relasional jika:

  • Anda butuh jaminan unik yang kuat.
  • Riwayat perlu diaudit.
  • Efek samping terkait transaksi data utama.

Pilih penyimpanan key-value bila:

  • volume sangat tinggi,
  • retensi pendek cukup,
  • duplikasi setelah masa TTL masih bisa diterima.

Catatan: TTL terlalu pendek adalah kesalahan umum. Jika pesan lama masih mungkin direplay setelah beberapa hari, idempotency key yang hanya hidup 1 jam tidak cukup melindungi sistem.

Distributed locking: pakai sempit, jangan refleks

Distributed lock berguna saat hanya satu worker yang boleh memproses entitas tertentu dalam satu waktu, misalnya sinkronisasi akun eksternal per account_id. Namun lock bukan pengganti idempotensi.

Gunakan lock bila:

  • ada resource bersama yang tidak aman diproses paralel,
  • urutan per entitas penting,
  • kontensi per key relatif rendah.

Jangan gunakan lock global untuk seluruh tipe job kecuali benar-benar perlu. Lock global membuat sistem terlihat “aman” tetapi throughput jatuh drastis dan antrean cepat menumpuk.

// Pseudocode lock per account
function processAccountSync(job) {
  lockKey = "lock:account-sync:" + job.accountId
  lock = acquireLock(lockKey, ttl=30s)
  if (!lock) {
    retryLater(job)
    return
  }

  try {
    syncAccount(job.accountId)
  } finally {
    releaseLock(lock)
  }
}

Trade-off-nya jelas: lock menurunkan balapan data, tetapi menambah kontensi dan risiko stale lock bila TTL, perpanjangan lock, atau mekanisme release tidak rapi.

Deduplication di level producer dan consumer

Deduplication terbaik biasanya terjadi di dua tempat:

  1. Producer tidak menerbitkan event identik berulang kali bila sumbernya sama.
  2. Consumer tetap memverifikasi duplikasi karena producer tidak selalu bisa dipercaya.

Contoh: saat profil pengguna berubah, producer bisa membandingkan perubahan penting sebelum mengirim job sinkronisasi. Tetapi consumer tetap memeriksa event_id atau entity_version untuk mencegah proses ulang.

Retry dengan backoff dan jitter

Retry adalah kebutuhan, tetapi retry yang salah sering lebih berbahaya daripada tidak retry sama sekali. Gunakan kategori error:

  • Transient error: timeout jaringan, dependency 503, limit sesaat. Layak di-retry.
  • Permanent error: payload invalid, referensi data tidak ada, bug deterministik. Jangan retry tanpa batas.

Praktik yang aman:

  • gunakan exponential backoff,
  • tambahkan jitter agar retry tidak serentak,
  • batasi jumlah percobaan,
  • kirim ke dead letter queue setelah gagal berulang.
// Pseudocode penjadwalan retry
attempt = job.attempt + 1
baseDelay = min(2 ^ attempt, 300)   // detik, hanya contoh strategi
jitter = random(0, 30)
nextDelay = baseDelay + jitter
scheduleRetry(job, delay=nextDelay)

Jangan me-retry error validasi payload. Itu hanya membakar kapasitas worker dan membuat backlog job sehat ikut tertunda.

Dead Letter Queue bukan tempat sampah

DLQ berguna untuk memisahkan job bermasalah dari arus utama. Namun DLQ baru berguna jika ada keputusan operasional yang jelas:

  • kapan job masuk DLQ,
  • siapa yang menerima alert,
  • bagaimana membedakan bug kode vs data rusak vs dependency eksternal,
  • bagaimana replay dilakukan secara aman dan bertahap.

Replay massal tanpa throttling adalah kesalahan klasik. Setelah dependency pulih, ribuan pesan dari DLQ bisa kembali menabrak sistem hilir dan mengulang insiden yang sama.

Ordering: tentukan batas urutan yang benar-benar dibutuhkan

Urutan global pada queue terdistribusi mahal dan sering tidak diperlukan. Yang biasanya dibutuhkan adalah ordering per entity, misalnya per user_id, order_id, atau account_id.

Pola umum:

  • gunakan partisi berdasarkan kunci entitas,
  • pastikan semua event untuk entitas yang sama masuk ke partisi yang sama,
  • proses secara serial di level partisi atau gunakan versi data untuk menolak event usang.

Untuk sinkronisasi data, versi sangat membantu:

// Pseudocode menolak event usang
function applyProfileUpdate(event) {
  current = db.getUser(event.userId)
  if (event.version <= current.syncedVersion) {
    return // event lama atau duplikat
  }

  db.updateUser(event.userId, {
    profileData: event.data,
    syncedVersion: event.version
  })
}

Jika urutan sempat terbalik karena retry atau redelivery, versi data membantu menjaga keadaan akhir tetap benar.

Contoh alur: notifikasi dan sinkronisasi data

Skenario 1: pemrosesan notifikasi

Misalkan aplikasi menghasilkan event order_paid. Dari event ini, sistem ingin:

  1. mengirim notifikasi ke pengguna,
  2. memperbarui dashboard internal,
  3. menulis audit log.

Desain yang relatif aman:

  • Producer menulis event ke tabel outbox dalam transaksi yang sama dengan perubahan status order.
  • Publisher membaca outbox dan mengirim pesan ke broker.
  • Worker notifikasi memakai idempotency key per order_id + template.
  • Worker dashboard boleh eventually consistent dan bisa di-batch.
  • Audit log sebaiknya append-only dan tidak bergantung pada cache.

Mengapa pola outbox membantu? Karena tanpa itu, aplikasi bisa berhasil mengubah status order tetapi gagal menerbitkan event. Akibatnya data utama berubah, tetapi job turunan tidak pernah berjalan.

Skenario 2: sinkronisasi data ke sistem eksternal

Contoh lain adalah sinkronisasi profil pelanggan ke CRM eksternal. Di sini bottleneck sering bukan worker lokal, tetapi API eksternal yang punya latency tinggi, limit rate, atau semantik update yang tidak idempoten.

Pola yang umum dipakai:

  • queue dipartisi per customer_id,
  • lock hanya pada customer yang sedang disinkronkan,
  • setiap job membawa version,
  • consumer mengabaikan versi lama,
  • retry dibatasi untuk error transient,
  • job gagal permanen masuk DLQ dengan alasan yang tersimpan jelas.

Jika API eksternal lambat, menambah worker lokal sering hanya memperbesar tekanan ke dependency. Solusi yang lebih tepat adalah rate limiting, concurrency limit per dependency, atau batch update bila API mendukung.

Cache invalidation dan cache stampede dalam alur queue

Queue dan cache sering berinteraksi secara tidak sehat saat throughput tinggi. Dua pola masalah yang umum:

Invalidasi cache terlalu cepat atau terlalu lambat

Misalnya worker memperbarui database lalu menghapus cache. Jika pembacaan terjadi di sela-sela replikasi atau ada event lain yang datang out-of-order, konsumen bisa membaca nilai lama lalu menulis ulang cache dengan data usang.

Pendekatan yang lebih aman bergantung pada kebutuhan:

  • Cache-aside cocok untuk banyak kasus, tetapi perlu proteksi terhadap stampede.
  • Write-through lebih konsisten, tetapi menambah biaya tulis.
  • Versioned cache key membantu mencegah overwrite oleh data lama.

Cache stampede saat banyak worker miss bersamaan

Saat key panas kedaluwarsa, banyak worker bisa sekaligus memuat ulang data dari database atau service hilir. Gejalanya mirip insiden database padahal pemicunya cache.

Mitigasinya:

  • gunakan TTL acak agar expiry tidak serentak,
  • terapkan single flight atau lock singkat saat rebuild cache,
  • sajikan stale data untuk waktu terbatas bila domain memungkinkan,
  • prewarm cache untuk key yang sangat panas.
// Pseudocode cache rebuild dengan single flight sederhana
function getUserProfile(userId) {
  key = "user-profile:" + userId
  value = cache.get(key)
  if (value) return value

  lock = acquireLock("rebuild:" + key, ttl=5s)
  if (lock) {
    try {
      value = db.loadUserProfile(userId)
      cache.set(key, value, ttl=randomizedTtl())
      return value
    } finally {
      releaseLock(lock)
    }
  }

  // worker lain sedang membangun cache
  sleep(50ms)
  return cache.get(key) || db.loadUserProfile(userId)
}

Trade-off-nya: menyajikan data usang kadang lebih baik daripada merobohkan database. Tetapi untuk data kritis seperti saldo, strategi ini harus sangat hati-hati.

Metrik yang wajib dipantau

Tanpa metrik yang tepat, backlog sering baru disadari ketika pengguna sudah terdampak. Fokus pada metrik yang menjelaskan kapasitas, kebenaran, dan kesehatan retry:

Metrik queue

  • Queue depth/backlog: jumlah job menunggu.
  • Oldest message age: umur pesan tertua, sering lebih bermakna daripada panjang antrean.
  • Ingress vs egress rate: laju job masuk dibanding job selesai.
  • Time in queue: waktu tunggu sebelum diproses.

Metrik worker

  • Success rate dan error rate per tipe job.
  • Retry rate dan distribusi jumlah attempt.
  • Processing time p50/p95/p99.
  • Concurrency aktif dan utilisasi worker.
  • Duplicate detection hit rate: berapa banyak job terdeteksi duplikat.

Metrik dependency

  • latency database dan query paling lambat,
  • kontensi lock, timeout, deadlock,
  • rate limit atau error dari API eksternal,
  • cache hit ratio dan rebuild rate.

Untuk alert, umur pesan tertua dan retry rate biasanya lebih cepat mengungkap masalah daripada sekadar CPU worker.

Trade-off consistency yang harus diputuskan di depan

Tidak ada desain queue yang gratis. Beberapa keputusan penting:

At-least-once vs exactly-once

Dalam banyak sistem nyata, mengejar exactly-once end-to-end jauh lebih sulit daripada membangun consumer yang idempoten. Karena itu pendekatan praktis yang umum adalah terima at-least-once, lalu lindungi dengan idempotensi dan versioning.

Ordering ketat vs throughput tinggi

Ordering ketat memperkecil race condition, tetapi membatasi paralelisme. Jika hanya sebagian domain yang butuh urutan, terapkan per key, bukan global.

Consistency kuat vs availability

Saat dependency hilir terganggu, Anda harus memilih:

  • menahan job agar data lebih konsisten, atau
  • tetap melayani dengan eventual consistency dan proses koreksi di belakang.

Untuk notifikasi, eventual consistency sering cukup. Untuk mutasi finansial, biasanya tidak.

Kesalahan implementasi yang sering terjadi

  • Meng-ack job terlalu awal sebelum efek samping benar-benar berhasil.
  • Meng-ack terlalu lambat hingga visibility timeout habis dan job diambil worker lain.
  • Retry tanpa batas untuk payload yang pasti gagal.
  • Idempotency key tidak stabil, misalnya memakai timestamp acak sehingga duplikasi tidak terdeteksi.
  • Lock tanpa TTL atau fencing, menyebabkan lock yatim.
  • DLQ tanpa metadata alasan kegagalan, membuat replay buta.
  • Mengandalkan cache sebagai sumber kebenaran utama untuk alur kritis.

Runbook singkat saat backlog mulai melonjak

Runbook tidak perlu panjang, tetapi harus bisa dieksekusi cepat oleh on-call.

  1. Lihat umur pesan tertua, bukan hanya panjang queue. Ini menunjukkan dampak aktual.
  2. Periksa distribusi error: apakah dominan timeout, rate limit, validasi, atau lock contention?
  3. Bandingkan ingress dan egress rate. Jika ingress lebih tinggi, scaling worker mungkin hanya solusi sementara.
  4. Identifikasi dependency lambat: database, cache, API eksternal, atau service internal lain.
  5. Kurangi concurrency pada jalur yang gagal bila gejalanya retry storm atau rate limit.
  6. Aktifkan throttling atau circuit breaker untuk dependency bermasalah.
  7. Pastikan idempotensi aman sebelum melakukan replay atau menaikkan worker.
  8. Isolasi job beracun ke DLQ agar antrean utama kembali bergerak.
  9. Replay DLQ bertahap dengan batas laju yang terukur.
  10. Setelah insiden, audit timeout, backoff, visibility timeout, ukuran batch, dan pola lock.

Penutup

Desain queue di sistem terdistribusi bukan lomba membuat worker paling cepat. Tujuan utamanya adalah menjaga sistem tetap benar saat cepat. Pada throughput tinggi, backlog, duplikasi, retry, ordering, lock, dan cache akan saling memengaruhi. Karena itu fondasi yang paling penting biasanya bukan broker tertentu, melainkan keputusan desain seperti idempotensi, versioning, retry yang disiplin, DLQ yang bisa dioperasikan, dan batas konsistensi yang eksplisit.

Jika Anda sedang menangani gejala seperti backlog terus naik, worker sudah banyak tetapi data masih kacau, mulai dari pertanyaan sederhana: apa yang terjadi jika satu job dijalankan dua kali, terlambat, atau datang tidak berurutan? Dari sana, desain queue Anda akan jauh lebih siap menghadapi beban nyata.