Cache stampede terjadi ketika banyak request bersamaan gagal menemukan data di cache, lalu semuanya memukul database atau upstream yang sama pada waktu yang hampir bersamaan. Di service Rust, masalah ini biasanya muncul saat item populer expired, node baru melakukan warm-up, atau lonjakan traffic membuat miss rate naik tajam.

Solusi yang efektif bukan hanya “pasang cache”, tetapi mengendalikan siapa yang boleh refresh, berapa lama data boleh dianggap cukup baik, dan bagaimana sistem bereaksi saat refresh gagal atau lambat. Untuk itu, artikel ini fokus pada tiga pendekatan utama: mutex lokal, distributed lock Redis, dan singleflight/request coalescing, lengkap dengan TTL jitter, stale-while-revalidate, timeout, retry terbatas, dan observability yang dibutuhkan di produksi.

Gejala dan penyebab cache stampede

Gejala cache stampede biasanya terlihat sebagai kombinasi dari:

  • Lonjakan query database sesaat setelah key populer expired.
  • P95/P99 latency meningkat walau hit rate cache terlihat masih lumayan.
  • Thundering herd: banyak goroutine/task/future menunggu data yang sama, lalu semuanya berebut refresh atau membaca ulang.
  • Error berantai ke database, Redis, atau upstream karena beban mendadak.
  • CPU dan koneksi pool habis akibat miss burst pada key yang sama.

Penyebab umumnya bukan satu hal, melainkan kombinasi:

  • TTL seragam untuk banyak key sehingga expired bersamaan.
  • Cache miss pada item populer tanpa request coalescing.
  • Refresh sinkron yang memblokir semua request, tanpa stale fallback.
  • Distributed system: lock lokal tidak cukup ketika ada banyak instance service.
  • Refresh tidak idempotent, sehingga retry atau concurrent refresh menghasilkan efek samping ganda.

Intinya: cache stampede bukan sekadar miss rate tinggi, tetapi miss yang terkonsentrasi pada key yang sama pada saat yang sama.

Pola mitigasi: pilih berdasarkan scope masalah

1. Mutex lokal

Mutex lokal mencegah banyak task di satu proses melakukan refresh key yang sama secara bersamaan. Pendekatan ini sederhana, overhead rendah, dan cocok jika service Anda hanya satu instance atau efek stampede paling sering terjadi di dalam satu pod/process.

Kelebihan:

  • Paling sederhana diimplementasikan.
  • Tidak butuh komponen eksternal.
  • Latensi rendah karena semua sinkronisasi in-process.

Keterbatasan:

  • Tidak melindungi antar-instance.
  • Jika ada 20 pod, Anda tetap bisa mendapat 20 refresh paralel untuk key yang sama.
  • Jika lock terlalu kasar, throughput turun.

2. Singleflight / request coalescing

Singleflight berarti hanya satu refresh yang benar-benar dijalankan untuk satu key, sementara request lain menunggu hasil yang sama. Secara praktik, ini adalah bentuk sinkronisasi yang lebih sesuai untuk cache stampede daripada mutex global, karena scope-nya per-key dan hasilnya dibagikan ke penunggu lain.

Kelebihan:

  • Sangat efektif untuk key panas.
  • Mengurangi duplikasi kerja, bukan hanya mengunci.
  • Mudah digabung dengan stale-while-revalidate.

Keterbatasan:

  • Perlu pengelolaan state inflight per-key.
  • Jika future refresh macet, penunggu bisa ikut macet tanpa timeout yang jelas.

3. Distributed lock Redis

Jika service berjalan di banyak instance, Anda perlu koordinasi lintas proses. Distributed lock Redis sering dipakai agar hanya satu instance yang melakukan refresh untuk key tertentu, sementara instance lain menunggu, mengembalikan stale data, atau gagal cepat.

Kelebihan:

  • Bekerja lintas instance.
  • Mengurangi burst ke database pada deployment skala besar.
  • Cocok untuk key yang sangat mahal di-refresh.

Keterbatasan dan risiko:

  • Lebih kompleks secara operasional.
  • Ada risiko lock orphan jika owner crash dan lock TTL terlalu panjang atau mekanisme release tidak aman.
  • Menambah dependency pada Redis availability dan latency.

Desain yang aman: gabungkan beberapa lapisan

