Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi System Design — #05

Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede

Cache-aside en yaygın strateji, ama popüler bir kaydın süresi dolduğu an bütün yük veritabanına iner: gerçek Redis denemesinde 200 eşzamanlı istek 200 sorgu üretiyor, request coalescing bunu 1'e indiriyor. Toplu ısıtılan 10.000 kayıt jitter'sız aynı saniyede sona eriyor, ±%10 jitter'la en kalabalık saniye 109 kayda düşüyor; XFetch ile erken yenileme 500 yeniden hesaplamayı 1'e indiriyor ve hot key'ler Redis'in LFU sayacıyla bulunuyor.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; örnekler StackExchange.Redis 3.4.31 ile Redis 7.4 üzerinde koşturuldu.

Önce Tahmin Et

Bookstore'un kitap detay sayfası, kitap, yazar, fiyat, stok ve yorum sayısını birleştiren pahalı bir sorguyla doluyor; sorgu 50 ms sürüyor. Ekip cache-aside ile Redis'e bir cache koydu: önce Redis'e bak, yoksa veritabanından oku ve Redis'e 10 dakikalık TTL (time to live) ile yaz. Kampanya sabahı Dune'un sayfası anasayfada ve saniyede yüzlerce kez açılıyor.

Saat 10:00:00'da Dune'un cache kaydının süresi doluyor ve tam o anda aynı sayfa için 200 istek geliyor. Veritabanına kaç sorgu gider: 1 mi, 200 mü? Ve ekip kampanyadan önce 10.000 kitabı tek bir script'le cache'e "ısıtmışsa", on dakika sonra ne olur?

Cache Nerede Durur?

Cache, pahalı bir işin sonucunu daha ucuz ve daha hızlı bir yerde tekrar kullanmak için saklamak. Serinin ikinci yazısındaki latency tablosu cache'in neden işe yaradığını söylüyordu: bellekten okumak, network ve diskten mertebelerce hızlı. Bir isteğin yolunda cache'in durabileceği altı yer var:

browser        Cache-Control: max-age         the user's own device
CDN            s-maxage, purge API            edge close to the user
gateway        output cache / response cache  in front of the app
application    IMemoryCache (in-process)      each instance's own memory
distributed    Redis                          shared by all instances
database       buffer pool, plan cache        inside the engine

İsteğin kaynağa ne kadar yakın yerde karşılandığı, ne kadar ucuz olduğunu belirler. CDN'de (Content Delivery Network) karşılanan istek sunucuya hiç gelmez; uygulama belleğinde karşılanan istek network'e hiç çıkmaz. Bedeli de aynı sırayla artar: kaynağa uzak bir cache'i invalidate etmek daha zordur. Tarayıcıdaki bir kopyayı geri çağıramazsın, ancak süresinin dolmasını beklersin.

.NET tarafındaki API'ler (Application Programming Interface), yani IMemoryCache, IDistributedCache, output cache ve HybridCache, ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Caching: IMemoryCache, Redis ve Response Caching" ve ".NET 9 Yenilikleri: ASP.NET Core'da Yerleşik OpenAPI, Scalar ve HybridCache" yazılarında ve ilerideki Backend kategorisindeki Caching serisinde. Bu yazı kütüphaneden bağımsız olarak stratejilere bakıyor; kod bu yüzden doğrudan StackExchange.Redis kullanıyor.

Okuma ve Yazma Stratejileri

Cache'i veritabanıyla nasıl konuşturduğuna göre beş yaygın strateji var:

