Cache stampede dan worker ganda di sistem queue biasanya bukan bug tunggal, melainkan gabungan dari timeout yang tidak cocok, retry yang terlalu agresif, lock yang kedaluwarsa, dan desain job yang belum idempoten. Gejalanya sering muncul saat cache habis bersamaan, worker autoscaling bertambah cepat, atau ada lonjakan event yang memicu banyak job identik.

Solusi yang stabil jarang cukup dengan satu teknik. Praktiknya, Anda biasanya perlu menggabungkan idempotency key, distributed lock, pengaturan visibility timeout yang benar, backoff + jitter, dead letter queue, strategi cache invalidation, dan observability agar bisa membedakan mana bottleneck utama dan mana efek sampingnya.

Masalah yang Sering Terjadi di Produksi

1. Cache stampede

Cache stampede terjadi ketika banyak request atau worker mendeteksi cache miss pada saat yang hampir bersamaan, lalu semuanya menghantam database atau service upstream untuk membangun ulang data yang sama. Jika key tersebut populer, satu cache miss bisa berubah menjadi lonjakan query besar.

Gejala umum:

  • Lonjakan query ke database tepat setelah TTL cache habis.
  • Latency naik mendadak untuk endpoint atau job tertentu.
  • CPU database atau service upstream melonjak, lalu cache baru terisi terlambat.

2. Duplicate worker execution

Eksekusi ganda terjadi ketika dua atau lebih worker memproses job yang secara logis sama, atau bahkan job yang persis sama, pada waktu berdekatan. Ini bisa terjadi karena pesan dikirim ulang, worker crash setelah side effect terjadi tetapi sebelum ack, timeout queue terlalu pendek, atau producer mengirim job duplikat.

Dampaknya bergantung pada jenis job. Untuk job yang membuat invoice, memotong saldo, mengirim email, atau sinkronisasi stok, duplikasi bisa berarti data korup, transaksi ganda, atau notifikasi berulang.

3. Lock kedaluwarsa

Lock sering dipakai untuk mencegah dua worker mengerjakan resource yang sama. Masalahnya, lock punya TTL. Jika job berjalan lebih lama daripada TTL lock, worker kedua bisa menganggap lock sudah bebas lalu memulai pekerjaan yang sama. Dari luar, tim sering mengira lock sudah terpasang, padahal umur lock tidak sesuai dengan durasi kerja nyata.

4. Retry storm

Retry storm terjadi ketika banyak job gagal pada waktu yang sama dan semuanya dijadwalkan retry dengan pola yang seragam, misalnya persis 5 detik kemudian. Hasilnya, service yang sedang tidak sehat justru dihantam lagi dalam gelombang besar, membuat pemulihan semakin lambat.

5. Konsistensi data setelah job gagal

Masalah ini muncul saat job melakukan beberapa langkah: menulis database, memanggil API eksternal, lalu memperbarui cache atau status. Jika gagal di tengah, sebagian perubahan sudah terjadi dan sebagian belum. Tanpa desain yang jelas, sistem masuk ke keadaan setengah jadi yang sulit diperbaiki.

Mengapa Ini Terjadi: Akar Masalah Utama

At-least-once delivery berarti duplikasi itu normal

Banyak sistem queue bekerja dengan model at least once delivery. Artinya, pesan bisa dikirim lebih dari sekali, terutama ketika ack tidak diterima tepat waktu, worker crash, atau timeout habis sebelum pemrosesan selesai. Karena itu, asumsi "satu job pasti diproses satu kali" biasanya salah untuk sistem produksi.

TTL, timeout, dan durasi job tidak sinkron

Masalah klasik:

  • Visibility timeout lebih pendek dari durasi job aktual.
  • Lock TTL lebih pendek dari durasi langkah kritis.
  • Cache TTL terlalu seragam sehingga banyak key kedaluwarsa bersamaan.
  • Client timeout memicu retry padahal backend masih bekerja.

