Cache stampede adalah kondisi ketika banyak request datang pada saat item cache habis atau invalid, lalu semuanya mencoba membangun ulang data yang sama secara bersamaan. Dampaknya mirip thundering herd atau dogpile effect: beban yang tadinya kecil di level lokal berubah menjadi lonjakan global pada database, API upstream, atau service internal.

Analogi yang berguna datang dari cellular automata: aturan sederhana di tingkat sel dapat memunculkan pola besar yang sulit diprediksi. Dalam sistem terdistribusi, interaksi lokal yang tampak sepele—misalnya satu key cache kedaluwarsa, beberapa worker membaca miss, dan retry otomatis aktif—bisa memicu perilaku emergen berupa antrean memanjang, CPU melonjak, koneksi DB habis, dan latensi memburuk di seluruh sistem. Karena itu, pencegahan cache stampede bukan hanya soal cache, tetapi juga soal koordinasi antarproses.

Memahami gejala operasional cache stampede

Secara operasional, cache stampede sering terlihat sebagai pola berikut:

  • Cache hit ratio turun tajam dalam waktu singkat untuk key atau grup key tertentu.
  • Lonjakan query database atau request ke upstream tepat setelah TTL cache berakhir.
  • Latency p95/p99 meningkat, kadang hanya untuk endpoint tertentu, kadang menjalar ke service lain.
  • Queue backlog bertambah bila regenerasi data dipindahkan ke worker tetapi tanpa kontrol konkurensi.
  • Error sekunder seperti timeout, connection pool exhaustion, rate limit upstream, atau retry storm.

Masalah ini sering disalahartikan sebagai sekadar cache miss biasa. Padahal pembeda utamanya adalah kontensi serentak pada data yang sama.

Root cause yang paling umum

  • TTL seragam untuk banyak key sehingga kedaluwarsa bersamaan.
  • Hot key: satu key diakses jauh lebih sering daripada key lain.
  • Regenerasi mahal: query kompleks, agregasi besar, atau panggilan ke beberapa dependency.
  • Retry tanpa backoff dari aplikasi atau load balancer.
  • Regenerasi dilakukan inline oleh setiap request tanpa koordinasi.
  • Invalidasi massal setelah deploy, migrasi data, atau perubahan konfigurasi.

Inti masalahnya bukan cache miss itu sendiri, melainkan banyak eksekusi mahal yang berjalan untuk payload yang identik.

Pola mitigasi: request, cache, worker queue, dan distributed lock

Pendekatan yang paling aman biasanya menggabungkan beberapa teknik, bukan mengandalkan satu mekanisme saja. Pola dasarnya:

  1. Request membaca cache.
  2. Jika data ada dan masih segar, langsung kembalikan.
  3. Jika data sudah tua tetapi masih layak pakai, kembalikan stale lalu jadwalkan refresh di background.
  4. Jika data benar-benar tidak ada, hanya satu proses yang boleh membangun ulang data.
  5. Regenerasi dilakukan oleh queue worker agar beban mahal tidak menumpuk di jalur request sinkron.
  6. Distributed lock mencegah banyak worker atau instance mengerjakan key yang sama bersamaan.

Mengapa queue worker membantu

Memindahkan regenerasi cache ke queue worker memberi beberapa keuntungan:

  • Isolasi latensi: request pengguna tidak harus menunggu komputasi berat.
  • Kontrol konkurensi: jumlah worker dapat dibatasi agar tidak membanjiri DB.
  • Retry terukur: kegagalan bisa ditangani dengan backoff, dead-letter queue, atau circuit breaker.
  • Observabilitas lebih baik: job dapat diukur, ditelusuri, dan diinspeksi terpisah dari traffic request.

Namun queue saja tidak cukup. Tanpa deduplikasi atau lock per key, sepuluh request miss bisa membuat sepuluh job identik masuk ke antrean.

Mengapa distributed lock diperlukan

Distributed lock dipakai agar hanya satu aktor yang boleh meregenerasi cache untuk key tertentu pada satu waktu. Ini penting di lingkungan multi-instance, multi-pod, atau multi-worker, di mana lock dalam memori proses lokal tidak berguna lintas node.

Lock bekerja bila memenuhi prinsip berikut:

  • Memiliki TTL/lease time agar tidak menggantung selamanya jika worker mati.
  • Menggunakan token pemilik saat melepas lock, agar proses lain tidak salah melepaskan lock yang bukan miliknya.
  • Durasi lock lebih pendek dari batas aman dan bisa diperpanjang bila memang ada mekanisme lease renewal yang andal.