StratejiOkumaYazmaBedeli
Cache-asideUygulama cache'e bakar, yoksa veritabanından okuyup cache'e yazarUygulama veritabanına yazar, cache'teki kaydı silerİlk okuma yavaş; yarış durumunda eski değer TTL boyunca kalabilir
Read-throughUygulama yalnız cache'e sorar, cache kaçırırsa kendisi kaynaktan yüklerÇoğu zaman write-through ile birlikteYükleme mantığı cache katmanına bağlanır
Write-throughCache her zaman güncelYazma cache'e ve veritabanına birlikte, senkron giderHer yazma iki kez yavaş; hiç okunmayacak veri de cache'e girer
Write-behindCache her zaman güncelYazma önce cache'e, veritabanına sonra toplu ve asenkronCache düşerse onaylanmış yazmalar kaybolabilir
Write-aroundCache-aside gibiYazma yalnız veritabanına, cache'e dokunmazYeni yazılan veri ilk okunana kadar cache'te yok

Web uygulamalarında en yaygın olan cache-aside; Microsoft Learn'deki Cloud Design Patterns kataloğunda da bu adla anlatılıyor. Avantajı, cache'in sistemin doğruluğu için şart olmaması: Redis düşerse uygulama yavaşlar ama çalışmaya devam eder. Write-behind ise tam tersine cache'i veri yolunun bir parçası yapar; sayaçlar, görüntülenme sayısı gibi birkaç yazmanın kaybolmasının sorun olmadığı yerlerde işe yarar, sipariş ve ödeme gibi yerlerde yaramaz.

Cache-Aside'ı Kodla Görmek

Bookstore'un kitap detayı için cache-aside:

// cache-aside: önce cache, yoksa veritabanı, sonra cache'e yaz
public async Task<BookDetail> GetAsync(int id)
{
    RedisValue cached = await cache.StringGetAsync($"book:{id}");
    if (cached.HasValue)
        return JsonSerializer.Deserialize<BookDetail>(cached.ToString())!;

    BookDetail book = await db.FindAsync(id);
    await cache.StringSetAsync($"book:{id}", JsonSerializer.Serialize(book), Ttl);
    return book;
}

BookRepository.FindAsync 50 ms bekleyip bir BookDetail dönen ve kaç kez çağrıldığını sayan bir taklit. Üç ardışık okuma:

read 1: Dune, db calls so far = 1
read 2: Dune, db calls so far = 1
read 3: Dune, db calls so far = 1

İlk okuma veritabanına gitti, sonraki ikisi Redis'ten döndü. Buraya kadar her şey yolunda; sorunlar eşzamanlılıkla başlıyor.

TTL, Eviction ve Invalidation

Cache'teki bir kayıt üç yoldan çıkar. TTL dolar; bu, "en fazla şu kadar eski veri gösteririm" sözü. Bellek dolar ve eviction policy bir kaydı atar: LRU (Least Recently Used) en uzun süredir okunmayanı, LFU (Least Frequently Used) en seyrek okunanı atar. Redis'in varsayılan maxmemory-policy'si noeviction; yani maxmemory sınırına gelince kayıt atmaz, yazmaları hatayla reddeder. Cache olarak kullandığın bir Redis'te bunu allkeys-lru ya da allkeys-lfu yapmayı unutma. Üçüncü yol invalidation: veri değişince cache'teki kopyayı bilerek silmek ya da güncellemek.

Invalidation'ın en sağlam hâli "yaz, sonra sil": fiyat değişince veritabanını güncelle, ardından Redis'teki book:42'yi sil, bir sonraki okuma taze veriyi yüklesin. Silmek, güncellemekten daha güvenli; iki eşzamanlı güncelleme cache'e ters sırayla yazarsa eski fiyat cache'te kalır. Ama silmenin de bir yarış durumu var: bir okuma cache'i kaçırıp veritabanından eski fiyatı okur, tam o sırada bir yazma fiyatı güncelleyip cache'i siler, sonra okuma elindeki eski fiyatı cache'e yazar. Eski fiyat TTL dolana kadar orada kalır. Facebook'un 2013'te yayımlanan "Scaling Memcache at Facebook" makalesi bu stale set problemini lease'lerle çözüyor; pratikte çoğu sistem TTL'i bu yarışın üst sınırı olarak kabul ediyor.

