Caching Stratejileri
Caching (önbellekleme), yazılım performansının en etkili iyileştirme tekniğidir. Pahalı bir işlemin sonucunu kaydederek, aynı istek tekrarlandığında işlemi yeniden yapmadan sonucu doğrudan sunarsınız. Veritabanı sorgusu, API çağrısı, karmaşık hesaplama — bunların hepsi cache'lenebilir. Bu derste temel caching stratejilerini, TTL, eviction politikalarını ve hangi durumda hangi stratejiyi kullanmanız gerektiğini öğreneceğiz.
Neden Cache Kullanırız?
Bir e-ticaret sitesinin ana sayfasını düşünün:
Her sayfa yüklemesinde kategori listesi veritabanından çekilir
Kategori listesi nadiren değişir (günde 1-2 kez)
Günlük 1 milyon sayfa yüklemesi = 1 milyon gereksiz veritabanı sorgusu
Cache ile: İlk sorgu veritabanına gider, sonuç cache'e yazılır. Sonraki 999.999 istek cache'den milisaniyeler içinde yanıtlanır. Veritabanı yükü %99.9 azalır.
Cache'in sağladığı faydalar:
Latency azalması: Veritabanı sorgusu 5-50ms, cache okuma 0.1-1ms
Throughput artışı: DB saniyede 1000 sorgu kaldırır, cache 100.000+
Maliyet tasarrufu: Daha az DB instance, daha az CPU kullanımı
Kullanıcı deneyimi: Daha hızlı sayfa yükleme, daha az bekleme
Cache-Aside (Lazy Loading)
En yaygın stratejidir. Uygulama önce cache'e bakar; yoksa veritabanından okuyup cache'e yazar:
İstek → Cache var mı? ──YES──→ Cache'den döndür
│
NO
│
↓
Veritabanından oku → Cache'e yaz → Döndür@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
private final CacheManager cacheManager;
public Product getProduct(Long id) {
Cache cache = cacheManager.getCache("products");
// 1. Cache'e bak
Cache.ValueWrapper cached = cache.get(id);
if (cached != null) {
return (Product) cached.get();
}
// 2. Cache'de yoksa DB'den oku
Product product = repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// 3. Cache'e yaz
cache.put(id, product);
return product;
}
}Cache-Aside avantajları:
Sadece istenen veri cache'lenir (lazy — gereksiz veri yüklenmez)
Cache çökse bile sistem çalışmaya devam eder (resilient)
Cache ve DB bağımsız ölçeklenebilir
Cache-Aside dezavantajları:
İlk istek her zaman yavaş (cold start / cache miss penalty)
Cache ile DB arasında geçici tutarsızlık olabilir (eventual consistency)
Read-Through
Cache-Aside'a benzer ama fark şudur: Uygulama sadece cache ile konuşur, cache kendisi DB'ye gider:
İstek → Cache → (miss) → Cache DB'den okur → Cache'e yazar → Döndür// Read-Through genellikle cache provider seviyesinde yapılandırılır.
// Caffeine'de CacheLoader ile:
LoadingCache<Long, Product> productCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build(id -> productRepository.findById(id) // CacheLoader
.orElseThrow(() -> new ProductNotFoundException(id)));
// Kullanım — sadece cache'e sor, gerisini o halleder:
Product product = productCache.get(productId);Fark: Cache-Aside'da uygulama hem cache hem DB ile konuşur. Read-Through'da uygulama sadece cache ile konuşur, cache kendi içinde DB'ye gider.
Write-Through
Veri yazılırken hem veritabanına hem cache'e senkron olarak yazılır. Veri tutarlılığı garantidir ama yazma işlemi yavaşlar:
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
private final CacheManager cacheManager;
// Write-Through: DB + cache aynı anda güncellenir
public Product saveProduct(Product product) {
// 1. Veritabanına yaz
Product saved = repository.save(product);
// 2. Cache'i güncelle (senkron)
Cache cache = cacheManager.getCache("products");
cache.put(saved.getId(), saved);
return saved;
}
}Write-Through avantajları:
Cache her zaman güncel (strong consistency)
Read miss sonrası penalty yok (veri zaten cache'de)
Write-Through dezavantajları:
Yazma işlemi yavaşlar (DB + cache = 2 yazma)
Hiç okunmayacak veri bile cache'lenir (gereksiz bellek kullanımı)
Write-Behind (Write-Back)
Veri önce cache'e yazılır, DB'ye yazma asenkron olarak sonra yapılır. Yazma performansı çok yüksek ama veri kaybı riski vardır:
Yazma → Cache güncelle → Hemen dön
↓ (async, batch)
DB'ye toplu yaz// Write-Behind kavramsal örnek
@Service
public class OrderService {
private final Cache orderCache;
private final BlockingQueue<Order> writeQueue = new LinkedBlockingQueue<>();
public Order createOrder(Order order) {
// 1. Cache'e hemen yaz (hızlı)
orderCache.put(order.getId(), order);
// 2. Queue'ya ekle (async DB yazma için)
writeQueue.offer(order);
return order;
}
// Arka planda çalışan scheduled task
@Scheduled(fixedRate = 5000)
public void flushToDatabase() {
List<Order> batch = new ArrayList<>();
writeQueue.drainTo(batch, 100); // max 100 kayıt al
if (!batch.isEmpty()) {
orderRepository.saveAll(batch); // toplu DB yazma
}
}
}⚠️ Dikkat: Write-Behind, cache çökerse henüz DB'ye yazılmamış veri kaybına yol açar. Finansal işlemlerde kullanmayın!
Refresh-Ahead
Cache'deki verinin süresi dolmadan proaktif olarak yenilenir. Kullanıcı hiçbir zaman cache miss yaşamaz:
// Caffeine'de refreshAfterWrite ile
LoadingCache<Long, Product> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(60)) // 60 dk sonra expire
.refreshAfterWrite(Duration.ofMinutes(45)) // 45 dk'da arka planda yenile
.build(id -> productRepository.findById(id).orElseThrow());TTL (Time-To-Live) ve Eviction Politikaları
TTL, cache'deki bir entry'nin ne kadar süre yaşayacağını belirler. Süresi dolan entry otomatik olarak silinir.
TTL Stratejileri:
| Strateji | TTL | Kullanım Alanı | Örnek |
|---|---|---|---|
| Kısa TTL | 1-5 dk | Sık değişen, tutarsızlık tolere edilemez | Stok durumu, anlık fiyat |
| Orta TTL | 15-60 dk | Orta sıklıkta değişen | Ürün detayı, kullanıcı profili |
| Uzun TTL | 1-24 saat | Nadiren değişen | Kategori listesi, konfigürasyon |
| Çok Uzun TTL | Günler/haftalar | Neredeyse hiç değişmeyen | Ülke listesi, para birimleri |
Eviction Politikaları:
LRU (Least Recently Used): En son ne zaman erişildiğine bakar. En uzun süredir erişilmeyen entry çıkarılır. Genel amaçlı en iyi seçim.
LFU (Least Frequently Used): Erişim sıklığına bakar. En az erişilen entry çıkarılır. "Popüler" veriyi korumak için idealdir.
FIFO (First In First Out): İlk giren ilk çıkar. Basit ama genellikle LRU'dan kötü performans verir.
W-TinyLFU (Window TinyLFU): Caffeine'in kullandığı gelişmiş politika. LRU + LFU hibrit. Near-optimal hit rate sağlar.
// Caffeine ile W-TinyLFU otomatik kullanılır
Caffeine.newBuilder()
.maximumSize(10_000) // W-TinyLFU eviction
.expireAfterWrite(Duration.ofMinutes(30)) // TTL
.build();Cache Invalidation — En Zor Problem
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Cache invalidation (cache'i geçersiz kılma), verinin güncellendiğinde cache'deki eski kopyasının temizlenmesidir.
Yöntem 1: TTL-based (Zamana dayalı)
// En basit yöntem: TTL süresi dolunca otomatik temizlenir
// Dezavantaj: TTL süresi boyunca stale (eski) veri döner
spring.cache.redis.time-to-live=300000 // 5 dakikaYöntem 2: Event-based (Olaya dayalı)
// Veri değiştiğinde cache'i aktif olarak temizle
@Service
public class ProductService {
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return repository.save(product);
}
// Veya Spring Events ile:
@EventListener
public void onProductUpdated(ProductUpdatedEvent event) {
cacheManager.getCache("products").evict(event.getProductId());
}
}Yöntem 3: Version-based (Sürüme dayalı)
// Cache key'e version ekle — veri değişince version artar, eski cache otomatik expire olur
String cacheKey = "product:" + id + ":v" + product.getVersion();Cache Stampede (Thundering Herd) Problemi
Popüler bir cache key'in süresi dolduğunda, yüzlerce eşzamanlı istek aynı anda DB'ye gider. Bu durum DB'yi çökertebilir.
TTL doldu → 500 eşzamanlı istek → Hepsi cache miss → 500 DB sorgusu → DB çöker!Çözüm 1: Locking (Mutex)
// Spring @Cacheable(sync = true) ile sadece 1 thread DB'ye gider
@Cacheable(value = "products", key = "#id", sync = true)
public Product getProduct(Long id) {
return repository.findById(id).orElseThrow();
}Çözüm 2: Probabilistic Early Expiration
// TTL dolmadan önce rastgele bir zamanda yenile
// Her istek %5 ihtimalle cache'i yeniler (TTL'nin son %20'sinde)
public Product getProductWithEarlyRefresh(Long id) {
CachedProduct cached = cache.get(id);
if (cached != null) {
double remainingRatio = cached.getRemainingTtlRatio();
if (remainingRatio < 0.2 && Math.random() < 0.05) {
// Arka planda yenile
asyncRefresh(id);
}
return cached.getValue();
}
return loadAndCache(id);
}Strateji Seçim Rehberi
| Senaryo | Önerilen Strateji | Neden |
|---|---|---|
| Ürün katalog okuma | Cache-Aside + Orta TTL | Çok okunan, az güncellenen veri |
| Kullanıcı session | Write-Through | Her yazma sonrası tutarlılık gerekli |
| Log/metric yazma | Write-Behind | Yüksek yazma throughput, kayıp tolere edilir |
| Popüler ürün sayfası | Refresh-Ahead | Hiçbir kullanıcı cache miss yaşamamalı |
| Stok durumu | Kısa TTL + Event invalidation | Gerçek zamana yakın doğruluk gerekli |
Yaygın Hatalar
❌ Her şeyi cache'lemek: Cache de bellek tüketir. Sadece sık okunan, pahalı üretilen veriyi cache'leyin.
❌ TTL koymamak: Bellek sızıntısına yol açar. Her zaman TTL belirleyin.
❌ Cache consistency'yi göz ardı etmek: Güncelleme sonrası stale veri döner. Invalidation stratejiniz olsun.
❌ Cache key collision: Farklı veri aynı key'e yazılır. Key'leri namespace ile prefix'leyin:
"product:{id}","user:{id}".❌ Büyük objeleri cache'lemek: Serialization/deserialization maliyeti artar. Sadece gerekli alanları cache'leyin.
💡 Özet: Cache-Aside en yaygın stratejidir. Write-Through tutarlılık, Write-Behind performans sağlar. TTL ile cache süresi kontrol edilir. Cache Stampede'e karşı
sync=truekullanın. Doğru strateji, verinin okuma/yazma oranına ve tutarlılık gereksinimine bağlıdır.
AI Asistan
Sorularını yanıtlamaya hazır