Contoh alur request-cache-worker-lock

Alur berikut cukup umum untuk endpoint yang read-heavy dan mahal saat cache miss:

  1. Request masuk untuk key report:tenant123:daily.
  2. Aplikasi cek cache.
  3. Jika fresh hit, data dikembalikan.
  4. Jika stale hit, data stale dikembalikan cepat, lalu aplikasi mencoba enqueue job refresh secara deduplikatif.
  5. Jika hard miss, aplikasi mencoba mengambil lock untuk key tersebut.
  6. Jika lock berhasil, aplikasi bisa:
    • menghitung langsung jika murah dan cepat, atau
    • lebih aman: enqueue job refresh lalu menunggu singkat hasilnya, atau fallback sesuai SLA.
  7. Jika lock gagal, berarti proses lain sedang membangun data; request dapat menunggu singkat, polling cache, mengembalikan stale jika ada, atau mengembalikan respons degradasi.
// pseudo-code tingkat tinggi
function getOrRefresh(key):
    entry = cache.get(key)

    if entry exists and entry.isFresh():
        return entry.value

    if entry exists and entry.isStaleButServeable():
        enqueueRefreshIfNeeded(key)
        return entry.value

    lockToken = lock.tryAcquire("lock:" + key, ttl=30s)
    if lockToken:
        try:
            // dedupe lagi di worker jika perlu
            queue.enqueueUnique("refresh-cache", { key: key })
            result = waitForCache(key, timeout=200ms)
            if result exists:
                return result
            return fallbackResponse()
        finally:
            lock.release("lock:" + key, lockToken)
    else:
        result = waitForCache(key, timeout=200ms)
        if result exists:
            return result

        if entry exists:
            return entry.value // stale fallback

        return fallbackResponse()

Pseudo-code di atas sengaja generik. Implementasi detail bisa berbeda tergantung stack, tetapi prinsipnya sama: hindari regenerasi paralel untuk key yang sama dan pisahkan jalur pengguna dari pekerjaan mahal.

Teknik mitigasi yang sebaiknya dipakai bersama

1. TTL jitter untuk mencegah kedaluwarsa serentak

TTL jitter berarti menambahkan variasi acak pada masa berlaku cache. Tujuannya agar banyak key tidak habis bersamaan pada detik yang sama.

baseTtl = 300 // 5 menit
jitter = randomBetween(0, 60)
cache.set(key, value, ttl=baseTtl + jitter)

Ini sederhana tetapi efektif, terutama untuk data dengan pola populasi massal. Kekurangannya, jitter tidak menyelesaikan masalah hot key sendirian. Jika satu key sangat populer, ia tetap butuh lock atau stale strategy.

2. Stale-while-revalidate untuk menjaga latensi tetap stabil

Stale-while-revalidate memungkinkan sistem tetap menyajikan data yang sedikit usang selama jangka waktu tertentu sambil melakukan refresh di background. Ini sangat berguna ketika konsistensi absolut tidak diperlukan untuk setiap request.

Contoh kebijakan:

  • Fresh window: 5 menit, selalu kembalikan cache.
  • Stale window: 2 menit berikutnya, kembalikan data stale tetapi refresh di background.
  • Expired hard: setelah itu, perlu regenerasi penuh atau fallback.

Trade-off-nya jelas: latensi dan stabilitas membaik, tetapi pembaca mungkin menerima data yang sedikit lama. Untuk dashboard, katalog, agregasi statistik, atau hasil pencarian tertentu, ini sering masuk akal. Untuk saldo rekening atau status transaksi, perlu evaluasi lebih ketat.

3. Idempotency pada job refresh

Job regenerasi cache harus idempotent. Artinya, jika job yang sama diproses dua kali akibat retry, race condition, atau redelivery, hasil akhirnya tetap aman.

Praktik yang membantu:

  • Gunakan job key berdasarkan cache key.
  • Simpan hasil ke key yang sama dengan operasi overwrite atomik jika memungkinkan.
  • Hindari efek samping non-idempotent di dalam job, misalnya mencatat statistik bisnis atau mengirim event tanpa deduplikasi.
  • Pastikan worker memeriksa ulang apakah cache sudah diisi oleh proses lain sebelum menghitung ulang.
