Cache stampede di worker queue terjadi ketika banyak worker atau job mencoba membangun ulang data yang sama secara bersamaan setelah cache habis, invalid, atau belum tersedia. Akibatnya bukan hanya lonjakan beban ke database atau API downstream, tetapi juga duplicate job, lock timeout, stale cache yang bertahan terlalu lama, dan hasil yang tidak konsisten setelah retry.

Pada sistem terdistribusi, kita jarang mendapat sinyal yang rapi. Seperti ide embedding information in disorder, sering kali informasi penting justru tersebar dalam state yang tidak sinkron: key cache sudah expire, job retry belum selesai, lock masih tertinggal, worker lain sudah menulis versi lama, dan metrik antrian mulai naik. Tantangan praktisnya adalah mengubah “state yang berantakan” ini menjadi alur operasional yang aman: siapa yang boleh menghitung ulang, berapa lama yang lain menunggu, kapan boleh memakai data stale, dan bagaimana menjaga hasil tetap konsisten saat job gagal lalu diulang.

Memahami alur queue-worker-cache dan titik rawannya

Pola yang sering ditemui:

  1. Request atau proses internal menambahkan job ke queue untuk membangun atau menyegarkan data.
  2. Beberapa worker mengambil job yang terlihat identik atau menyentuh resource yang sama.
  3. Worker membaca cache. Jika miss atau expired, worker menghitung ulang dari database atau service lain.
  4. Hasil ditulis ke cache, lalu dipakai oleh request berikutnya atau job lanjutan.

Masalah muncul ketika beberapa kondisi terjadi bersamaan:

  • Thundering herd: banyak worker melihat cache miss pada saat hampir bersamaan.
  • Duplicate job: queue berisi pekerjaan dengan key bisnis yang sama, baik karena producer mengirim dua kali maupun karena retry.
  • Lock timeout: lock habis sebelum komputasi selesai, lalu worker lain ikut membangun ulang.
  • Stale cache: data lama tetap disajikan lebih lama dari yang direncanakan karena refresh gagal atau tertunda.
  • Inkonsistensi setelah retry: job yang gagal lalu diulang menimpa hasil yang lebih baru.

Intinya, cache tidak bisa diperlakukan sebagai lapisan yang berdiri sendiri. Pada worker queue, cache harus didesain bersama mekanisme koordinasi antar worker.

Mengapa cache stampede pada worker queue lebih sulit daripada di request biasa

Pada request-response biasa, stampede sering dibahas sebagai banyak request web yang serentak mengakses key yang sama. Pada worker queue, situasinya lebih rumit karena:

  • Job dapat replayed atau di-retry.
  • Job bisa selesai tidak berurutan.
  • Visibility timeout queue dan lock TTL bisa saling bertabrakan.
  • Worker biasanya berjalan paralel dalam jumlah besar.
  • Pembacaan dan penulisan cache tidak selalu terjadi dalam satu transaksi dengan sumber data utama.

Karena itu, solusi tunggal seperti “pasang lock” sering tidak cukup. Anda perlu menggabungkan beberapa teknik sesuai sifat beban kerja dan kebutuhan konsistensi.

Strategi inti: kapan memakai lock, lease, TTL jitter, coalescing, dan idempotency key

1. Distributed lock: pilih saat komputasi ulang mahal dan harus dibatasi satu eksekutor

Distributed lock digunakan agar hanya satu worker yang boleh membangun ulang cache untuk key tertentu pada satu waktu. Worker lain bisa menunggu, keluar lebih cepat, atau memakai data stale.

Pakai lock jika:

  • Perhitungan ulang mahal bagi database atau API downstream.
  • Biaya duplicate work lebih besar daripada biaya koordinasi.
  • Anda butuh batas tegas: satu key hanya diproses satu kali dalam satu jendela waktu.

Jangan menganggap lock sebagai solusi otomatis untuk konsistensi penuh. Lock hanya mengurangi kerja ganda; ia tidak menyelesaikan masalah hasil lama menimpa hasil baru setelah retry.

