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 valPada 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:
- Worker pertama yang mendeteksi cache miss akan mencoba mengakuisisi lock di Redis menggunakan operasi atomik (
SET key uuid NX PX timeout). - Worker yang berhasil memegang lock akan mengeksekusi komputasi data dan menyimpannya kembali ke cache.
- 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)
passMitigasi Deadlock dan Edge Cases
- Lease Timeout (TTL Lock): Parameter
lock_timeout=10memastikan kunci lock di Redis akan kedaluwarsa secara otomatis melalui mekanismePXRedis. 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 mengeksekusicalculate_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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!