function refreshCacheJob(key):
    if cache.hasFresh(key):
        return // sudah diisi worker lain

    token = lock.tryAcquire("lock:" + key, ttl=30s)
    if not token:
        return // worker lain sedang mengerjakan

    try:
        if cache.hasFresh(key):
            return

        data = buildExpensiveData(key)
        cache.set(key, wrapWithFreshAndStaleMetadata(data), ttlWithJitter())
    finally:
        lock.release("lock:" + key, token)

Pengecekan ganda seperti di atas penting karena kondisi sistem bisa berubah antara saat job diantrikan dan saat job dieksekusi.

4. Request coalescing atau deduplikasi enqueue

Kalau sepuluh request miss memicu sepuluh enqueue identik, antrean akan terisi pekerjaan yang sebenarnya sama. Karena itu, deduplikasi job sangat berguna. Caranya bisa melalui:

  • Lock singkat khusus enqueue.
  • Set/marker dengan TTL untuk menandai refresh sedang dijadwalkan.
  • Fitur unique job jika platform antrean Anda memang menyediakannya.

Tujuannya bukan menjamin eksklusivitas sempurna, melainkan mengurangi ledakan kerja duplikat.

Kapan memakai Redis lock, DB lock, atau tanpa lock

Redis lock: tepat untuk cache-centric workload yang cepat

Redis lock sering menjadi pilihan praktis karena cepat dan dekat dengan lapisan cache. Cocok ketika:

  • Anda sudah memakai Redis sebagai cache atau koordinasi ringan.
  • Durasi critical section relatif pendek.
  • Anda butuh lock lintas instance dengan overhead rendah.

Hal yang perlu diperhatikan:

  • Gunakan nilai token unik saat acquire dan verifikasi saat release.
  • Pastikan TTL lock tidak terlalu pendek sehingga lock kedaluwarsa saat kerja belum selesai, tetapi juga tidak terlalu lama hingga memperlambat pemulihan jika worker mati.
  • Jangan menganggap lock terdistribusi sebagai jaminan konsistensi absolut untuk semua kasus bisnis. Untuk proteksi cache regeneration, biasanya cukup; untuk transaksi bisnis kritis, evaluasinya berbeda.

DB lock: tepat jika sumber kebenaran dan koordinasi sudah ada di database

DB lock bisa tepat ketika koordinasi memang harus dekat dengan data transaksional, atau ketika Anda belum memiliki infrastruktur cache/lock terpisah. Bentuknya bisa berupa row-level locking, advisory lock, atau tabel lease sederhana, tergantung DB yang dipakai.

Kelebihan:

  • Konsistensi lebih dekat ke sumber data.
  • Mengurangi komponen tambahan jika arsitektur sederhana.

Kekurangan:

  • Menambah beban ke database, justru pada saat database mungkin sudah jadi bottleneck.
  • Risiko lock contention lebih mahal dibanding koordinasi di Redis.
  • Lebih sensitif terhadap transaksi panjang dan deadlock.

Pilihan ini masuk akal jika volume tidak terlalu tinggi atau jika lock memang harus disejajarkan dengan operasi data yang sama.

Tanpa lock: tepat untuk data murah, toleran duplikasi, atau precompute

Tanpa lock bukan berarti anti-pattern dalam semua kasus. Ini justru tepat bila:

  • Biaya regenerasi sangat murah.
  • Duplikasi kerja sesekali tidak berdampak nyata.
  • Data diprecompute periodik, bukan on-demand.
  • Traffic rendah sehingga probabilitas stampede kecil.

Jangan menambah kompleksitas lock jika manfaatnya tidak sebanding. Lock membawa overhead operasional, kegagalan baru, dan kebutuhan tuning.

Trade-off consistency yang perlu dipahami

Tidak ada strategi tunggal yang selalu optimal. Anda perlu memilih berdasarkan kebutuhan produk dan karakteristik beban.

Strong-ish freshness vs availability

  • Lebih segar: request menunggu regenerasi, risiko latensi naik dan herd membesar jika koordinasi buruk.
  • Lebih tersedia: izinkan stale-while-revalidate, latensi stabil tetapi data bisa sedikit terlambat.

Central coordination vs simplicity

  • Dengan distributed lock: lebih aman terhadap stampede, tetapi ada kompleksitas token, TTL, retry, dan observabilitas.
  • Tanpa lock: implementasi lebih sederhana, tetapi rawan duplikasi kerja saat puncak traffic.

Queue isolation vs eventual completion

  • Queue worker melindungi jalur request, tetapi hasil refresh menjadi asinkron dan bisa tertunda saat antrean padat.
  • Inline rebuild memberi hasil langsung jika berhasil, tetapi risiko ledakan latensi lebih besar.