Burada TTL'in iki rolü ayrışıyor: bellek yönetimi ve tutarlılık sınırı. Kitap açıklaması için 10 dakika eski veri sorun değil; fiyat için belki 1 dakika, stok için cache hiç uygun olmayabilir. Cache'in consistency'e etkisi bu: cache koyduğun her yerde "ne kadar eski veri kabul edilebilir?" sorusuna bir cevap vermiş oluyorsun. Bu sorunun teorik çerçevesi ilerideki Distributed Systems Temelleri serisinin consistency model'leri yazısında.

Cache Stampede

Açılıştaki ilk soruya dönelim. Cache boşken aynı kitap için 200 eşzamanlı istek:

stampede, plain cache-aside: 200 db calls for 200 requests

200 istek, 200 sorgu. Hepsi Redis'e baktı, hepsi kaçırdı ve hepsi aynı 50 ms'lik sorguyu veritabanına gönderdi. Buna cache stampede ya da thundering herd deniyor: popüler bir kaydın süresi dolduğu an cache'in koruduğu bütün yük veritabanına bir anda iner. Serinin ilk yazısındaki connection pool hesabıyla birleştir: 200 eşzamanlı sorgu, 100'lük bir pool'u tek başına doldurur.

İlk çare request coalescing (single-flight): aynı key için aynı anda yalnız bir yükleme yapılır, diğer istekler onun sonucunu bekler.

private readonly ConcurrentDictionary<int, Lazy<Task<BookDetail>>> _inFlight = new();

// aynı key için aynı anda yalnız bir yükleme; diğerleri onun sonucunu bekler
public async Task<BookDetail> GetCoalescedAsync(int id)
{
    RedisValue cached = await cache.StringGetAsync($"book:{id}");
    if (cached.HasValue)
        return JsonSerializer.Deserialize<BookDetail>(cached.ToString())!;

    Lazy<Task<BookDetail>> load = _inFlight.GetOrAdd(id, key => new(() => LoadAndStoreAsync(key)));
    try { return await load.Value; }
    finally { _inFlight.TryRemove(new KeyValuePair<int, Lazy<Task<BookDetail>>>(id, load)); }
}
stampede, request coalescing: 1 db calls for 200 requests

Lazy<Task>, GetOrAdd'in factory'yi birden fazla kez çalıştırabilmesine karşı bir sigorta: dictionary'ye kim girerse girsin, yalnız bir Task başlıyor. Sınırı da bilmek gerek: bu koordinasyon process içinde. Üç instance'lı Bookstore'da en kötü durumda 3 sorgu gider, 200 değil; bu çoğu zaman yeterli. Tek sorguyu bütün cluster için garanti etmek istiyorsan Redis'te kısa ömürlü bir kilit (SET key value NX PX 5000) ve kilidi alamayanların kısa bir bekleyip cache'i tekrar okuması gerekir. .NET 9 ile gelen HybridCache process içi stampede korumasını hazır veriyor.

İkinci problem, açılıştaki ikinci soru: aynı anda doldurulan kayıtların aynı anda sona ermesi. 10.000 kitabı tek script'le, biri sabit 10 dakika, diğeri ±%10 jitter'lı TTL ile yazıp Redis'ten kalan sürelerini okuyoruz:

public static TimeSpan WithJitter(TimeSpan ttl, double spread, Random rng) =>
    ttl * (1 - spread + rng.NextDouble() * 2 * spread);
plain  expiry between 599 s and 599 s, busiest second: 10000 keys
jitter expiry between 539 s and 659 s, busiest second: 109 keys

Sabit TTL'de 10.000 kaydın hepsi aynı saniyede sona eriyor: on dakika sonra, kampanyanın tam ortasında, 10.000 ayrı stampede aynı anda başlıyor. Jitter'la sona erme iki dakikaya yayılıyor ve en kalabalık saniyede 109 kayıt var. Tek satırlık bir değişiklik.