Kalau angka-angka ini tidak dihitung dari distribusi durasi nyata, duplikasi dan stampede akan muncul meskipun kode aplikasi terlihat benar.

Job tidak idempoten

Jika menjalankan job yang sama dua kali menghasilkan dua side effect berbeda, berarti job belum idempoten. Contoh paling umum: create row tanpa unique constraint, memotong saldo tanpa pencatatan idempotency, atau mengirim webhook tanpa deduplikasi.

Observability kurang detail

Banyak tim hanya melihat jumlah gagal dan berhasil. Itu belum cukup. Untuk mendiagnosis stampede atau duplicate execution, Anda perlu tahu cache miss rate per key pattern, durasi job per tipe, retry count, lock contention, queue lag, dan alasan job masuk DLQ.

Arsitektur Mitigasi yang Praktis

Untuk beban nyata, pola berikut lebih aman daripada hanya mengandalkan satu mekanisme.

Client / Producer
    |
    v
API / Event Handler
    |
    |-- generate idempotency key
    |-- optional dedupe check
    v
Queue
    |
    v
Worker
    |
    |-- acquire lock (bila perlu, per resource)
    |-- load/update DB in transaction where appropriate
    |-- call external service with idempotency key if supported
    |-- update state/outbox
    |-- invalidate or refresh cache carefully
    |-- ack message only after critical steps complete
    v
Metrics / Logs / Traces / DLQ

Prinsipnya:

  • Idempotency mencegah side effect ganda.
  • Lock mengurangi konkurensi untuk resource yang sama.
  • Visibility timeout mencegah redelivery terlalu cepat.
  • Backoff + jitter mencegah retry serentak.
  • DLQ memisahkan pesan bermasalah dari aliran normal.
  • Observability memberi bukti, bukan asumsi.

Mitigasi Inti dan Cara Kerjanya

1. Idempotency key: lapisan pertahanan utama

Jika Anda hanya memilih satu teknik untuk mencegah efek terburuk duplicate worker execution, pilih idempotency key. Ide dasarnya: setiap operasi bisnis punya kunci unik yang mewakili niat bisnis, bukan sekadar ID pesan queue.

Contoh:

  • payment:{order_id} untuk penagihan satu order.
  • email:{template}:{user_id}:{date} untuk notifikasi tertentu.
  • inventory-reserve:{cart_id} untuk reservasi stok.

Kunci ini disimpan di storage yang konsisten, biasanya database dengan unique constraint atau tabel khusus status eksekusi.

function processPaymentJob(job) {
  key = "payment:" + job.orderId

  // insert unique row or return existing result
  record = idempotencyStore.begin(key)
  if (record.status == "completed") {
    return record.result
  }

  try {
    result = chargeCustomer(job.orderId, key)
    markOrderPaid(job.orderId, result.transactionId)
    idempotencyStore.complete(key, result)
    return result
  } catch (err) {
    idempotencyStore.failOrKeepPending(key, err)
    throw err
  }
}

Mengapa ini efektif:

  • Jika pesan sama diproses ulang, hasil bisnis tetap satu.
  • Jika worker crash setelah side effect eksternal, retry masih bisa memeriksa status sebelumnya.
  • Jika API eksternal juga mendukung idempotency key, konsistensi lintas sistem menjadi lebih kuat.

Trade-off:

  • Perlu desain skema penyimpanan status eksekusi.
  • Perlu mendefinisikan batas kapan key dianggap sama.
  • Tidak otomatis mencegah semua race condition jika update state lain masih lemah.

2. Distributed lock: batasi konkurensi, jangan menggantikan idempotency

Distributed lock berguna ketika Anda ingin memastikan hanya satu worker yang memproses resource tertentu pada saat yang sama, misalnya satu akun, satu order, atau satu cache key yang sedang direbuild. Namun lock bukan pengganti idempotency. Lock mengurangi peluang balapan; idempotency melindungi hasil akhirnya.

