Cache stampede (dikenal juga sebagai thundering herd problem) terjadi saat kunci cache bernilai komputasi tinggi kedaluwarsa secara tiba-tiba di bawah beban trafik tinggi. Ratusan atau ribuan worker thread yang membaca cache secara serentak mendapati nilai None (cache miss), lalu bersamaan mengeksekusi kueri berat ke database untuk meregenerasi nilai tersebut. Lonjakan beban kueri paralel ini dapat melumpuhkan database.

Solusi standar industri untuk masalah ini adalah membatasi regenerasi cache hanya ke satu worker menggunakan distributed mutex lock berbasis Redis, sementara worker lain menunggu atau membaca data lama.

Akar Masalah: Mengapa Cache Miss Menyebabkan Crash

Pola lazim pengambilan data Django biasanya menggunakan alur baca-tulis standar:

val = cache.get("heavy_query_key")
if val is None:
    val = run_heavy_db_query()
    cache.set("heavy_query_key", val, timeout=3600)
return val

Pada kondisi normal, kode ini bekerja dengan baik. Namun pada 1.000 Request Per Second (RPS), jika kunci tersebut kedaluwarsa pada detik 00:00 dan run_heavy_db_query() membutuhkan waktu 500 ms, maka seluruh 500 request yang masuk dalam rentang waktu tersebut akan mengalami cache miss dan membombardir database dengan query yang identik secara paralel.

Arsitektur Solusi: Pola Mutex Lock dengan django-redis

Dengan menerapkan pola Mutex (Mutual Exclusion) terdistribusi:

  1. Worker pertama yang mendeteksi cache miss akan mencoba mengakuisisi lock di Redis menggunakan operasi atomik (SET key uuid NX PX timeout).
  2. Worker yang berhasil memegang lock akan mengeksekusi komputasi data dan menyimpannya kembali ke cache.
  3. Worker lain yang gagal memperoleh lock akan diblokir sementara (polling) hingga lock dilepas atau waktu tunggu habis, lalu mengambil nilai yang baru saja ditulis ke cache tanpa menyentuh database.

1. Konfigurasi Backend Cache

Pastikan Django dikonfigurasi menggunakan library django-redis pada settings.py:

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "IGNORE_EXCEPTIONS": False,
        }
    }
}

2. Implementasi Helper get_or_set_locked

Berikut adalah implementasi helper fungsi yang membungkus distributed lock milik django-redis dengan pengamanan Double-Check Locking dan Lease Timeout untuk mencegah deadlock jika worker mengalami crash di tengah eksekusi.

import logging
import time
from django.core.cache import cache
from redis.exceptions import LockError

logger = logging.getLogger(__name__)

def get_or_set_locked(
    key: str,
    calculate_func,
    timeout: int = 300,
    lock_timeout: int = 10,
    blocking_timeout: float = 5.0,
):
    """
    Mengambil data dari cache atau menghitung ulang secara eksklusif menggunakan distributed lock.
    
    :param key: Cache key
    :param calculate_func: Callable untuk komputasi data jika miss
    :param timeout: TTL data di cache (detik)
    :param lock_timeout: Lease time lock di Redis untuk cegah deadlock jika crash (detik)
    :param blocking_timeout: Batas waktu worker lain menunggu antrean lock (detik)
    """
    val = cache.get(key)
    if val is not None:
        return val

    lock_key = f"lock:{key}"
    client = cache.client.get_client()
    
    # Inisialisasi lock via redis-py lock implementation bawaan django-redis
    lock = client.lock(lock_key, timeout=lock_timeout, blocking_timeout=blocking_timeout)

    acquired = False
    try:
        acquired = lock.acquire(blocking=True)
        if acquired:
            # Double-check pattern: cek apakah worker sebelumnya sudah selesai mengisi cache
            val = cache.get(key)
            if val is not None:
                return val

            # Eksekusi komputasi data (hanya 1 worker)
            val = calculate_func()
            cache.set(key, val, timeout=timeout)
            return val
        else:
            # Gagal memperoleh lock dalam rentang blocking_timeout
            logger.warning(f"Gagal memperoleh lock untuk key: {key} (timeout).")
            val = cache.get(key)
            return val if val is not None else calculate_func()
    except LockError as e:
        logger.error(f"Redis LockError pada key {key}: {e}")
        return calculate_func()
    finally:
        if acquired:
            try:
                lock.release()
            except LockError:
                # Lock mungkin sudah expired otomatis (lease timeout habis)
                pass

Mitigasi Deadlock dan Edge Cases

  • Lease Timeout (TTL Lock): Parameter lock_timeout=10 memastikan kunci lock di Redis akan kedaluwarsa secara otomatis melalui mekanisme PX Redis. Jika worker mati (misalnya terkena OOM killer atau SIGKILL), lock tetap akan terbuka otomatis sehingga request lain tidak hang selamanya.
  • Double-Check Locking: Pengecekan cache.get(key) dilakukan dua kali: sebelum akuisisi lock dan tepat setelah lock diperoleh. Hal ini krusial karena worker antrean ke-2 hingga ke-N yang masuk setelah worker ke-1 selesai tidak perlu mengeksekusi calculate_func() ulang.
  • Fail-safe Execution: Jika Redis bermasalah atau lock timeout tercapai akibat kalkulasi worker pertama terlalu lama, fungsi fallback langsung mengeksekusi calculate_func() agar API tidak mengembalikan status error 500 kepada pengguna.

Verifikasi Pengujian: Runnable Lock Test

Kode uji berikut menggunakan thread pool untuk membuktikan bahwa ketika 10 request datang secara simultan pada cache kosong, fungsi komputasi berat hanya dipanggil tepat satu kali.

import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import Mock
from django.test import TestCase
from django.core.cache import cache
from myapp.utils import get_or_set_locked  # Sesuaikan path modul Anda

class CacheStampedeLockTestCase(TestCase):
    def setUp(self):
        cache.clear()

    def tearDown(self):
        cache.clear()

    def test_single_execution_under_concurrency(self):
        expensive_func = Mock()
        
        def compute_side_effect():
            time.sleep(0.2)  # Simulasi database latency
            return "query_result"
            
        expensive_func.side_effect = compute_side_effect
        cache_key = "concurrent_test_key"
        workers_count = 10

        def run_worker():
            return get_or_set_locked(
                key=cache_key,
                calculate_func=expensive_func,
                timeout=60,
                lock_timeout=5,
                blocking_timeout=2.0
            )

        # Jalankan 10 worker secara simultan
        with ThreadPoolExecutor(max_workers=workers_count) as executor:
            futures = [executor.submit(run_worker) for _ in range(workers_count)]
            results = [f.result() for f in futures]

        # Semua worker harus mendapatkan hasil yang sama
        for res in results:
            self.assertEqual(res, "query_result")

        # Fungsi komputasi hanya boleh dipanggil tepat 1 kali
        self.assertEqual(expensive_func.call_count, 1)

Kapan Menggunakan Pola Ini?

Gunakan pola mutex lock ini untuk data agregasi berat, reporting, atau kueri halaman beranda yang memiliki rasio baca sangat tinggi. Jika komputasi memakan waktu lebih dari 5 detik, pertimbangkan pola pelengkap seperti background cache warming via Celery atau penyajian stale data sambil memperbarui data secara asynchronous.