Caching Stratejisi: Neyi, Hangi Katmanda, Ne Kadar Süre Cache'lemeli?
Cache kararı ölçümle başlar: Bookstore'da kategori menüsü istek başına 2 sorgu ve 19,9 ms p95 ile tek gerçek adaydı, HybridCache ile p95 3,2 ms'ye ve 4.000 sorgu 0'a indi; ucuz ve kişiye özel sepet ise cache'lenmemeli. TTL veri tazelik gereksiniminden türetilir, her katmanın invalidation sahibi baştan belirlenir.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; örnekler Entity Framework Core (EF Core) 10.0.14 (SQLite) ve Microsoft.Extensions.Caching.Hybrid 10.11.0 ile koşturuldu.
Önce Tahmin Et
Bookstore'un vitrin API'sinde (Application Programming Interface) üç endpoint var: her sayfanın üstündeki kategori menüsü, bir kategorinin kitap listesi ve giriş yapmış kullanıcının sepeti. Ekip "yavaşız, cache koyalım" diyor ve üçüne birden Redis eklemeyi planlıyor. Elinde henüz tek bir ölçüm yok.
İki soru. Birincisi: bu üç endpoint'ten hangisini cache'lemek gerçekten fark yaratır, hangisini cache'lemek yalnız risk getirir? İkincisi: menüyü HybridCache ile cache'ledik ve uygulama yeni başladı, cache boş. Aynı anda 50 bağlantıdan 2.000 istek geliyor. Veritabanına kaç SQL (Structured Query Language) komutu gider?
Bu Seri Nerede Başlıyor
Serinin konusu cache'in .NET tarafındaki uygulaması: hangi API'yi, hangi ayarla, hangi bedeli bilerek kullanacağın. Temeller başka yazılarda hazır. ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Caching: IMemoryCache, Redis ve Response Caching" yazısı absolute ve sliding expiration'ı, yazınca silmeyi ve Redis'e ilk bakışı anlattı; ".NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching" output cache'e, ".NET 9 Yenilikleri: ASP.NET Core'da Yerleşik OpenAPI, Scalar ve HybridCache" ise HybridCache'e giriş yaptı. Strateji tarafını, yani cache-aside, write-through, write-behind karşılaştırmasını, TTL (Time to Live) ve eviction ilişkisini ve stampede'in teorisini System Design serisinin "Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede" yazısı kütüphaneden bağımsız olarak anlattı. Burada onları tekrar etmeyeceğiz; bu yazı, seri boyunca kullanacağımız karar çerçevesini kuruyor.
Bir isteğin yolunda cache'in durabileceği her katmanın .NET'te bir karşılığı ve bir "invalidation sahibi" var:

Haritadaki en önemli sütun sonuncusu. Tarayıcıdaki bir kopyayı sunucudan geri çağıramazsın; ya süresi dolar ya da URL'yi (Uniform Resource Locator) değiştirirsin. CDN'deki (Content Delivery Network) kopyayı ancak CDN'in purge API'siyle silersin, bu da ilerideki DevOps kategorisindeki Domain, DNS (Domain Name System) ve CDN serisinin konusu. Output cache'i ve Redis'teki L2'yi (level 2, process dışı ortak cache) uygulaman kendisi temizler. L1 (level 1, process içi cache) ise sinsi olan: bir instance'ta sildiğin key, diğer instance'ların belleğinde yaşamaya devam eder. Bu yüzden bir veriyi hangi katmana koyacağına karar verirken önce "değiştiğinde kim silecek?" diye sor. Cevabı "kimse" ise TTL senin tutarlılık sözündür.
Önce Ölç: p95 ve Sorgu Sayısı
Cache kararı bir tahminle değil, iki sayıyla başlar: endpoint'in gecikme dağılımı (p50 ve p95) ve istek başına veritabanına giden komut sayısı. İkincisi için EF Core'un interceptor'ı yeterli:
// her SQL komutunu sayar; "önce ölç" için tek gereken şey
public sealed class QueryCounter : DbCommandInterceptor
{
private int _count;
public int Count => Volatile.Read(ref _count);
public void Reset() => Interlocked.Exchange(ref _count, 0);
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command, CommandExecutedEventData eventData, DbDataReader result,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _count);
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
QueryCounter counter = new();
builder.Services.AddSingleton(counter);
builder.Services.AddDbContext<CatalogDb>(o => o
.UseSqlite("Data Source=bookstore.db")
.AddInterceptors(counter));
Interceptor'ların ayrıntısı EF Core serisinin "EF Core Interceptor'ları ve Event'leri: Audit Field'ları ve Yavaş Sorgu Logu" yazısında. Sayacı /stats endpoint'inden okuyoruz. Yükü de küçük bir console uygulaması üretiyor: 50 eşzamanlı worker toplam 2.000 istek atıyor, her isteğin süresini Stopwatch.GetTimestamp ile ölçüyor, sonunda sıralayıp yüzdelikleri yazıyor. Veritabanında 80 kategori, 50.000 kitap ve 1.000 müşterinin sepeti var. Isınma turundan sonra cache'siz üç endpoint:
/api/categories/menu req=2000 rps= 4254 p50= 10.5ms p95= 19.9ms queries=4000
/api/books?category={cat}&page=1 req=2000 rps= 19078 p50= 2.5ms p95= 4.3ms queries=2000
/api/carts/{n} req=2000 rps= 21783 p50= 2.1ms p95= 4.1ms queries=2000
Tablo kararı neredeyse kendisi veriyor. Menü istek başına iki sorgu çalıştırıyor (kategoriler ve kategori başına kitap sayısı için bir GROUP BY), p95'i diğerlerinin beş katı ve her sayfa görüntülemesinde çağrılıyor. Kitap listesi ve sepet index'li tek sorguyla 4 ms civarında dönüyor. Gerçek üretimde bu sayıları APM (Application Performance Monitoring) aracından ya da OpenTelemetry metric'lerinden alırsın; serinin dokuzuncu yazısında cache'in kendisini de ölçeceğiz. Önemli olan sıra: önce ölç, sonra en pahalı ve en sık çağrılanı cache'le, sonra tekrar ölç.
Neyi Cache'lemeli, Neyi Asla
Bir verinin iyi bir cache adayı olup olmadığını dört soru belirliyor: Okunma sıklığı yazılma sıklığından ne kadar fazla? Üretmesi ne kadar pahalı? Kaç farklı key üretir? Eski hâlini görmek kime ne kadar zarar verir?
Kategori menüsü dört soruda da iyi cevap veriyor: her sayfada okunuyor, ayda birkaç kez değişiyor, tek bir key ve birkaç dakikalık eskiliğin kimseye zararı yok. Kitap listesi orta yerde: 72 alt kategori ve onlarca sayfa, yani binlerce key; sorgu zaten ucuz. Bu listeyi cache'lemek bellekte yer kaplar ama ölçülebilir bir kazanç getirmez; listede gerçekten yavaşlık görürsen önce sorguyu düzeltirsin (EF Core serisinin "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" yazısı), cache'i sonra düşünürsün.
Sepet ise cache'lenmemesi gerekenin ders kitabı örneği. Her müşteri için ayrı key, her "sepete ekle" tıklamasında değişen içerik ve 4 ms'lik bir sorgu. Cache'lersen her yazmada invalidation yapmak zorundasın; birini unuttuğun gün müşteri sepetinde olmayan kitabı görür. Aynı mantık bakiye, stok ve ödeme durumu için de geçerli: yanlış değerin bedeli, yavaş değerin bedelinden büyük olan her yerde cache yok. Kişiye özel ve sık değişen veri, ucuz sorgu, güçlü tutarlılık gerektiren değer: bunlardan biri varsa cache'leme kararını bir kez daha düşün.
TTL'i Tazelik Gereksiniminden Türetmek
TTL'i "10 dakika iyidir" diye seçme. Her veri için ürün sahibine şu soruyu sor: "Bu değer değiştikten sonra kullanıcı en fazla ne kadar süre eskisini görebilir?" Cevap TTL'in üst sınırıdır; invalidation ise bu süreyi kısaltan ek bir mekanizma. Bookstore için ortaya çıkan tablo:
| Veri | Kabul edilebilir eskilik | TTL | Invalidation |
|---|---|---|---|
| Kategori menüsü | Saatler; admin değiştirince hemen | 30 dk | Tag ile, admin endpoint'inde |
| Kitap detayı (başlık, açıklama) | Dakikalar | 10 dk | Yazınca sil |
| Fiyat | Kampanyada saniyeler | 30–60 sn | Fiyat değişikliği event'i |
| Bestseller listesi | Bir saat | 15 dk | Yok; arka planda 5 dk'da bir yenilenir |
| Stok, sepet, ödeme durumu | Sıfır | Cache yok | Gerekmez |
Tablonun bir yan etkisi var: ekip "fiyat en fazla 60 saniye eski görünür" cümlesini yazılı hâle getirmiş oluyor. Bu cümle bir SLO (Service Level Objective) gibi davranır; pazarlama ekibi kampanya saatini buna göre planlar, destek ekibi "fiyat neden eski" sorusunun cevabını bilir. TTL'in bir de bellek tarafı var ama o bir sonraki yazının, IMemoryCache'in SizeLimit ve eviction konusunun işi.
Pattern'lerin .NET'teki Karşılığı
Strateji isimlerinin .NET'te hangi koda dönüştüğünü bilmek, tartışmayı somut tutar. En yaygını cache-aside; HybridCache.GetOrCreateAsync bunu tek çağrıda yapıyor ve aynı key için eşzamanlı istekleri tek bir factory çağrısında birleştiriyor. Menünün cache'li hâli:
app.MapGet("/api/categories/menu-cached", (CatalogDb db, HybridCache cache, CancellationToken ct) =>
cache.GetOrCreateAsync(
"v1:category-menu",
async token => await MenuQuery.LoadAsync(db, token),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(30), LocalCacheExpiration = TimeSpan.FromMinutes(30) },
tags: ["category-menu"],
cancellationToken: ct));
Uygulamayı yeniden başlatıp boş cache'e aynı yükü verdik, sonra ısınmış hâliyle bir kez daha:
/api/categories/menu-cached req=2000 rps= 8398 p50= 4.6ms p95= 8.2ms queries=2
/api/categories/menu-cached req=2000 rps= 24814 p50= 1.9ms p95= 3.2ms queries=0
Tahminin cevabı: 2. Boş cache'e gelen 2.000 isteğin hepsi aynı key'i istedi, HybridCache factory'yi bir kez çalıştırdı ve o tek çalışma iki SQL komutu üretti. Isınmış cache'te p95 19,9 ms'den 3,2 ms'ye, sorgu sayısı 4.000'den 0'a indi. Aynı işi IMemoryCache.GetOrCreateAsync ile yapsaydın boş cache'te factory, isteği kaçıran her eşzamanlı çağrı için ayrı çalışırdı; Microsoft Learn bu iki method'un atomik olmadığını açıkça yazıyor. Neden ve nasıl düzeltileceği bir sonraki yazıda.
Refresh-ahead ise .NET'te bir BackgroundService: veri, süresi dolmadan arka planda yeniden hesaplanıp cache'e yazılır, istek hiçbir zaman factory'yi beklemez. Bestseller listesi bunun için uygun, çünkü hesaplaması pahalı ve herkes aynı listeyi görüyor:
// refresh-ahead: liste süresi dolmadan arka planda yeniden hesaplanır, istek hiç beklemez
public sealed class BestsellerRefresher(IServiceScopeFactory scopes, HybridCache cache, ILogger<BestsellerRefresher> logger)
: BackgroundService
{
private static readonly HybridCacheEntryOptions Options = new()
{
Expiration = TimeSpan.FromMinutes(15),
LocalCacheExpiration = TimeSpan.FromMinutes(15)
};
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromMinutes(5));
do
{
try
{
using IServiceScope scope = scopes.CreateScope();
CatalogDb db = scope.ServiceProvider.GetRequiredService<CatalogDb>();
List<BookSummary> top = await db.Books.AsNoTracking()
.OrderByDescending(b => b.Sold).Take(10)
.Select(b => new BookSummary(b.Id, b.Title, b.Price))
.ToListAsync(stoppingToken);
await cache.SetAsync("v1:bestsellers", top, Options, cancellationToken: stoppingToken);
logger.LogInformation("Bestsellers refreshed: {Count} books, first {Title}", top.Count, top[0].Title);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// yenileme başarısızsa eski liste 15 dakika daha servis edilir
logger.LogError(ex, "Bestseller refresh failed");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
info: BestsellerRefresher[0]
Bestsellers refreshed: 10 books, first Book 6943
Expiration'ın (15 dk) yenileme aralığının (5 dk) üç katı olması bilinçli: iki yenileme üst üste başarısız olsa bile liste servis edilmeye devam eder. DbContext scoped olduğu için singleton olan hosted service onu doğrudan inject edemez; her turda yeni bir scope açıyoruz. Üç instance'lı bir dağıtımda bu service her instance'ta çalışır ve aynı listeyi üç kez hesaplar; tek instance'ta çalıştırmak için gereken distributed lock ilerideki Background Job'lar serisinin konusu.
Write-through'ın .NET karşılığı, veritabanına yazdıktan sonra SetAsync ile yeni değeri cache'e koymak; write-behind ise önce cache'e yazıp veritabanına bir Channel ya da queue üzerinden sonra yazmak. İkisi de System Design yazısındaki bedelleri aynen taşır ve Bookstore'un hiçbir verisinde cache-aside'dan iyi bir oran vermiyor; bu seride nadiren göreceğiz.
Invalidation'ın Sahibini Belirlemek
Menüyü değiştiren tek bir yol olması, invalidation'ı basit tutmanın en ucuz yolu. Admin endpoint'i hem veritabanını güncelliyor hem de menünün tag'ini geçersiz kılıyor:
// Menüyü değiştiren tek yol bu endpoint; invalidation da burada
app.MapPut("/api/admin/categories/{id:int}", async (int id, RenameCategory body, CatalogDb db, HybridCache cache, CancellationToken ct) =>
{
int updated = await db.Categories.Where(c => c.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(c => c.Name, body.Name), ct);
if (updated == 0) return Results.NotFound();
await cache.RemoveByTagAsync("category-menu", ct);
return Results.NoContent();
});
$ curl -s localhost:17501/api/categories/menu-cached | head -c 60
[{"id":1,"name":"Root 1","bookCount":6272,"children":[{"id":
$ curl -s -X PUT localhost:17501/api/admin/categories/1 -H 'Content-Type: application/json' -d '{"name":"Fiction"}' -w "%{http_code}\n"
204
$ curl -s localhost:17501/api/categories/menu-cached | head -c 60
[{"id":1,"name":"Fiction","bookCount":6272,"children":[{"id"
Key'deki v1: ön eki de bir invalidation aracı: menünün JSON (JavaScript Object Notation) yapısı değiştiğinde yeni sürüm v2:category-menu key'ini kullanır, eski key'ler TTL ile kendiliğinden ölür ve deploy sırasında eski ile yeni kod aynı key'i farklı şekilde yorumlamaz. Bu çözümün bir sınırı var: RemoveByTagAsync o anki instance'ın L1'ini ve ortak L2'yi etkiler, ama diğer sunucuların belleğindeki kopyaya dokunmaz. Menüde bu, en fazla 30 dakikalık bir tutarsızlık demek; dördüncü yazıda bunu iki instance'la ölçüp neden kısa bir L1 süresinin tek başına yetmediğini göreceğiz.
Üretimde Dikkat
Cache'i koyduğun gün ölçümü de koy. Hit ratio, factory çağrı sayısı ve veritabanı sorgu sayısı yan yana bir dashboard'da durmalı; cache'in kaldırılması gerektiğini de bu sayılar söyler. Bir key yüzde 20 hit ratio ile çalışıyorsa bellek harcıyor, tutarlılık riski taşıyor ve neredeyse hiçbir şey kazandırmıyor.
Cache'in düşmesi uygulamayı düşürmemeli. Microsoft Learn'ün in-memory caching sayfası bunu net yazıyor: uygulama cache'teki veriye hiçbir zaman bağımlı olmamalı. Cache-aside'ın güzelliği de bu; Redis yokken yavaşlarsın ama doğru cevabı verirsin. Bunun için Redis timeout'larını kısa tutmak ve hatayı yakalayıp veritabanına düşmek gerekiyor, ayrıntısı üçüncü ve sekizinci yazılarda.
Ölçeği unutma: menü tek key, ama "kategori × sayfa × sıralama × dil" gibi bir key şeması bir anda yüz binlerce entry üretir. Key sayısını, cache'lemeye karar vermeden önce hesapla.
Sık Düşülen Tuzaklar
Ölçmeden cache'lemek. Yavaşlığın kaynağı çoğu zaman tek bir eksik index ya da N+1 sorgusudur; cache bunu gizler ve cache boşaldığı ilk anda sorun geri gelir.
Kişiye özel veriyi ortak bir key'le cache'lemek. Key'e kullanıcı kimliğini koymayı unutursan bir kullanıcının sepeti diğerine görünür; bu bir performans hatası değil, bir veri sızıntısıdır.
Her veriye aynı TTL'i vermek. TTL bir tazelik sözü; menü ile fiyatın aynı sözü olamaz.
Invalidation sahibini belirlememek. Veriyi değiştiren her yol (admin paneli, import job'ı, elle yazılan bir SQL) cache'ten habersizse yazınca silmek yetmez; ya bu yolları tek bir service'ten geçir ya da TTL'i kabul edilebilir eskiliğe göre kısalt.
Refresh-ahead'i hata yönetimi olmadan yazmak. Arka plan döngüsünde yakalanmayan bir exception BackgroundService'i durdurur ve .NET 6'dan beri varsayılan davranışla host'u da kapatır; yenileme sessizce ölmesin.
Küçük Bir Deneme
★☆☆ Ölçüm aracını 200 eşzamanlı bağlantıyla çalıştır. Cache'siz menünün p95'i ve saniyedeki istek sayısı nasıl değişiyor? Cache'li menüde değişim neden çok daha küçük?
★★☆ Kitap listesini HybridCache ile 1 dakikalık TTL ile cache'le ve aynı ölçümü tekrarla. p95'te kazanç var mı? Kaç key oluştuğunu hesapla ve bu kazancın bellek bedeline değip değmediğine karar ver.
★★★ BestsellerRefresher'ın sorgusunu her çağrıda exception fırlatacak şekilde değiştir. İlk yenilemeden sonra 15 dakika boyunca liste servis edilmeye devam ediyor mu? Log'larda ne görüyorsun ve bu hatayı bir alarma nasıl bağlardın?
Bu yazıda kararın iskeletini kurduk: önce ölç, adayı dört soruyla seç, TTL'i tazelikten türet, invalidation'ın sahibini belirle. Bir sonraki yazıda en alttaki katmana iniyoruz: IMemoryCache'in SizeLimit'i, eviction ve compaction kuralları, change token ile grup invalidation ve GetOrCreateAsync'in gizli stampede'i.