Üçüncü teknik, süre dolmadan yenilemek. Andrea Vattani, Flavio Chierichetti ve Keegan Lowenstein'ın 2015 tarihli "Optimal Probabilistic Cache Stampede Prevention" makalesindeki XFetch algoritmasında her okuma, kaydın süresi dolmadan önce küçük bir olasılıkla yeniden hesaplamayı tetikliyor; olasılık, süre dolmaya yaklaştıkça ve yeniden hesaplama ne kadar uzun sürüyorsa o kadar artıyor. Kural tek satır: now - delta * beta * ln(rand()) >= expiry ise yenile (delta hesaplamanın süresi, beta ayar katsayısı). Saniyede 1.000 istek alan ve hesaplaması 0,5 saniye süren bir kayıt için simülasyon:

// Vattani et al.: now - delta * beta * ln(rand) >= expiry ise süresi dolmadan yeniden hesapla
bool expired = now >= expiry;
bool refreshEarly = early && now - delta * beta * Math.Log(rng.NextDouble()) >= expiry;
if (expired || refreshEarly)
{
    recomputations++;
    refreshedAt = Math.Min(refreshedAt, now + delta);  // ilk hesaplama bitince değer yenilenir
}
plain  recomputations around expiry: 500
xfetch recomputations around expiry: 1

Düz expiry'de, yeni değer hazır olana kadar geçen yarım saniyede gelen 500 isteğin hepsi yeniden hesaplamaya girişiyor. XFetch'te süre dolmadan önce tek bir istek yenilemeyi tetikliyor ve kayıt hiç boşalmıyor. Kilit yok, koordinasyon yok; bedeli, kaydın TTL'inden biraz önce yenilenmesi.

Hot Key

Stampede bir anlık problem; hot key kalıcı bir dengesizlik. Kampanyada bütün trafiğin aynı kitaba gittiğini düşün: Redis cluster'ında key'ler slot'lara, slot'lar node'lara dağıtılır ve tek bir key her zaman tek bir node'dadır. 10 node'lu bir cluster'da Dune'un key'i hangi node'daysa, o node'un CPU'su (Central Processing Unit) ya da network'ü bütün cluster'ın tavanı olur.

Redis'i allkeys-lfu policy'siyle çalıştırınca, her key için bir erişim sıklığı sayacı tutuyor ve redis-cli --hotkeys bunu tarayabiliyor. Dune'a 50.000, 1.000 başka kitaba birer okuma gönderdikten sonraki gerçek çıktının özeti (diğer satırların hepsi 6):

-------- summary -------

Sampled 21001 keys in the keyspace!
hot key found with counter: 110	keyname: "book:42"
hot key found with counter: 6	keyname: "book:1563"
hot key found with counter: 6	keyname: "book:1870"

Sayaç logaritmik: Redis yeni bir key'i 5'ten başlatıyor ve sayaç arttıkça artış olasılığını düşürüyor. Bir kez okunan kitaplar 6'da, 50.000 kez okunan Dune 110'da. Bu yüzden sayaç bir istek sayısı değil, sıralama için bir sinyal.

Hot key'e karşı üç yaygın çare var. İlki uygulama içinde kısa ömürlü bir L1 (level 1, process içi) cache: Dune'u her instance'ın belleğinde 5 saniye tutmak, Redis'e giden okumayı instance başına saniyede bire indirir; bedeli 5 saniyelik ek eskime. İkincisi key'i çoğaltmak: book:42#0 ile book:42#7 arası sekiz kopya yazıp okumada rastgele birini seçmek, yükü sekiz slot'a dağıtır; bedeli invalidation'da sekiz key'i silmek. Üçüncüsü okuma için replica'lar. Hangisi olursa olsun önce ölçmek gerekiyor; hot key'i tahminle değil, sayaçla bulursun.