Di produksi, pendekatan yang paling stabil biasanya bukan memilih satu teknik saja, tetapi menggabungkan:

  1. In-memory cache per-instance untuk akses cepat.
  2. Singleflight lokal per-key agar miss bersamaan dalam satu process tidak menggandakan refresh.
  3. TTL jitter agar expiration tidak seragam.
  4. Stale-while-revalidate (SWR) agar request tetap dilayani saat refresh sedang berlangsung atau upstream melambat.
  5. Distributed lock Redis hanya untuk key atau jalur yang memang butuh koordinasi lintas instance.

Urutan ini penting karena distributed lock tidak selalu perlu. Jika semua miss langsung melewati Redis lock, Anda menambah latency dan kompleksitas walau masalah sebenarnya bisa selesai dengan singleflight lokal + SWR.

Implementasi Rust: in-memory cache dengan singleflight

Berikut contoh implementasi yang realistis secara konsep menggunakan tokio, dashmap, dan state inflight per-key. Fokusnya adalah alur kontrol, bukan detail crate tertentu.

use std::{sync::Arc, time::{Duration, Instant}};
use tokio::sync::{Mutex, Notify};
use dashmap::DashMap;
use rand::{thread_rng, Rng};

#[derive(Clone, Debug)]
struct CacheEntry {
    value: Arc<str>,
    fresh_until: Instant,
    stale_until: Instant,
}

impl CacheEntry {
    fn is_fresh(&self) -> bool {
        Instant::now() < self.fresh_until
    }

    fn is_stale_but_servable(&self) -> bool {
        let now = Instant::now();
        now >= self.fresh_until && now < self.stale_until
    }
}

struct Inflight {
    notify: Arc<Notify>,
}

struct AppCache {
    data: DashMap<String, CacheEntry>,
    inflight: DashMap<String, Arc<Inflight>>,
    key_locks: DashMap<String, Arc<Mutex<()>>>,
}

impl AppCache {
    fn new() -> Self {
        Self {
            data: DashMap::new(),
            inflight: DashMap::new(),
            key_locks: DashMap::new(),
        }
    }

    fn ttl_with_jitter(base: Duration, jitter_pct: u64) -> Duration {
        let mut rng = thread_rng();
        let pct = rng.gen_range(0..=jitter_pct);
        let delta_ms = base.as_millis() as u64 * pct / 100;
        base.saturating_sub(Duration::from_millis(delta_ms))
    }

    async fn get_or_refresh<F, Fut>(
        &self,
        key: String,
        fetcher: F,
    ) -> Result<Arc<str>, String>
    where
        F: Fn() -> Fut + Send + Sync,
        Fut: std::future::Future<Output = Result<String, String>> + Send,
    {
        if let Some(entry) = self.data.get(&key) {
            if entry.is_fresh() {
                return Ok(entry.value.clone());
            }
        }

        let lock = self
            .key_locks
            .entry(key.clone())
            .or_insert_with(|| Arc::new(Mutex::new(())))
            .clone();

        let _guard = lock.lock().await;

        if let Some(entry) = self.data.get(&key) {
            if entry.is_fresh() {
                return Ok(entry.value.clone());
            }
        }

        if let Some(inflight) = self.inflight.get(&key) {
            let notify = inflight.notify.clone();
            drop(_guard);
            notify.notified().await;

            if let Some(entry) = self.data.get(&key) {
                if entry.is_fresh() || entry.is_stale_but_servable() {
                    return Ok(entry.value.clone());
                }
            }
            return Err("refresh selesai tapi cache tidak terisi".into());
        }

        let inflight = Arc::new(Inflight { notify: Arc::new(Notify::new()) });
        self.inflight.insert(key.clone(), inflight.clone());
        drop(_guard);

        let stale_value = self.data.get(&key).and_then(|e| {
            if e.is_stale_but_servable() {
                Some(e.value.clone())
            } else {
                None
            }
        });

        let refresh_result = tokio::time::timeout(Duration::from_millis(800), fetcher()).await;

        let result = match refresh_result {
            Ok(Ok(value)) => {
                let value: Arc<str> = Arc::from(value);
                let fresh_ttl = Self::ttl_with_jitter(Duration::from_secs(60), 20);
                let stale_ttl = fresh_ttl + Duration::from_secs(30);
                self.data.insert(
                    key.clone(),
                    CacheEntry {
                        value: value.clone(),
                        fresh_until: Instant::now() + fresh_ttl,
                        stale_until: Instant::now() + stale_ttl,
                    },
                );
                Ok(value)
            }
            Ok(Err(e)) => {
                if let Some(stale) = stale_value {
                    Ok(stale)
                } else {
                    Err(e)
                }
            }
            Err(_) => {
                if let Some(stale) = stale_value {
                    Ok(stale)
                } else {
                    Err("timeout saat refresh cache".into())
                }
            }
        };

        self.inflight.remove(&key);
        inflight.notify.notify_waiters();
        result
    }
}

