Cache stampede adalah kondisi ketika banyak request bersamaan mengalami cache miss pada key yang sama, lalu semuanya mencoba mengisi ulang data dari sumber yang lebih lambat seperti database atau API upstream. Di produksi, gejalanya biasanya jelas: lonjakan query ke database, latency spike, worker atau thread pool kewalahan, antrean makin panjang, dan kadang memicu kegagalan berantai.
Masalah ini sering muncul saat key yang populer kedaluwarsa pada waktu yang sama. Solusinya bukan sekadar memperpanjang TTL, tetapi mengatur bagaimana proses refill dilakukan. Kombinasi yang umum dipakai adalah single flight atau request coalescing, mutex per-key, TTL bertahap melalui soft TTL dan hard TTL, ditambah jitter TTL, stale-while-revalidate, warming, dan fallback saat lock gagal.
Apa itu cache stampede dan bagaimana gejalanya di produksi
Pola dasarnya sederhana. Misalkan key product:popular:123 disimpan di cache selama 60 detik. Jika ratusan request membaca key yang sama, cache akan menghemat banyak query. Namun ketika TTL habis, semua request berikutnya melihat cache miss hampir bersamaan. Tanpa koordinasi, semuanya akan memanggil database untuk data yang sama.
Dalam sistem backend terdistribusi, efeknya lebih berat karena banyak instance aplikasi dapat melakukan refill secara paralel. Gejala yang sering terlihat:
- Lonjakan request ke database atau layanan upstream pada momen tertentu.
- Latency spike pada endpoint yang tadinya stabil.
- Worker/thread pool penuh karena banyak request menunggu I/O yang sama.
- Timeout dan error rate meningkat akibat bottleneck di downstream.
- Cache miss serentak pada key populer yang kadaluwarsa pada waktu hampir bersamaan.
Jika tidak ditangani, satu key populer saja bisa memicu gangguan operasional. Ketika database mulai lambat, request refill semakin lama, lock makin lama dipegang, antrean membesar, dan kegagalan menyebar ke endpoint lain yang sebenarnya tidak terkait langsung.
Penyebab utama cache stampede
1. TTL seragam untuk banyak key
Jika banyak key dibuat bersamaan dengan TTL identik, kedaluwarsanya juga akan berdekatan. Ini menciptakan pola beban periodik yang mudah terlihat di grafik.
2. Key populer dengan rasio baca tinggi
Semakin tinggi fan-out pembaca pada satu key, semakin besar risiko stampede saat cache miss terjadi. Hal ini umum pada data katalog populer, konfigurasi global, dashboard, halaman landing, atau agregasi statistik.
3. Tidak ada koordinasi refill
Cache saja tidak cukup. Saat miss terjadi, aplikasi perlu menentukan siapa yang boleh mengambil data ke database dan siapa yang harus menunggu, menggunakan data stale, atau gagal cepat.
4. Refill lambat atau downstream tidak stabil
Walaupun hanya satu proses yang refill, waktu refill yang terlalu lama tetap berbahaya. Request lain akan menumpuk. Jika lock terlalu singkat, refill bisa dobel; jika terlalu panjang, request menunggu terlalu lama.
Mengapa single flight efektif untuk mengatasi cache stampede
Single flight atau request coalescing berarti untuk satu key yang sedang direfill, hanya satu eksekusi yang benar-benar memanggil sumber data. Request lain untuk key yang sama akan:
- menunggu hasil refill pertama, atau
- menerima data stale sementara, atau
- gagal cepat dan memakai fallback.
Pendekatan ini efektif karena menurunkan amplification factor. Tanpa single flight, 500 request miss bisa berubah menjadi 500 query database. Dengan single flight, 500 request bisa dipadatkan menjadi 1 refill aktif ditambah 499 request yang menunggu atau menerima data stale.
Diagram alur sederhana
Request masuk
|
v
Cek cache untuk key K
|
+-- Hit && belum soft-expired --> kembalikan data
|
+-- Hit tapi soft-expired ------> kembalikan stale
| dan picu refresh di background
|
+-- Miss / hard-expired
|
v
Coba ambil lock per-key
|
+-- Berhasil --> ambil data dari DB/upstream
| simpan ke cache
| lepas lock
| kembalikan data
|
+-- Gagal ----> tunggu sebentar / cek ulang cache
jika ada stale, pakai stale
jika tidak ada, fallback atau fail fastTeknik inti: mutex per-key dan TTL bertahap
Mutex per-key
Mutex per-key berarti lock diterapkan pada key spesifik, bukan lock global. Ini penting agar refill untuk user:1 tidak menghambat refill product:2. Dalam sistem terdistribusi, lock biasanya ditempatkan di penyimpanan bersama seperti Redis.
Karakteristik lock yang baik:
- Scope per-key, bukan global.
- Punya TTL agar lock tidak menggantung jika proses pemegang lock mati.
- Pelepasan lock aman, idealnya hanya oleh pemilik lock.
- Waktu tunggu pendek agar request tidak menggantung terlalu lama.
Catatan: Lock terdistribusi membantu membatasi refill ganda, tetapi bukan pengganti desain timeout, retry, dan fallback. Jika database sedang bermasalah, satu refill aktif tetap bisa lambat atau gagal.
Soft TTL vs hard TTL
Hard TTL adalah batas mutlak kapan data dianggap tidak valid dan tidak boleh dipakai lagi. Soft TTL adalah batas lebih awal yang menandai data sudah saatnya disegarkan, tetapi masih boleh dilayani untuk sementara.
Contoh pola:
- Data disimpan dengan hard TTL 300 detik.
- Soft TTL ditetapkan 240 detik.
- Pada umur 0-240 detik, data dilayani normal.
- Pada umur 240-300 detik, data stale masih boleh dilayani sambil refresh berjalan.
- Setelah 300 detik, data tidak boleh dipakai lagi tanpa kebijakan fallback yang eksplisit.
Teknik ini menurunkan risiko stampede karena request tidak serentak berubah menjadi miss mutlak. Sebagian request masih bisa dilayani dari cache stale sambil hanya satu proses menyegarkan data.
Jitter TTL
Jitter TTL berarti menambahkan variasi acak kecil pada TTL. Tujuannya agar key tidak kedaluwarsa bersamaan. Misalnya TTL dasar 300 detik ditambah jitter acak 0-60 detik. Untuk key yang dibuat massal, ini sangat membantu menyebarkan beban refill.
Jitter tidak menghilangkan kebutuhan akan single flight, tetapi mengurangi kemungkinan ledakan sinkron.
Stale-while-revalidate
Stale-while-revalidate adalah strategi ketika data yang melewati soft TTL tetap dikembalikan ke klien, sementara refresh dilakukan di belakang layar. Ini sering menjadi kompromi terbaik untuk data yang boleh sedikit usang demi latensi rendah dan stabilitas sistem.
Trade-off utamanya jelas: Anda menukar sedikit staleness dengan pengurangan lonjakan beban dan latency spike.
Pseudo-code netral bahasa
function getOrLoad(key):
entry = cache.get(key)
now = currentTime()
if entry exists:
if now < entry.softExpireAt:
return entry.value
if now < entry.hardExpireAt:
if tryLock("lock:" + key, lockTtl):
spawnBackgroundRefresh(key)
return entry.value // stale-while-revalidate
// miss atau hard-expired
if tryLock("lock:" + key, lockTtl):
try:
// double check untuk menghindari refill ganda
entry = cache.get(key)
if entry exists and now < entry.hardExpireAt:
return entry.value
value = loadFromSource(key)
softTtl = baseSoftTtl + randomJitter()
hardTtl = baseHardTtl + randomJitter()
cache.set(key, {
value: value,
softExpireAt: now + softTtl,
hardExpireAt: now + hardTtl
}, ttl=hardTtl)
return value
finally:
unlock("lock:" + key)
else:
sleep(shortBackoff)
entry = cache.get(key)
if entry exists and now < entry.hardExpireAt:
return entry.value
if entry exists:
return entry.value // fallback stale jika kebijakan mengizinkan
return loadFallbackOrFail()Ada beberapa detail penting pada pseudo-code di atas:
- Double check setelah lock berhasil mencegah refill ganda jika proses lain sudah lebih dulu menyegarkan data.
- Lock TTL harus lebih besar dari durasi refill normal, tetapi tidak terlalu panjang agar lock tidak menahan terlalu lama saat proses gagal.
- Backoff singkat setelah gagal lock memberi kesempatan bagi pemegang lock untuk menyelesaikan refill.
- Fallback harus jelas: pakai stale, data default, antrekan background job, atau kembalikan error terkontrol.
Contoh implementasi dengan Redis
Redis sering dipakai karena mendukung operasi atomik untuk lock sederhana dan cocok sebagai cache bersama antar-instance. Implementasi berikut bersifat generik agar tidak bergantung pada satu framework.
Menyimpan entry dengan soft TTL dan hard TTL
// value yang disimpan di Redis bisa berupa JSON/serialized object
entry = {
"data": payload,
"soft_expire_at": 1710000240,
"hard_expire_at": 1710000300
}
SET cache:product:123 "{...entry...}" EX 300TTL Redis mengikuti hard TTL. Sementara soft_expire_at dan hard_expire_at juga disimpan di payload agar aplikasi bisa memutuskan apakah data masih fresh, stale, atau sudah tidak boleh dipakai.
Mengambil lock per-key
// token acak dipakai agar unlock hanya dilakukan oleh pemilik lock
SET lock:product:123 <token-unik> NX EX 10Arti operasi di atas:
NX: hanya set jika key lock belum ada.EX 10: lock otomatis kedaluwarsa dalam 10 detik.
Jika perintah berhasil, proses boleh refill. Jika tidak, proses lain sedang refill.
Melepas lock dengan aman
Hindari pola GET lalu DEL terpisah tanpa verifikasi token, karena lock bisa kedaluwarsa lalu diambil proses lain sebelum DEL dijalankan. Gunakan skrip atomik agar hanya pemilik lock yang bisa menghapusnya.
if redis.get(lockKey) == token then
redis.del(lockKey)
endSecara praktik, logika ini sebaiknya dijalankan atomik di sisi Redis, misalnya dengan script server-side. Intinya bukan API spesifiknya, tetapi prinsip bahwa unlock harus memverifikasi token pemilik lock.
Alur refill dengan Redis
function readWithRedis(key):
raw = redis.get("cache:" + key)
now = currentTime()
if raw exists:
entry = decode(raw)
if now < entry.soft_expire_at:
return entry.data
if now < entry.hard_expire_at:
if acquireLock(key):
triggerAsyncRefresh(key)
return entry.data
if acquireLock(key):
try:
raw = redis.get("cache:" + key)
if raw exists:
entry = decode(raw)
if now < entry.hard_expire_at:
return entry.data
data = queryDatabase(key)
softTtl = 240 + random(0, 30)
hardTtl = 300 + random(0, 30)
entry = {
data: data,
soft_expire_at: now + softTtl,
hard_expire_at: now + hardTtl
}
redis.set("cache:" + key, encode(entry), ex=hardTtl)
return data
finally:
releaseLock(key)
else:
sleep(50ms)
raw = redis.get("cache:" + key)
if raw exists:
entry = decode(raw)
if now < entry.hard_expire_at:
return entry.data
return fallback()Untuk refresh background, Anda bisa memakai worker queue. Request pengguna tetap cepat karena stale data dikembalikan, sedangkan worker mengisi ulang cache secara terkontrol.
Fallback saat lock gagal
Ketika request tidak mendapatkan lock, jangan langsung menembak database. Ini kesalahan yang paling sering membuat single flight gagal total. Pilihan fallback yang umum:
- Pakai stale data jika masih dalam batas kebijakan.
- Retry singkat dengan backoff, lalu cek cache lagi.
- Fail fast dengan respons terkontrol jika data benar-benar tidak tersedia.
- Enqueue refresh ke worker jika request sinkron tidak wajib menunggu.
- Gunakan nilai degradasi seperti daftar kosong, snapshot terakhir, atau ringkasan parsial, jika secara bisnis masih dapat diterima.
Pilihan fallback bergantung pada jenis data. Untuk harga real-time atau otorisasi, staleness mungkin tidak dapat diterima. Untuk daftar konten populer atau agregasi statistik, stale singkat sering masih layak.
Warming cache: mencegah stampede sebelum terjadi
Cache warming berarti memuat key-key penting sebelum permintaan pengguna memicunya. Strategi ini berguna untuk key yang sangat populer dan cukup dapat diprediksi.
Contoh warming yang praktis:
- Refresh berkala untuk top-N key paling sering diakses.
- Preload setelah deploy atau restart worker.
- Refresh proaktif ketika mendekati soft TTL, bukan menunggu request pengguna pertama.
Trade-off warming adalah biaya komputasi tambahan. Jika daftar key terlalu besar atau pola akses berubah cepat, warming bisa membuang sumber daya pada key yang jarang dipakai.
Observability: metrik yang wajib dipantau
Mitigasi cache stampede tidak cukup hanya di kode. Anda perlu metrik untuk memastikan strategi bekerja dan untuk membedakan masalah cache, lock, dan downstream.
Checklist observability
- Cache hit ratio: hit, miss, stale-hit, dan hard-expire.
- Lock contention: berapa sering lock gagal didapat, per key dan secara agregat.
- Refill latency: durasi fetch dari database/upstream saat mengisi cache.
- Error rate: error saat refill, timeout, dan fallback activation rate.
- Top hot keys: key yang paling sering diakses dan paling sering memicu contention.
- DB/upstream QPS: khususnya saat ada lonjakan miss.
- Queue depth jika refresh dilakukan via background worker.
Beberapa pola debugging yang berguna:
- Jika hit ratio tinggi tetapi latency tetap spike, lihat stale-hit rate dan refill latency.
- Jika lock contention tinggi, cek apakah lock TTL terlalu pendek atau refill terlalu lambat.
- Jika banyak request tetap lolos ke database, cek apakah fallback saat lock gagal diam-diam melakukan query langsung.
- Jika stampede muncul periodik, cek apakah TTL semua key terlalu seragam dan belum diberi jitter.
Anti-pattern yang umum
1. Semua request miss langsung query database
Ini akar masalah cache stampede. Tanpa single flight atau lock per-key, cache hanya menunda ledakan, bukan mencegahnya.
2. Lock global untuk semua key
Lock global memang sederhana, tetapi menurunkan paralelisme dan membuat key yang tidak terkait saling menghambat.
3. Tidak ada double check setelah lock didapat
Setelah menunggu lock, cache mungkin sudah diisi oleh proses lain. Jika Anda tetap query database, Anda membuat refill ganda yang seharusnya bisa dihindari.
4. Unlock tanpa verifikasi pemilik lock
Ini berbahaya dalam sistem terdistribusi. Proses bisa menghapus lock milik proses lain jika lock sudah berganti pemilik.
5. TTL identik untuk banyak key populer
Tanpa jitter, Anda menciptakan stampede yang terjadwal.
6. Tidak membedakan data yang boleh stale dan yang tidak
Stale-while-revalidate bagus, tetapi tidak cocok untuk semua data. Jangan gunakan pola yang sama untuk semua domain data.
7. Lock TTL terlalu pendek atau terlalu panjang
Terlalu pendek dapat memicu refill ganda karena lock kedaluwarsa sebelum proses selesai. Terlalu panjang memperpanjang waktu tunggu ketika pemegang lock macet.
Kapan memakai strategi yang mana
Pakai single flight atau mutex per-key ketika:
- Satu key dibaca sangat sering.
- Refill mahal atau lambat.
- Anda menjalankan banyak instance aplikasi.
- Ledakan query ke database harus dicegah dengan ketat.
Tambahkan soft TTL dan stale-while-revalidate ketika:
- Data boleh sedikit usang.
- Latensi stabil lebih penting daripada freshness absolut.
- Anda ingin menghindari user-facing spike saat refresh berjalan.
Tambahkan jitter TTL ketika:
- Banyak key dibuat bersamaan.
- Ada pola lonjakan periodik akibat expiry serempak.
Gunakan warming ketika:
- Ada himpunan key panas yang dapat diprediksi.
- Anda ingin mengurangi miss pada jam sibuk.
Gunakan fail fast atau fallback ketat ketika:
- Data tidak boleh stale.
- Downstream sensitif dan lebih baik menolak request daripada memperparah overload.
Trade-off dan batasan
Tidak ada satu teknik yang sempurna. Single flight mengurangi beban downstream, tetapi bisa meningkatkan waktu tunggu sebagian request. Stale-while-revalidate menjaga latensi tetap rendah, tetapi mengorbankan freshness. Warming membantu key panas, tetapi menambah biaya refresh. Lock terdistribusi berguna, tetapi implementasinya harus hati-hati agar tidak menambah failure mode baru.
Pilih kombinasi berdasarkan karakter data:
- Data kritis dan harus fresh: mutex per-key, timeout ketat, fail fast, observability kuat.
- Data populer dan toleran stale: soft TTL, hard TTL, single flight, stale-while-revalidate, jitter.
- Key panas yang bisa diprediksi: semua di atas ditambah warming.
Penutup
Untuk mengatasi cache stampede dengan single flight dan TTL bertahap, fokus utamanya adalah mengontrol siapa yang melakukan refill dan bagaimana request lain diperlakukan saat cache sedang kedaluwarsa. Praktik yang paling efektif biasanya berupa kombinasi mutex per-key, soft TTL vs hard TTL, jitter TTL, dan stale-while-revalidate, dengan fallback yang jelas ketika lock gagal.
Jika Anda baru mulai, implementasi minimum yang paling berdampak adalah:
- Tambah lock per-key untuk refill.
- Gunakan double check setelah lock berhasil.
- Tambahkan jitter pada TTL.
- Pisahkan soft TTL dan hard TTL untuk key populer.
- Pantau hit ratio, lock contention, refill latency, dan error rate.
Dengan kombinasi itu, lonjakan miss serentak tidak lagi otomatis berubah menjadi badai query ke database.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!