Anti-pattern yang sering menyebabkan dogpile effect

  • Semua key memakai TTL identik setelah proses warm-up massal.
  • Menghapus cache terlebih dahulu sebelum data baru siap, padahal bisa pakai update atomik atau stale window.
  • Lock tanpa TTL, menyebabkan lock yatim saat worker crash.
  • Melepas lock tanpa token pemilik, berisiko menghapus lock milik proses lain.
  • Retry agresif tanpa jitter/backoff untuk request dan job.
  • Menaruh pekerjaan berat di request path meskipun sudah tahu data mahal dibangun.
  • Menggunakan DB lock untuk hot path bertraffic tinggi tanpa mengukur dampak ke database.
  • Tidak membatasi konkurensi worker, sehingga worker sendiri menjadi sumber herd baru.

Metrik observabilitas yang wajib dipantau

Mitigasi cache stampede tidak bisa dianggap selesai tanpa observabilitas. Pantau minimal:

Metrik cache

  • Hit ratio per endpoint atau per namespace key.
  • Miss rate dan stale serve rate.
  • Distribusi umur item saat dibaca.
  • Jumlah regenerasi per key hot.

Metrik lock

  • Acquire success rate.
  • Acquire latency.
  • Lock contention per key.
  • Jumlah lock timeout atau lease expiration sebelum kerja selesai.

Metrik queue worker

  • Queue depth / backlog.
  • Job age dan processing latency.
  • Retry count, failure rate, dead-letter rate.
  • Concurrency aktif per tipe job refresh.

Metrik dependency

  • QPS ke database/upstream saat cache miss.
  • Latency p95/p99 dan timeout rate.
  • Connection pool saturation.

Tambahkan juga logging atau tracing dengan atribut seperti cache_key, cache_status=fresh|stale|miss, lock_acquired=true|false, dan job_deduped=true|false. Dengan begitu Anda bisa melihat apakah lonjakan terjadi karena expiry, lock contention, atau backlog queue.

Strategi debugging saat insiden terjadi

  1. Identifikasi hot key: apakah hanya beberapa key atau seluruh namespace?
  2. Cek pola expiry: apakah banyak key habis bersamaan?
  3. Lihat lock contention: lock gagal tinggi bisa berarti TTL terlalu panjang atau worker terlalu lambat.
  4. Periksa backlog queue: jika job refresh tertahan, stale window mungkin habis sebelum refresh selesai.
  5. Audit retry pada client, gateway, dan worker.
  6. Bandingkan hit ratio dengan DB QPS: lonjakan yang searah biasanya mengonfirmasi stampede.

Jika perlu mitigasi darurat, langkah yang sering aman adalah memperpanjang TTL sementara, mengaktifkan stale fallback, menurunkan concurrency job refresh, atau memanaskan ulang key panas secara terkontrol.

Checklist implementasi praktis

  • Tentukan key mana yang hot dan mahal diregenerasi.
  • Tambahkan TTL jitter pada set cache.
  • Terapkan stale-while-revalidate untuk data yang toleran stale.
  • Pindahkan refresh mahal ke queue worker.
  • Tambahkan distributed lock per cache key untuk mencegah regenerasi paralel.
  • Pastikan job refresh idempotent dan memeriksa cache ulang sebelum bekerja.
  • Deduplikasi enqueue untuk key yang sama.
  • Batasi worker concurrency agar DB/upstream tetap terlindungi.
  • Instrumentasikan metrik cache, lock, queue, dan dependency.
  • Uji skenario expiry serentak, worker crash, lock timeout, dan retry storm di staging.

Penutup

Mencegah cache stampede dengan queue worker dan distributed lock pada dasarnya adalah soal mengendalikan perilaku emergen. Seperti pada cellular automata, interaksi kecil di level lokal dapat memunculkan lonjakan global jika tidak ada aturan koordinasi yang tepat. Dalam praktiknya, kombinasi TTL jitter, stale-while-revalidate, idempotent worker, dan distributed lock biasanya jauh lebih efektif daripada hanya memperbesar cache atau menambah instance aplikasi.

Pilih tingkat koordinasi sesuai risiko. Untuk hot key dan regenerasi mahal, gunakan lock dan queue. Untuk data murah atau traffic rendah, pendekatan sederhana mungkin cukup. Yang paling penting, ukur perilakunya di produksi: cache stampede jarang terlihat dari kode saja, tetapi sangat jelas dari metrik ketika sistem mulai membentuk pola herd.