Mengapa pola ini bekerja?

  • Fast path: jika cache fresh, langsung return tanpa lock.
  • Per-key lock: mencegah race kecil saat mengecek dan mendaftarkan inflight.
  • Inflight map: task lain untuk key yang sama tidak memanggil fetcher lagi.
  • Timeout: penunggu tidak menggantung tanpa batas jika upstream macet.
  • Stale-while-revalidate: jika refresh gagal atau timeout, request masih bisa dilayani dengan data lama dalam jendela stale.
  • TTL jitter: expiration item tidak sinkron.

Kesalahan umum pada implementasi lokal

  • Memegang mutex saat I/O. Ini membuat semua request serial dan latency meledak.
  • Tidak membersihkan state inflight saat error/panic, sehingga task lain menunggu selamanya.
  • TTL sama untuk semua key, memicu herd saat banyak key expired bersama.
  • Tidak membedakan fresh vs stale, sehingga sistem memilih antara “benar-benar baru” atau “error”, padahal data sedikit lama sering masih lebih baik.

Integrasi Redis: distributed lock yang aman

Untuk banyak instance, gunakan lock Redis hanya di jalur refresh. Pola dasarnya:

  1. Cek cache lokal/Redis.
  2. Jika miss atau stale, coba ambil lock dengan key khusus, misalnya lock:product:123.
  3. Jika lock berhasil, refresh dari database/upstream lalu isi cache.
  4. Jika lock gagal, jangan semua instance memukul database. Pilih salah satu: tunggu singkat, baca stale, atau fail fast.
  5. Release lock hanya jika Anda masih owner lock tersebut.

Acquisition lock umumnya memakai SET key value NX PX ttl_ms. Nilai lock harus berupa token unik agar pelepasan lock tidak menghapus lock milik worker lain.

use redis::AsyncCommands;
use uuid::Uuid;

async fn try_acquire_lock(
    conn: &mut redis::aio::MultiplexedConnection,
    lock_key: &str,
    ttl_ms: u64,
) -> redis::RedisResult<Option<String>> {
    let token = Uuid::new_v4().to_string();
    let ok: Option<String> = redis::cmd("SET")
        .arg(lock_key)
        .arg(&token)
        .arg("NX")
        .arg("PX")
        .arg(ttl_ms)
        .query_async(conn)
        .await?;

    if ok.is_some() {
        Ok(Some(token))
    } else {
        Ok(None)
    }
}