function handleJob(job) {
  lockKey = "lock:order:" + job.orderId
  token = lock.acquire(lockKey, ttl=60s)
  if (!token) {
    retryWithDelay(job)
    return
  }

  try {
    processIdempotentOrder(job)
  } finally {
    lock.release(lockKey, token)
  }
}

Hal penting:

  • TTL lock harus lebih panjang dari bagian kritis, atau ada mekanisme perpanjangan lock.
  • Pelepasan lock sebaiknya memakai token/owner agar worker lain tidak melepas lock yang bukan miliknya.
  • Jika lock hilang saat job masih berjalan, duplicate execution tetap bisa terjadi.

3. Visibility timeout: sesuaikan dengan durasi job nyata

Pada banyak queue, pesan akan terlihat lagi jika tidak di-ack sebelum visibility timeout habis. Jika timeout ini terlalu pendek, job yang masih diproses akan diambil worker lain dan menyebabkan eksekusi ganda.

Panduan praktis:

  • Ukur durasi job pada persentil tinggi, bukan rata-rata.
  • Jika job bisa sangat lama, gunakan heartbeat atau perpanjang timeout selama pemrosesan bila platform mendukung.
  • Pisahkan job berat dan job ringan ke queue berbeda agar timeout tidak dipukul rata.

Kesalahan umum: timeout queue diatur dari asumsi awal proyek, lalu beban dan kompleksitas job berubah, tetapi angka timeout tidak pernah ditinjau ulang.

4. Backoff + jitter: obat untuk retry storm

Retry tanpa jitter membuat semua job gagal bangun lagi pada pola yang sama. Solusinya adalah exponential backoff ditambah jitter, sehingga retry tersebar dan beban pulih lebih halus.

function nextRetryDelay(attempt) {
  base = min(60, 2 ^ attempt)
  jitter = random(0, base / 2)
  return base + jitter
}

Kapan dipakai:

  • Layanan downstream timeout atau rate-limited.
  • Database sedang penuh dan butuh waktu pulih.
  • API eksternal mengembalikan error sementara.

Jangan retry tanpa batas. Tetapkan jumlah percobaan maksimum, lalu kirim ke dead letter queue jika tetap gagal.

5. Dead letter queue: jangan biarkan satu pesan rusak menyumbat sistem

DLQ menampung pesan yang gagal setelah beberapa kali percobaan. Ini penting agar queue utama tetap bergerak dan tim punya tempat khusus untuk investigasi.

Praktik yang disarankan:

  • Simpan alasan gagal terakhir dan jumlah retry.
  • Klasifikasikan error: permanen, sementara, atau bug data.
  • Sediakan alur replay yang aman setelah akar masalah diperbaiki.

DLQ bukan tempat sampah. Jika isinya tidak dipantau, Anda hanya memindahkan masalah dari queue utama ke tempat lain.

6. Cache invalidation dan anti-stampede

Untuk mengatasi cache stampede, beberapa teknik yang umum dipakai:

  • Request coalescing: hanya satu worker/request yang membangun ulang cache, yang lain menunggu atau memakai data lama.
  • Stale-while-revalidate: sajikan data cache yang sedikit usang sambil satu proses memperbarui di belakang.
  • TTL jitter: tambahkan variasi acak pada TTL agar key tidak habis bersamaan.
  • Proactive refresh: refresh key penting sebelum kadaluwarsa.
function getCachedReport(key) {
  value = cache.get(key)
  if (value.exists && !value.expiredHard) {
    if (value.expiredSoft) {
      triggerBackgroundRefreshOnce(key)
    }
    return value.data
  }

  lockKey = "rebuild:" + key
  token = lock.acquire(lockKey, ttl=30s)
  if (token) {
    try {
      fresh = loadFromDatabase(key)
      cache.set(key, fresh, ttlWithJitter())
      return fresh
    } finally {
      lock.release(lockKey, token)
    }
  }

  stale = cache.getStale(key)
  if (stale) return stale.data
  return loadFromDatabase(key)
}

