← Kursa Dön
📄 Text · 25 min

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:

StratejiTTLKullanım AlanıÖrnek
Kısa TTL1-5 dkSık değişen, tutarsızlık tolere edilemezStok durumu, anlık fiyat
Orta TTL15-60 dkOrta sıklıkta değişenÜrün detayı, kullanıcı profili
Uzun TTL1-24 saatNadiren değişenKategori listesi, konfigürasyon
Çok Uzun TTLGünler/haftalarNeredeyse 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 dakika

Yö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 StratejiNeden
Ürün katalog okumaCache-Aside + Orta TTLÇok okunan, az güncellenen veri
Kullanıcı sessionWrite-ThroughHer yazma sonrası tutarlılık gerekli
Log/metric yazmaWrite-BehindYüksek yazma throughput, kayıp tolere edilir
Popüler ürün sayfasıRefresh-AheadHiçbir kullanıcı cache miss yaşamamalı
Stok durumuKısa TTL + Event invalidationGerç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=true kullanın. Doğru strateji, verinin okuma/yazma oranına ve tutarlılık gereksinimine bağlıdır.