Bookstore Kitap Detay Sayfası: Çok Katmanlı Tasarım

Parçaları birleştirince kitap detay sayfası için şöyle bir tasarım çıkıyor:

layer        what                          TTL            invalidation
browser      cover image                   1 day          new file name per version (cover-v3.jpg)
CDN          cover image, static files     1 day          purge on change (rare)
CDN          GET /api/books/42 (JSON)      s-maxage=30    none, short TTL is the bound
app (L1)     hot books, in-process         5 s + jitter   none, short TTL is the bound
Redis (L2)   BookDetail                    10 min +- 10%  delete on BookPriceChanged
database     source of truth               -              -
stock        never cached on this page     -              read live at checkout

Fiyat değiştiğinde Catalog, BookPriceChanged event'ini yayınlıyor ve bir handler Redis'teki kaydı siliyor; L1 ve CDN'deki kopyalar en fazla 5 ve 30 saniye eski kalıyor. Pazarlama ekibiyle konuşulması gereken sayı bu: "fiyat değişikliği en fazla 30 saniyede herkese yansır". Stok bu sayfada hiç cache'lenmiyor, çünkü yanlış stok bilgisi bir siparişe dönüşebilir; checkout her zaman canlı okuyor. Bookstore'un 200 istek/saniyelik zirvesinde Dune'un okumalarının neredeyse hepsi L1'de ve CDN'de karşılanıyor, Redis ve veritabanı yalnız kenardaki kitapları görüyor.

Sık Düşülen Tuzaklar

Cache'i doğruluğun parçası yapmak. Redis düştüğünde sistem yavaşlamalı, durmamalı; cache-aside'da Redis hatalarını yakalayıp veritabanına düşen bir kod yolu olmalı.

Bütün kayıtlara aynı TTL'i vermek. Toplu ısıtılan cache aynı saniyede boşalır; TTL'e jitter ekle.

Popüler kayıtlarda stampede'i hesaba katmamak. En çok okunan kaydın süresinin dolması, en pahalı an; request coalescing ya da erken yenileme kullan.

Silmek yerine güncellemek. İki eşzamanlı yazma ters sırayla cache'e ulaşırsa eski değer kalır; yazmadan sonra sil, bir sonraki okumanın yüklemesine bırak.

Redis'i noeviction ile cache olarak kullanmak. Bellek dolunca yeni kayıtlar yazılamaz ve uygulama hata almaya başlar.

Her şeyi cache'lemek. Bir kez okunan veriyi cache'e yazmak belleği doldurur ve isabet oranını düşürür; stok ve bakiye gibi değerler için yanlış veri, yavaş veriden daha pahalı.

Küçük Bir Deneme

★☆☆ Stampede örneğini 2.000 istekle çalıştır ve BookRepository.FindAsync'i 500 ms'ye çıkar. Düz cache-aside ve coalescing arasındaki fark nasıl değişiyor? Toplam süreyi de ölç.

★★☆ XFetch simülasyonunda beta'yı 0,5 ve 2 yap. Yeniden hesaplama sayısı ve ilk yenilemenin expiry'den ne kadar önce yapıldığı nasıl değişiyor? beta'yı büyütmenin bedeli ne?

★★★ GetCoalescedAsync'i birden fazla instance için çalışacak hâle getir: Redis'te SET NX PX ile kısa bir kilit al, kilidi alamayan istekler 20 ms bekleyip cache'i yeniden okusun. Kilidi alan instance sorgu sırasında çökerse ne oluyor ve PX süresini neye göre seçiyorsun?

Cache okumaları veritabanından aldı, ama yazmalar ve cache'e sığmayan okumalar hâlâ tek bir veritabanında. Bir sonraki yazıda veri katmanının kendisini ölçekliyoruz: replication, read replica'larda read-your-writes problemi, sharding ve SQL (Structured Query Language) mi NoSQL mi sorusu.