Catatan: Lock harus memiliki owner token atau mekanisme serupa agar hanya pemilik lock yang boleh melepaskan atau memperpanjang lock. Ini mencegah worker lain melepas lock yang bukan miliknya.

Contoh alur dengan distributed lock

job(key):
  cacheValue = cache.get(dataKey(key))
  if cacheValue exists and not expired:
    return cacheValue

  lockToken = lock.acquire(lockKey(key), ttl=30s)
  if not lockToken:
    stale = cache.get(staleKey(key))
    if stale exists:
      return stale
    requeue_with_backoff(job)
    return

  try:
    cacheValue = cache.get(dataKey(key))
    if cacheValue exists and not expired:
      return cacheValue

    result = recompute_from_db_or_api(key)
    cache.set(dataKey(key), result, ttl=10m + jitter)
    cache.set(staleKey(key), result, ttl=30m)
    return result
  finally:
    lock.release(lockKey(key), lockToken)

Pola double-check setelah lock didapat penting. Bisa jadi worker lain sudah mengisi cache lebih dulu sebelum lock aktif atau sebelum job ini benar-benar masuk ke critical section.

Trade-off distributed lock

  • Kelebihan: menekan duplicate recomputation, melindungi downstream, sederhana untuk key per resource.
  • Kekurangan: menambah latensi, rawan salah TTL, perlu mekanisme fail-safe jika worker mati di tengah proses.

2. Lease: pilih saat komputasi bisa lama dan lock perlu diperpanjang aman

Lease adalah lock yang dapat diperpanjang selama worker masih hidup dan benar-benar mengerjakan tugasnya. Ini lebih aman daripada lock TTL statis jika durasi komputasi bervariasi.

Pakai lease jika:

  • Durasi job sulit diprediksi.
  • Ada risiko lock habis sebelum proses selesai.
  • Worker mampu mengirim heartbeat berkala.

Perhatian utamanya adalah jika heartbeat macet sementara proses sebenarnya masih berjalan, worker lain bisa mengambil alih. Karena itu lease tetap perlu dikombinasikan dengan perlindungan pada fase tulis hasil, misalnya verifikasi versi atau compare-and-set.

3. Jittered TTL: pilih untuk mencegah key populer kadaluarsa bersamaan

Jika banyak key dibuat pada waktu yang mirip dan semuanya memakai TTL yang sama, expiry akan bergerombol. Inilah pemicu klasik stampede.

Tambahkan jitter pada TTL, misalnya random kecil di sekitar TTL dasar:

baseTTL = 600   // 10 menit
jitter = random(0, 120)
finalTTL = baseTTL + jitter

Mengapa ini bekerja? Karena expiry tersebar, sehingga beban refresh menyebar sepanjang waktu alih-alih meledak di satu detik tertentu.

Trade-off-nya, umur cache menjadi tidak seragam. Untuk sebagian sistem ini tidak masalah, tetapi untuk data yang sangat sensitif waktu, jitter harus dijaga kecil.

4. Request coalescing: pilih saat banyak job identik bisa digabung

Request coalescing berarti banyak pemicu untuk resource yang sama tidak masing-masing membuat komputasi baru. Sebaliknya, mereka menunggu hasil yang sama atau menempel ke pekerjaan yang sudah berjalan.

Di worker queue, implementasinya bisa berupa:

  • Menyimpan status “refresh sedang berjalan” per key.
  • Menolak enqueue job baru jika ada job aktif dengan idempotency key yang sama.
  • Mengarahkan worker lain untuk membaca hasil yang sedang diproses, atau keluar lalu menunggu retry terjadwal.

Pendekatan ini cocok ketika producer sangat bising, misalnya satu perubahan data memicu banyak event turunan yang akhirnya meminta cache key yang sama.

5. Idempotency key: wajib saat queue bisa mengulang job

Idempotency key memastikan job yang sama tidak menghasilkan efek samping berulang ketika dikirim dua kali atau di-retry. Dalam konteks cache, ini penting untuk:

  • Mencegah job identik memicu refresh berkali-kali.
  • Mencegah penulisan hasil yang sama secara berulang ke cache atau storage lain.
  • Mengikat hasil kerja ke satu identitas operasi bisnis, bukan sekadar ke nama worker.

