“Computer science’ta sadece 2 zor problem var: cache invalidation, naming things ve off-by-one errors.” Phil Karlton’ın bu şakasının ciddi yanı var. Cache invalidation gerçekten zor.
Son 10+ yılda onlarca projede cache katmanları kurdum. İyi haber: bu zor problem için pratik çözüm patern’leri var. Kötü haber: her biri trade-off’larla geliyor, sihirli çözüm yok.
İki ana zorluk
Cache invalidation’da iki temel problem var:
1. Stale data: Cache eski veriyi tutuyor, kullanıcı yanlış sonuç görüyor. “Ürün fiyatını indirdim ama kullanıcılar eski fiyatı görüyor.”
2. Cache stampede: Cache expire olduğu an 1000 request aynı anda origin’e gidiyor. Origin çöküyor.
Bu ikisi genelde farklı stratejilerle çözülüyor. İkisini birden doğru handle etmek kritik.
Stale data için stratejiler
Time-based (TTL)
En basit: cache entry’ye bir süre veriyorsun (60 saniye, 5 dakika, 1 saat). Süre dolunca invalidate oluyor.
Avantaj: basit implementation. Dezavantaj: TTL boyunca stale data gösteriliyor.
Ne zaman işe yarar: veri değişim frekansı düşük, stale data 1-2 dakika tolere edilebilir. Çoğu content site için yeterli.
Ne zaman yetmez: finansal veri, stock level, real-time metric. TTL boyunca yanlış bilgi verirsin.
Event-based invalidation
Veri değiştiğinde cache’i explicit olarak temizle:
updateProduct(productId, newData);
cache.delete(`product:${productId}`);
cache.delete(`products:list`); // Liste cache'i de invalidateAvantaj: stale data problem’i yok. Dezavantaj: karmaşıklık yüksek. Hangi cache’lerin invalidate edileceğini her güncelleme için düşünmek lazım.
Ne zaman işe yarar: kritik veri, stale tolerance sıfır. E-ticaret fiyatı, stok durumu.
Karmaşıklığı azaltmak için cache key’lerine versioning ekleyebilirsiniz. Product güncellendiğinde product:123:v456 → product:123:v457 diye artıyor. Eski key’ler kendi TTL’leriyle ölüyor, yeni request yeni key’e gidiyor.
Write-through cache
Yazma işlemi hem DB’ye hem cache’e yazıyor:
db.save(data);
cache.set(key, data);Bu sayede cache hep DB ile senkron. Ama ek complexity: cache yazma fail olursa ne olur? DB yazma fail olursa cache ne olur?
Genelde transactional olması kolay değil. “Mostly consistent” bir yaklaşım.
Write-around cache
Yazma doğrudan DB’ye gidiyor, cache atlanıyor. Okuma sırasında cache populate ediliyor.
Avantaj: ölçeklenebilir, basit. Dezavantaj: ilk okuma hep cache miss.
Ne zaman işe yarar: yazma sık ama okuma seyrek. Log data, analytics events.
Cache stampede problemi
Problem: 100K kullanıcılı bir site. /products/popular cache’e 5 dakika TTL ile konmuş. Dakika 0’da cache populate, dakika 5’te expire. Dakika 5’te 10K eşzamanlı request geliyor. Hepsi cache miss, hepsi DB’ye gidiyor. DB çöküyor.
Çözümler:
1. Probabilistic early expiration
Cache expire olmadan önce bazı request’lerin “early refresh” yapması:
function get(key) {
const entry = cache.get(key);
if (!entry) return refresh(key);
// Expire'a ne kadar yakın?
const ratio = entry.age / entry.ttl;
if (ratio > 0.9 && Math.random() < 0.1) {
// %10 ihtimalle early refresh yap
refresh(key); // async, bu request eski data dönecek
}
return entry.data;
}Bu sayede expire anında herkes birden miss almıyor. Bazı request’ler önceden refresh yapmış oluyor.
2. Lock-based refresh (single-flight)
Cache miss olduğunda, ilk request DB’ye gidiyor. Diğer istekler “fetch in progress” lock’ta bekliyor. İlk request bitince hepsi aynı sonucu alıyor:
function get(key) {
const cached = cache.get(key);
if (cached) return cached;
// Lock al
if (!locks.has(key)) {
locks.set(key, fetchFromDB(key).then(data => {
cache.set(key, data);
locks.delete(key);
return data;
}));
}
return locks.get(key);
}Redis’te bunu SETNX ile implement edebilirsiniz. Distributed sistemde çalışıyor.
3. Stale-while-revalidate
HTTP cache-control header’dan esinlendim. Cache expire olsa bile eski data’yı dön, arka planda refresh et:
function get(key) {
const entry = cache.get(key);
if (!entry) return fetchAndStore(key);
if (entry.age > entry.ttl) {
// Stale ama kullan
refresh(key); // Async refresh
return entry.data;
}
return entry.data;
}Kullanıcı stale data görse bile hiç beklemiyor. Arka planda fresh data geliyor, bir sonraki request onu alıyor.
Ne zaman kullanabilirsin: stale tolerance birkaç saniye/dakika tolere edilebilir. News feed, recommendation, trending items.
Pratik mimarı
Benim projelerde genelde şu katmanlar var:
L1: Application memory cache. Aynı process’te. Millisecond latency. TTL 10-60 saniye. Single-instance app’ler için yeter.
L2: Redis cache. Distributed. 1-5ms latency. TTL dakikalar ila saatler. Multiple app instance’larının paylaştığı cache.
L3: CDN cache (HTTP level). Saniyeler ila günler TTL. Static veya semi-static content.
L4: DB materialized view. Özellikle analytics/raporlama için. Gece refresh oluyor.
Her katman farklı TTL, farklı invalidation stratejisi. L1 short TTL + auto-refresh, L2 event-based invalidation, L3 TTL + purge API.
Cache key tasarımı
Key tasarımı invalidation’ı belirliyor:
Versioned keys: product:123:v5. Her update versiyon artıyor, eski key’ler self-destruct.
Hierarchical keys: products:cat:electronics:page:1. Bir kategori değişirse, o prefix’li tüm key’leri temizle (Redis SCAN ile).
User-scoped keys: user:456:cart. Kullanıcı bazlı cache, kullanıcı logout olduğunda toplu temizle.
Composite keys: product:123:user:456. Kişiselleştirilmiş veri. Invalidation karmaşık.
Monitoring
Cache monitor etmen gereken metrics:
- Hit rate: %80 altıysa cache strategy’de sorun var
- Miss rate per key pattern: Hangi key’ler genelde miss?
- Staleness duration: Kullanıcı ne kadar stale data gördü?
- Invalidation frequency: Çok sık invalidate ediliyorsa cache anlamsızlaşıyor
- Stampede event’leri: Aynı key için concurrent miss var mı?
Sonuç
Cache invalidation sihir değil, disiplin. Doğru TTL, doğru invalidation trigger, stampede koruma, key tasarımı. Bu 4 axisi düşünerek tasarlarsan stale data ve overload sorunlarını minimize edersin.
Karmaşık bir uygulamanın üstüne cache eklemek genelde 1-2 haftalık iş. Değeri var mı? Database load %70+ düşüyor, latency dramatic şekilde iyileşiyor. Değer kesinlikle var, ama investment yapmadan önce metrik’lerini bilmeni öneririm.