Trade-off:

  • Stale-while-revalidate meningkatkan ketersediaan, tetapi sebagian client bisa menerima data tidak paling baru.
  • Lock rebuild cache mengurangi stampede, tetapi menambah kompleksitas dan risiko lock contention.
  • Refresh proaktif butuh daftar key penting dan observability yang baik.

Kapan Memilih Redis Lock, DB Lock, atau Tanpa Lock

Pilih Redis lock jika:

  • Anda butuh lock ringan dan cepat untuk koordinasi antar worker.
  • Durasi lock pendek dan failure mode bisa ditoleransi dengan lapisan idempotency.
  • Kasusnya seperti rebuild cache, sinkronisasi singkat, atau serialisasi akses ke resource non-kritis.

Kelebihan: cepat, sederhana untuk beban tinggi. Kekurangan: sensitif pada TTL, partisi jaringan, dan implementasi ownership/release yang salah.

Pilih DB lock jika:

  • Operasi bisnis inti sudah terjadi di database yang sama.
  • Anda butuh konsistensi kuat dalam satu transaksi.
  • Resource yang dikunci memang direpresentasikan sebagai row atau entitas database.

Contoh: mencegah dua worker memperbarui saldo atau status order yang sama dalam transaksi yang sama. Kelebihan: selaras dengan transaksi data. Kekurangan: bisa meningkatkan contention dan menahan koneksi database lebih lama.

Pilih tanpa lock jika:

  • Job benar-benar idempoten.
  • Duplikasi aman dan biaya menanganinya kecil.
  • Bottleneck utama justru berasal dari lock contention, bukan dari duplicate execution.

Contoh: sinkronisasi status yang dapat ditulis ulang dengan hasil akhir sama, atau pembuatan materialized cache yang overwrite-safe. Dalam kasus seperti ini, idempotency + dedupe + observability sering lebih sehat daripada lock global.

Aturan praktis: jika side effect finansial atau state penting bisa rusak oleh duplikasi, jangan mengandalkan lock saja. Gunakan idempotency sebagai fondasi, lalu tambahkan lock bila memang ada race yang harus dipersempit.

Konsistensi Data Setelah Job Gagal

Pisahkan status bisnis dari status teknis

Jangan hanya menyimpan "job failed". Simpan juga status bisnis yang relevan, misalnya payment_pending, charged_not_confirmed, atau cache_refresh_failed. Ini memudahkan recovery tanpa menebak-nebak apa yang sudah terjadi.

Gunakan pola outbox untuk integrasi event

Jika aplikasi harus mengubah database lalu mengirim event, pola transactional outbox membantu menghindari keadaan di mana database sudah berubah tetapi event tidak pernah terkirim. Intinya, catat event di tabel outbox dalam transaksi yang sama dengan perubahan bisnis, lalu publisher terpisah mengirim event tersebut secara andal.

Buat job kompensasi jika operasi tidak bisa atomik

Tidak semua langkah bisa dibungkus satu transaksi, terutama jika melibatkan API eksternal. Untuk itu, siapkan compensating action, misalnya membatalkan reservasi, menandai refund pending, atau menjadwalkan rekonsiliasi.

function fulfillOrder(job) {
  beginTransaction()
  order = lockAndLoadOrder(job.orderId)
  ensureNotAlreadyFulfilled(order)
  createOutboxEvent("order_fulfillment_started", order.id)
  markOrderProcessing(order.id)
  commit()

  try {
    reserveInventory(order.id)
    arrangeShipment(order.id)
    markOrderFulfilled(order.id)
  } catch (err) {
    markOrderNeedsCompensation(order.id, err)
    throw err
  }
}

Tujuannya bukan membuat semua langkah selalu berhasil, melainkan membuat kegagalan menjadi terlihat, bisa diulang dengan aman, atau bisa dipulihkan secara terstruktur.

Observability: Data yang Harus Anda Ukur