Idempotency key biasanya berasal dari kombinasi key bisnis dan versi input, misalnya product:123:price:v45. Jika input berubah, key juga berubah, sehingga hasil lama tidak dianggap identik dengan hasil baru.

Skenario incident yang realistis

Kasus: cache harga produk habis, queue meledak, hasil lama menimpa hasil baru

Misalkan sistem e-commerce menyimpan ringkasan harga dan stok produk di cache. Ketika banyak produk populer expire hampir bersamaan, event sinkronisasi menambahkan job refresh ke queue. Karena ada retry dari kegagalan sebelumnya, beberapa job untuk produk yang sama ikut masuk lagi.

Urutan incident:

  1. Cache key product:123:summary expired.
  2. Sepuluh worker mengambil job berbeda yang ternyata menyentuh produk yang sama.
  3. Tanpa lock, semuanya memanggil database dan service pricing.
  4. Salah satu panggilan lambat, worker timeout, job di-retry oleh queue.
  5. Worker yang retry selesai belakangan tetapi menulis hasil berdasarkan snapshot lama.
  6. Cache akhirnya berisi data stale, sementara downstream sudah menerima lonjakan beban.

Perbaikan praktisnya:

  • Gunakan lock atau lease per product_id.
  • Pakai stale-while-revalidate: worker lain boleh menyajikan cache lama untuk periode singkat.
  • Tambahkan jitter pada TTL agar expiry tidak serentak.
  • Gunakan idempotency key berbasis product_id dan versi perubahan.
  • Saat menulis hasil, validasi bahwa versi input job tidak lebih tua dari versi terbaru yang sudah tersimpan.

Pola implementasi yang aman dan cukup praktis

Pattern 1: stale-while-revalidate untuk menahan beban

Jika toleransi stale data masih ada dalam jangka pendek, gunakan dua horizon waktu:

  • Fresh TTL: data dianggap segar.
  • Stale TTL: data masih boleh dipakai sambil refresh berjalan.

Dengan pola ini, saat fresh TTL habis, hanya satu worker yang merefresh. Yang lain cukup memakai stale value. Ini menurunkan tekanan ke database dan mengurangi antrean job duplikat.

Hati-hati: stale-while-revalidate tidak cocok untuk data yang harus akurat per detik, misalnya saldo yang menentukan otorisasi transaksi.

Pattern 2: versioned write untuk mencegah retry menimpa hasil lebih baru

Masalah klasik bukan hanya duplicate recomputation, tetapi juga out-of-order completion. Job A mulai duluan, job B mulai belakangan namun selesai lebih cepat. Tanpa proteksi, job A yang retry bisa menimpa hasil B.

Solusinya: simpan metadata versi atau timestamp logis bersama nilai cache, lalu hanya izinkan penulisan jika versi job saat ini masih relevan.

result = compute(key)
current = cache.getWithMeta(dataKey(key))

if current == nil or job.version >= current.version:
  cache.set(dataKey(key), {
    value: result,
    version: job.version,
    written_at: now()
  }, ttl=finalTTL)

Versi terbaik adalah versi domain, misalnya nomor perubahan data dari event stream, bukan sekadar waktu lokal worker yang bisa berbeda antar node.

Pattern 3: enqueue deduplication sebelum job dijalankan

Jika duplicate job sudah bisa dicegah di sisi producer atau broker, beban worker turun jauh. Beberapa tim menerapkan key deduplikasi di saat enqueue, misalnya satu job aktif per resource dalam periode tertentu.

Ini tidak menggantikan idempotensi di sisi consumer. Queue tetap bisa mengantar ulang job jika worker gagal setelah menerima pesan.

Pattern 4: lock granularity yang tepat

Gunakan lock per resource yang benar-benar diperebutkan, misalnya per user_id, product_id, atau kombinasi key bisnis yang stabil. Lock global untuk seluruh kategori cache sering menjadi bottleneck.

