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:
| Strateji | Okuma | Yazma | Bedeli |
|---|---|---|---|
| Cache-aside | Uygulama cache'e bakar, yoksa veritabanından okuyup cache'e yazar | Uygulama veritabanına yazar, cache'teki kaydı siler | İlk okuma yavaş; yarış durumunda eski değer TTL boyunca kalabilir |
| Read-through | Uygulama yalnız cache'e sorar, cache kaçırırsa kendisi kaynaktan yükler | Çoğu zaman write-through ile birlikte | Yükleme mantığı cache katmanına bağlanır |
| Write-through | Cache her zaman güncel | Yazma cache'e ve veritabanına birlikte, senkron gider | Her yazma iki kez yavaş; hiç okunmayacak veri de cache'e girer |
| Write-behind | Cache her zaman güncel | Yazma önce cache'e, veritabanına sonra toplu ve asenkron | Cache düşerse onaylanmış yazmalar kaybolabilir |
| Write-around | Cache-aside gibi | Yazma yalnız veritabanına, cache'e dokunmaz | Yeni 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.