async fn release_lock_if_owner(
    conn: &mut redis::aio::MultiplexedConnection,
    lock_key: &str,
    token: &str,
) -> redis::RedisResult<i32> {
    let script = redis::Script::new(r#"
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        else
            return 0
        end
    "#);

    script.key(lock_key).arg(token).invoke_async(conn).await
}

Mengapa release harus cek owner?

Tanpa token unik, skenario berikut bisa terjadi:

  • Instance A mengambil lock.
  • A macet lama, TTL lock habis.
  • Instance B mengambil lock baru dan mulai refresh.
  • A bangun lagi dan memanggil DEL lock_key.
  • Lock milik B ikut terhapus, sehingga worker lain bisa masuk.

Ini adalah bentuk lock orphan / unsafe unlock yang sering diabaikan.

Pola refresh dengan Redis lock + stale fallback

async fn load_with_redis_lock<F, Fut>(
    redis_conn: &mut redis::aio::MultiplexedConnection,
    cache: &AppCache,
    key: String,
    fetcher: F,
) -> Result<Arc<str>, String>
where
    F: Fn() -> Fut + Send + Sync,
    Fut: std::future::Future<Output = Result<String, String>> + Send,
{
    if let Some(entry) = cache.data.get(&key) {
        if entry.is_fresh() || entry.is_stale_but_servable() {
            return Ok(entry.value.clone());
        }
    }

    let lock_key = format!("lock:{}", key);
    let lock_token = try_acquire_lock(redis_conn, &lock_key, 3000)
        .await
        .map_err(|e| e.to_string())?;

    if let Some(token) = lock_token {
        let result = cache.get_or_refresh(key.clone(), fetcher).await;
        let _ = release_lock_if_owner(redis_conn, &lock_key, &token).await;
        result
    } else {
        tokio::time::sleep(Duration::from_millis(50)).await;

        if let Some(entry) = cache.data.get(&key) {
            if entry.is_fresh() || entry.is_stale_but_servable() {
                return Ok(entry.value.clone());
            }
        }

        Err("refresh sedang dilakukan instance lain, data belum tersedia".into())
    }
}

Pada implementasi nyata, Anda biasanya juga menyimpan nilai cache di Redis, bukan hanya lock. Namun prinsip mitigasinya tetap sama: jangan biarkan semua instance meng-refresh key yang sama secara bersamaan.

TTL jitter, stale-while-revalidate, timeout, dan retry

TTL jitter

TTL jitter adalah pengurangan atau penambahan acak pada TTL agar item tidak expired serentak. Ini sederhana tetapi sangat efektif untuk mengurangi sinkronisasi miss.

  • Gunakan jitter per-key, misalnya 10–20% dari TTL dasar.
  • Untuk key sangat panas, jitter membantu tapi tidak menggantikan singleflight.
  • Jangan memakai jitter terlalu besar jika data punya SLA kesegaran yang ketat.

Stale-while-revalidate (SWR)

SWR berarti setelah data melewati fresh TTL, data masih boleh dilayani selama stale window sambil refresh berjalan di belakang. Ini sangat membantu menahan lonjakan latency saat upstream lambat.

Trade-off:

  • Ketersediaan naik, beban turun.
  • Namun Anda menerima kemungkinan data sedikit stale.
  • Cocok untuk data katalog, konfigurasi semi-statis, profil, atau agregasi yang tidak harus real-time per request.
  • Tidak cocok untuk data yang sangat sensitif terhadap freshness, seperti saldo final atau keputusan otorisasi yang harus mutakhir.

Timeout

Refresh cache harus punya timeout eksplisit. Tanpa ini, singleflight malah bisa mengubah satu request lambat menjadi banyak request yang ikut menunggu terlalu lama.

  • Timeout refresh sebaiknya lebih pendek dari timeout total request.
  • Jika timeout tercapai dan stale masih valid, kembalikan stale.
  • Catat timeout sebagai metrik terpisah dari error biasa.

Retry terbatas

Retry pada refresh cache harus konservatif. Retry yang agresif sering memperburuk stampede.

  • Gunakan retry terbatas, misalnya satu kali untuk error sementara.
  • Tambahkan backoff singkat dan jitter.
  • Jangan retry untuk error yang jelas permanen atau karena validasi input.
  • Jangan biarkan semua waiter menjalankan retry sendiri; retry sebaiknya tetap berada dalam jalur singleflight yang sama.

Idempotensi refresh cache

Refresh cache idealnya idempotent: menjalankan refresh dua kali tidak boleh menghasilkan efek samping ganda yang berbahaya. Ini penting karena dalam sistem terdistribusi Anda tidak pernah benar-benar bisa menjamin refresh hanya sekali secara absolut.

Contoh praktik:

  • Refresh hanya membaca dari database lalu menulis cache baru.
  • Jika ada side effect seperti publish event, pisahkan dari jalur refresh cache.
  • Gunakan nilai versi, timestamp sumber, atau checksum jika perlu menghindari overwrite data yang lebih baru.

Jika refresh melibatkan operasi tulis ke sistem lain, perlakukan itu sebagai workflow terpisah, bukan sekadar “isi cache”.

Kapan memilih mutex lokal, singleflight, atau Redis lock?

Pilih mutex lokal / singleflight lokal jika:

  • Masalah utama terjadi di dalam satu process.
  • Jumlah instance sedikit atau key panas tersebar.
  • Anda butuh solusi cepat dengan kompleksitas rendah.
  • Cache lokal adalah lapisan utama dan Redis hanya opsional.

Pilih distributed lock Redis jika:

  • Banyak instance service dapat miss key yang sama secara bersamaan.
  • Biaya refresh sangat mahal bagi database/upstream.
  • Key tertentu sangat panas dan dampak stampede lintas instance signifikan.
  • Anda siap menangani expiry lock, ownership token, timeout, dan fallback saat lock gagal.

Pilih kombinasi keduanya jika:

  • Ada skala horizontal dan traffic tinggi.
  • Anda ingin mengurangi duplicate work baik di dalam satu node maupun antar-node.
  • Anda butuh ketersediaan tinggi dengan stale fallback.

Aturan praktis: mulai dari singleflight lokal + SWR + TTL jitter. Tambahkan Redis lock hanya pada key atau jalur yang benar-benar menunjukkan stampede lintas instance.

Masalah operasional yang sering muncul

Thundering herd setelah deploy atau restart

Saat pod baru naik, cache kosong dan semua key populer mulai diminta sekaligus. Mitigasi:

  • Warm-up bertahap untuk key paling panas.
  • Batasi concurrency refresh global, bukan hanya per-key.
  • Gunakan SWR agar pod lama tetap melayani saat pod baru membangun cache.

Lock orphan

Lock orphan terjadi ketika lock tertinggal atau owner lock tidak jelas lagi. Mitigasi:

  • TTL lock harus lebih pendek dari timeout request yang masuk akal.
  • Gunakan token unik dan release-if-owner.
  • Hindari lock tanpa expiry.
  • Pertimbangkan mekanisme extend lock hanya jika benar-benar diperlukan dan Anda bisa menjaganya tetap aman.

Cache stale terlalu lama

SWR bisa menjadi jebakan jika refresh terus gagal tetapi stale terus disajikan. Mitigasi:

  • Batasi stale window secara eksplisit.
  • Beri alert jika umur data melewati ambang tertentu.
  • Expose metadata umur cache untuk debugging.

Lonjakan beban ke database

Jika cache layer gagal atau lock bermasalah, database akan menerima burst. Mitigasi:

  • Rate limit refresh global.
  • Gunakan connection pool dan timeout query yang ketat.
  • Pastikan query refresh efisien dan terindeks.
  • Siapkan circuit breaker atau degrade mode jika upstream sakit.

Checklist observability dan debugging

Tanpa metrik yang tepat, cache stampede sering terlihat seperti “database tiba-tiba lambat”, padahal akar masalahnya ada di layer cache. Minimal, ukur hal-hal berikut:

Metrik cache

  • cache_hit_total
  • cache_miss_total
  • cache_stale_served_total
  • cache_refresh_total
  • cache_refresh_error_total
  • cache_refresh_timeout_total
  • cache_key_age_seconds untuk sampling key penting

Metrik coalescing/lock

  • singleflight_waiters per key panas
  • singleflight_shared_result_total
  • redis_lock_acquire_success_total
  • redis_lock_acquire_fail_total
  • redis_lock_wait_duration
  • inflight_refresh_count

Metrik upstream

  • Latency dan error rate query database/upstream.
  • Jumlah query per endpoint atau per key class.
  • Pool saturation: koneksi aktif, antrean, timeout.

Logging dan tracing

  • Log event refresh dengan key, reason=miss|stale|force, durasi, dan hasil.
  • Tambahkan trace span untuk cache lookup, lock acquire, dan refresh fetch.
  • Sampling key panas untuk menganalisis herd tanpa membanjiri log.

Pertanyaan debugging yang praktis

  • Apakah miss terkonsentrasi pada sedikit key populer?
  • Apakah banyak key expired pada detik yang sama?
  • Apakah request menunggu lock terlalu lama atau refresh upstream yang terlalu lama?
  • Apakah stale sebenarnya tersedia tetapi kode memilih error?
  • Apakah lock Redis sering gagal karena TTL terlalu pendek atau latency Redis tinggi?

Keputusan desain yang biasanya masuk akal di produksi

Untuk sebagian besar service Rust, baseline yang aman adalah:

  1. In-memory cache per-instance untuk latency rendah.
  2. Singleflight lokal per-key untuk request coalescing.
  3. TTL jitter agar expiration tidak serempak.
  4. Stale-while-revalidate agar sistem tetap responsif saat refresh gagal/lambat.
  5. Timeout refresh yang ketat dan retry terbatas.
  6. Distributed lock Redis hanya untuk hot key atau refresh yang sangat mahal lintas instance.

Pola ini menekan thundering herd tanpa membuat arsitektur terlalu rumit sejak awal. Jika Anda langsung memakai distributed lock untuk semua key, Anda berisiko menambah bottleneck baru di Redis. Sebaliknya, jika hanya mengandalkan TTL biasa tanpa singleflight, cache akan runtuh justru saat traffic tertinggi.

Penutup

Rust cache stampede tidak selesai hanya dengan menambahkan cache layer. Yang perlu didesain adalah perilaku sistem saat cache miss terjadi secara serempak: siapa yang refresh, siapa yang menunggu, kapan stale masih boleh dipakai, dan bagaimana menghindari lock yang tidak aman.

Jika harus memilih urutan implementasi, mulai dari singleflight lokal + TTL jitter + stale-while-revalidate + timeout. Setelah itu, tambahkan distributed lock Redis secara selektif untuk koordinasi lintas instance. Dengan observability yang cukup, Anda bisa melihat apakah bottleneck sebenarnya ada di lock, cache, atau upstream—dan mencegah satu key populer menjatuhkan seluruh service.