Terlalu halus juga berbahaya jika satu operasi menyentuh banyak key yang sebenarnya harus konsisten bersama. Dalam kasus itu, Anda mungkin butuh strategi pengelompokan key atau desain cache yang berbeda.

Contoh implementasi pseudo-code end-to-end

function refreshSummary(productId, eventVersion):
  key = "product:" + productId + ":summary"
  lockKey = "lock:" + key
  idemKey = "idem:" + productId + ":" + eventVersion

  if idemStore.exists(idemKey):
    return "already-processed"

  value = cache.get(key)
  if value exists and value.version >= eventVersion and not value.freshExpired:
    idemStore.put(idemKey, ttl=1h)
    return value

  token = lock.acquire(lockKey, ttl=30s)
  if not token:
    stale = cache.get(key)
    if stale exists and not stale.hardExpired:
      return stale
    requeue_with_backoff(productId, eventVersion)
    return "requeued"

  try:
    current = cache.get(key)
    if current exists and current.version >= eventVersion and not current.freshExpired:
      idemStore.put(idemKey, ttl=1h)
      return current

    data = loadFromPrimaryStore(productId)
    summary = buildSummary(data)

    latest = cache.get(key)
    if latest == nil or eventVersion >= latest.version:
      cache.set(key, {
        value: summary,
        version: eventVersion,
        freshUntil: now() + 10m,
        hardUntil: now() + 30m,
      }, ttl=10m + random(0, 120))

    idemStore.put(idemKey, ttl=1h)
    return "ok"
  finally:
    lock.release(lockKey, token)

Meski pseudo-code, ada beberapa prinsip penting di sini:

  • Idempotency dicek lebih awal.
  • Cache dicek sebelum dan sesudah lock.
  • Worker lain boleh memakai stale value untuk menahan herd.
  • Penulisan hasil memperhatikan versi agar retry tidak merusak state terbaru.

Memilih strategi berdasarkan kebutuhan konsistensi

Saat prioritas utama adalah performa dan perlindungan downstream

Pilih kombinasi berikut:

  • Jittered TTL
  • Stale-while-revalidate
  • Lock per key untuk refresh berat
  • Backoff pada retry

Ini cocok untuk ringkasan dashboard, agregasi analitik, atau cache baca yang boleh sedikit terlambat.

Saat prioritas utama adalah konsistensi hasil

Pilih kombinasi berikut:

  • Idempotency key wajib
  • Versioned write atau compare-and-set
  • Lease jika job lama
  • Validasi out-of-order completion

Ini cocok untuk data yang dipakai sebagai dasar keputusan lanjutan, misalnya status pemrosesan, harga efektif, atau hasil sinkronisasi yang memicu workflow lain.

Kompromi yang perlu diterima

  • Lebih ketat konsistensi biasanya berarti koordinasi lebih banyak dan throughput lebih rendah.
  • Lebih agresif stale serving biasanya berarti latensi lebih stabil, tetapi risiko data lama lebih tinggi.
  • TTL lebih panjang menekan recomputation, tetapi memperbesar jendela ketidakakuratan.
  • Lock TTL pendek mengurangi dead lock semu, tetapi meningkatkan risiko duplicate recomputation jika proses validnya lama.

Metrik yang perlu dipantau

Tanpa observabilitas, cache stampede sering baru terlihat setelah database atau service downstream melambat. Pantau metrik berikut:

  • Cache hit ratio per key group, bukan hanya global.
  • Cache miss burst: lonjakan miss dalam interval pendek.
  • Jumlah lock acquire sukses/gagal.
  • Durasi tunggu lock dan jumlah lock timeout.
  • Jumlah duplicate job atau dedupe rejection.
  • Retry rate per jenis job.
  • Stale serve rate: seberapa sering sistem mengembalikan data stale.
  • Recompute duration dan distribusinya, bukan hanya rata-rata.
  • Queue depth, age of oldest message, dan time-to-drain.
  • Write skew: job versi lama mencoba menulis setelah versi baru ada.

Tambahkan log terstruktur minimal berisi:

  • resource key
  • job id
  • idempotency key
  • version/event sequence
  • lock token atau owner id
  • hasil akhir: hit, stale, recompute, requeue, dropped