Tanpa observability, tim sering salah menebak penyebab. Metrik dan log berikut sangat membantu:

  • Queue depth dan queue lag.
  • Processing time per tipe job, termasuk persentil tinggi.
  • Retry count, alasan retry, dan waktu antar retry.
  • Duplicate detection rate dari idempotency store.
  • Lock acquisition success/failure dan lama menunggu lock.
  • Visibility timeout expirations atau redelivery rate.
  • Cache hit/miss rate, rebuild rate, dan key pattern yang panas.
  • DLQ inflow dan kategori error.

Untuk logging, sertakan minimal:

  • job ID, idempotency key, resource ID, attempt number
  • worker ID atau hostname
  • durasi langkah kritis
  • lock token/owner bila relevan
  • status sebelum dan sesudah side effect

Tracing juga membantu ketika satu job memicu call ke banyak service. Anda bisa melihat apakah bottleneck ada di database, cache, atau API eksternal.

Checklist Diagnosis Produksi

  1. Periksa apakah job benar-benar diproses ganda, atau hanya log/metric yang duplikat.
  2. Bandingkan durasi job dengan visibility timeout. Jika banyak job selesai mendekati atau melewati timeout, redelivery sangat mungkin terjadi.
  3. Audit idempotency: apakah side effect utama punya kunci unik dan storage dedupe yang konsisten?
  4. Periksa lock TTL: apakah lebih pendek dari durasi kerja aktual? Apakah ada mekanisme perpanjangan?
  5. Lihat pola retry: apakah semua retry muncul serentak? Jika ya, tambahkan jitter.
  6. Periksa cache key panas: apakah TTL banyak key identik dan habis bersamaan?
  7. Audit ack timing: apakah message di-ack sebelum semua langkah kritis aman, atau terlalu terlambat tanpa timeout memadai?
  8. Tinjau DLQ: apakah error dominan bersifat permanen, timeout, race condition, atau bug data?
  9. Periksa unique constraint dan transaction boundary pada tabel yang rawan duplikasi.
  10. Validasi observability: apakah Anda bisa menghubungkan satu request, satu job, dan satu side effect eksternal lewat correlation ID?

Kesalahan Implementasi yang Sering Terjadi

  • Menganggap distributed lock sudah cukup, lalu melewatkan idempotency.
  • Menyimpan idempotency key di cache volatil tanpa persistensi untuk operasi kritis.
  • Menggunakan TTL cache yang seragam untuk semua key populer.
  • Retry semua error, termasuk error validasi atau data korup yang seharusnya langsung ke DLQ.
  • Mengatur timeout dari rata-rata durasi, bukan dari distribusi nyata.
  • Menghapus cache sebelum data baru konsisten, lalu memicu pembacaan state setengah jadi.
  • Menggabungkan job berat dan ringan dalam queue yang sama dengan satu kebijakan timeout.

Rekomendasi Praktis yang Sederhana

Jika Anda sedang memperbaiki sistem yang sudah berjalan, urutan prioritas yang paling pragmatis biasanya seperti ini:

  1. Tambahkan idempotency key untuk semua side effect penting.
  2. Sesuaikan visibility timeout dan jumlah worker berdasarkan durasi job nyata.
  3. Terapkan backoff + jitter untuk retry.
  4. Aktifkan dan pantau DLQ.
  5. Tambahkan distributed lock hanya pada resource yang memang rawan race.
  6. Perbaiki cache invalidation dengan request coalescing atau stale-while-revalidate.
  7. Lengkapi metrics, logs, traces untuk pembuktian dan tuning lanjutan.

Poin terpentingnya: pada sistem queue terdistribusi, duplikasi dan keterlambatan bukan pengecualian. Desain yang baik bukan mencoba menghapus semua kemungkinan itu, melainkan membuat sistem tetap benar meskipun pesan diproses ulang, lock habis lebih cepat, cache kedaluwarsa bersamaan, atau downstream sempat gagal.