Debugging tip saat incident berlangsung

1. Cari apakah masalah utamanya miss burst atau duplicate enqueue

Jika miss naik tapi enqueue normal, sumbernya bisa expiry serentak. Jika enqueue juga meledak, producer atau retry policy mungkin penyebab utama.

2. Bandingkan lock TTL dengan durasi recompute p95/p99

Lock yang terlalu pendek akan terlihat dari pola lock expire lalu worker lain masuk, sementara job pertama belum selesai. Gejalanya: jumlah recompute untuk key yang sama melonjak.

3. Periksa job out-of-order

Lihat apakah job dengan versi lebih tua masih menulis hasil setelah job versi baru selesai. Jika iya, masalah Anda bukan lagi stampede murni, tetapi konsistensi penulisan.

4. Audit retry policy

Retry tanpa backoff atau tanpa jitter pada delay sering memperparah herd. Worker gagal bersama, lalu bangun lagi bersama.

5. Cek apakah stale data terlalu lama bertahan

Jika refresh sering gagal, stale-while-revalidate bisa berubah menjadi stale-forever. Pastikan ada alarm saat hard TTL sering terlewati atau saat refresh gagal berturut-turut.

Checklist implementasi untuk tim backend

  1. Tentukan cache key dan resource boundary yang jelas.
  2. Tentukan apakah data boleh stale sementara. Jika ya, definisikan fresh TTL dan hard TTL.
  3. Tambahkan jitter pada TTL untuk key yang banyak diakses.
  4. Gunakan distributed lock per key jika recomputation mahal.
  5. Jika durasi job panjang atau variatif, pakai lease/heartbeat.
  6. Lakukan double-check cache sebelum dan sesudah lock.
  7. Terapkan idempotency key pada consumer queue.
  8. Simpan version metadata bersama cache untuk mencegah overwrite oleh retry lama.
  9. Pastikan retry memakai exponential backoff + jitter, bukan delay tetap.
  10. Pantau lock contention, stale serve rate, dan duplicate recompute.
  11. Siapkan mode degradasi: serve stale, requeue, atau drop low-priority refresh.
  12. Uji dengan skenario paralel: banyak worker, lock expire, retry, dan out-of-order completion.

Anti-pattern yang sering terjadi

  • Lock tanpa owner token: worker lain bisa melepas lock yang bukan miliknya.
  • TTL lock disamakan dengan estimasi optimistis: di produksi durasi nyata sering lebih panjang.
  • Mengandalkan cache saja untuk idempotensi: aman untuk sebagian kasus, tetapi rapuh jika TTL salah atau key collision terjadi.
  • Menghapus cache sebelum data baru siap: memicu miss massal dan herd.
  • Retry instan tanpa jitter: semua worker bangun kembali di saat bersamaan.
  • Satu lock global untuk semua refresh: throughput turun dan contention tinggi.
  • Tidak menyimpan versi data: hasil lama bisa menimpa hasil baru diam-diam.
  • Metrik hanya hit ratio global: menutupi hotspot pada beberapa key populer.

Kesimpulan

Cache stampede di worker queue tidak cukup ditangani dengan satu trik. Masalah nyatanya berada di pertemuan antara cache expiry, paralelisme worker, retry queue, dan penulisan hasil yang bisa tidak berurutan. Karena state sistem terdistribusi sering tampak tidak rapi, tim backend perlu mengekstrak sinyal operasional dari sana: key mana yang diperebutkan, kapan lock sering habis, kapan stale masih aman, dan kapan retry mulai merusak konsistensi.

Jika harus mulai dari langkah paling praktis, urutannya biasanya begini: tambahkan jitter pada TTL, pasang lock per resource untuk recomputation berat, terapkan stale-while-revalidate bila domain mengizinkan, lalu tutup celah konsistensi dengan idempotency key dan versioned write. Setelah itu, ukur contention, retry, dan stale serve rate. Dengan pendekatan ini, Anda tidak hanya menurunkan beban saat incident, tetapi juga membuat perilaku sistem lebih dapat diprediksi ketika disorder memang tidak bisa dihindari.