ASP.NET Core'da birkaç tür önbellek var ve her biri farklı bir problemi çözüyor. IMemoryCache nesneleri sürecin kendi belleğinde tutar; Redis ile IDistributedCache ise veriyi sunucular arasında paylaştırır. HybridCache bu ikisini iki seviyeli bir önbellekte birleştirir (L1 süreçte, L2 Redis'te) ve eşzamanlı isteklerin veri kaynağına stampede hâlinde yüklenmesini engeller. Output caching tüm HTTP yanıtını sunucuda saklar. Bu rehberde her katmanın ne işe yaradığını, anahtarların ve TTL'lerin nasıl seçileceğini, tag ile geçersiz kılmanın neyi yapıp neyi yapmadığını ve neleri önbelleğe almamanız gerektiğini anlatıyorum.
Önbellek katmanları ve her birinin işi
Bir araç seçmeden önce iki soruyu yanıtlayın. Uygulama tek bir sunucuda mı, yoksa bir load balancer'ın arkasında birkaç instance olarak mı çalışıyor? Ve neyi önbelleğe alıyorsunuz: kodunuzun üzerinde çalıştığı veriyi mi, yoksa istemciye dönen yanıtın tamamını mı? Aşağıdaki tablo farkları özetliyor:
| Katman | Verinin durduğu yer | Sunucular arasında paylaşım | Stampede koruması | Grup hâlinde geçersiz kılma |
|---|---|---|---|---|
| IMemoryCache | Süreç belleği, nesnenin kendisi | Hayır | Yok | Change token'lar ile |
| Redis ile IDistributedCache | Redis, byte[] olarak | Evet | Yok | Yok |
| HybridCache | L1 süreçte, L2 IDistributedCache'te | Yalnızca L2 | Evet, süreç başına | Tag'ler, mantıksal |
| Response Caching | Süreç belleği, yanıtın tamamı | Hayır | Yok | Yok |
| Output Caching | Varsayılan olarak bellek, Redis ile değiştirilebilir | Redis deposuyla | Kaynak kilitleme | Tag'ler, EvictByTagAsync ile |
Tek bir sunucuda IMemoryCache genellikle yeterlidir. Birkaç instance olduğunda her birinin kendi önbelleği olur ve sticky session olmadan bir kullanıcı iki sunucudan iki farklı yanıt alabilir; paylaşılan bir önbellek işte o noktada gerekli hâle gelir. Herkese açık bir endpoint'in yanıtının tamamını saklamak istiyorsanız doğru araç output caching'dir.
IMemoryCache ve bellek sınırı
IMemoryCache serialize edilmiş bir kopyayı değil, nesnenin kendisini saklar; bu yüzden ondan okumak neredeyse hiçbir maliyet getirmez. Bu avantaj bir tuzakla birlikte gelir: her çağıran aynı instance'ı alır ve biri önbellekteki bir listeyi değiştirirse önbellekteki değer herkes için değişmiş olur. Değişmez (immutable) tipleri önbelleğe alın ve geri aldığınız şeye salt okunurmuş gibi davranın.
İkinci konu bellek. Microsoft'un dokümantasyonuna göre ASP.NET Core bu önbelleği bellek baskısına (memory pressure) karşılık küçültmez ve SizeLimit olmadan sınırsız büyür. SizeLimit değerinin bir birimi yoktur; birimi kendiniz seçersiniz, örneğin girdi başına bir. Bir sınır ayarlandığında her girdinin bir Size değeri olmalıdır ve toplam sınırı aşacaksa yeni girdi saklanmaz. AddMemoryCache'in kaydettiği instance birkaç kütüphane tarafından paylaşılıyor olabilir ve hepsinin girdilerine Size verdiğinden emin olamazsınız; dokümantasyonun, paylaşılan önbelleğe sınır koymanın uygulamayı bozabileceği konusunda uyarmasının nedeni budur. Bunun yerine ayrı bir instance oluşturun:
using Microsoft.Extensions.Caching.Memory;
public sealed class LookupCache
{
// SizeLimit has no unit: every entry below has Size = 1, so this caps the entry count.
public MemoryCache Cache { get; } = new(new MemoryCacheOptions { SizeLimit = 5_000 });
}
// Program.cs
builder.Services.AddSingleton<LookupCache>();
// Inside a service that receives LookupCache lookup, AppDbContext db and a CancellationToken ct
var grades = await lookup.Cache.GetOrCreateAsync("lookup:grades", async entry =>
{
entry.SetSize(1);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return await db.Grades.Select(g => new GradeDto(g.Id, g.Title)).ToArrayAsync(ct);
});
Bu kod 5.000 girdiyle sınırlı ayrı bir önbellek oluşturur ve her girdiye bir Size ve mutlak bir sona erme süresi (absolute expiration) verir. Tek başına sliding expiration yeterli değildir, çünkü sürekli okunan bir anahtarın süresi hiç dolmaz. IMemoryCache'teki GetOrCreateAsync factory'nin eşzamanlı çalıştırılmasını da engellemez; dolayısıyla birkaç eşzamanlı isteğin her biri sorguyu ayrı ayrı çalıştırabilir.
IDistributedCache ile Redis ve iki seviyeli önbellek olarak HybridCache
IDistributedCache, süreç dışı önbellekler için ortak arayüzdür; Microsoft.Extensions.Caching.StackExchangeRedis paketi de Redis uygulamasını AddStackExchangeRedisCache ile kaydeder. Arayüz kasıtlı olarak basittir: string bir anahtar ve bir byte[] değer. Serialization, miss'leri ele almak ve stampede'leri önlemek tamamen size kalır. Gozine2'de Sentinel ile Redis, sınav zirvelerine yönelik altyapının bir parçasıydı ve önbellekleme yollarını iyileştirmek de benim işimin bir parçasıydı. Oradaki yığın .NET 6 idi ve HybridCache henüz yoktu; bu yüzden stampede koruması cache-aside kodunun kendisinden gelmek zorundaydı. O deseni ve Sentinel mimarisini Backend Notları 1 yazısında anlatıyorum ve burada tekrarlamayacağım.
HybridCache, Mart 2025'ten beri kararlı bir sürümü olan ve .NET 8'de de çalışan Microsoft.Extensions.Caching.Hybrid paketinde yer alır. Süreç belleğinde bir L1 katmanı vardır ve bir IDistributedCache kayıtlıysa onu L2 olarak kullanır. GetOrCreateAsync önce L1'e, sonra L2'ye bakar; yalnızca ikisi de boşsa factory'yi çağırır ve sonucu ikisine de yazar. Her anahtar için eşzamanlı çağıranlardan yalnızca biri factory'yi çalıştırır, diğerleri onun sonucunu bekler. Bu koordinasyon tek bir HybridCache instance'ıyla sınırlıdır ve hepsi aynı Redis'i kullansa bile diğer sunuculara uzanmaz.
// dotnet add package Microsoft.Extensions.Caching.Hybrid
// dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "exams:"; // key prefix for this app
});
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // overall lifetime, also sent to Redis (L2)
LocalCacheExpiration = TimeSpan.FromMinutes(1) // in-process copy (L1)
};
});
builder.Services.AddDbContextFactory<ExamDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Exams")));
// ExamCatalog.cs
public sealed class ExamCatalog(HybridCache cache, IDbContextFactory<ExamDbContext> dbFactory)
{
public ValueTask<ExamSummary?> GetSummaryAsync(int examId, CancellationToken ct) =>
cache.GetOrCreateAsync(
$"exam:{examId}:summary",
async cancel =>
{
await using var db = await dbFactory.CreateDbContextAsync(cancel);
return await db.Exams
.Where(e => e.Id == examId)
.Select(e => new ExamSummary(e.Id, e.Title, e.StartsAtUtc, e.DurationMinutes))
.FirstOrDefaultAsync(cancel);
},
tags: [$"exam:{examId}"],
cancellationToken: ct);
public ValueTask InvalidateAsync(int examId, CancellationToken ct) =>
cache.RemoveByTagAsync($"exam:{examId}", ct);
}
Connection string koddan değil, yapılandırmadan okunur. Expiration girdinin toplam ömrüdür ve Redis'e de gönderilir; LocalCacheExpiration ise süreç içi kopyanın ömrüdür. Factory kasıtlı olarak mevcut isteğin DbContext'ini kullanmaz: sonucu eşzamanlı çağıranlar arasında paylaşılır ve cancellation token'ı yalnızca bekleyen tüm çağıranlar iptal ettiğinde iptal edilir; dolayısıyla onu başlatan istek bittikten sonra da çalışmaya devam edebilir. IDbContextFactory'den alınan kısa ömürlü bir DbContext bu bağımlılığı ortadan kaldırır.
Redis tarafında, 64 bit sistemlerde varsayılan maxmemory sıfırdır; bu da bellek sınırı olmadığı anlamına gelir. Yalnızca önbellek olarak hizmet veren bir Redis instance'ı için bir sınır ve allkeys-lru gibi bir eviction politikası ayarlayın. Varsayılan politika noeviction'dır ve bu politikada bellek sınıra ulaştığında yeni veri yazan komutlar hatayla başarısız olur.
HTTP yanıtlarını önbelleğe almak: response caching mi, output caching mi?
Adları benzese de bu ikisi farklı bir mantık izler. AddResponseCaching ve UseResponseCaching ile etkinleştirilen response caching middleware'i standart HTTP önbellekleme semantiğini uygular: yalnızca Cache-Control header'ı public olan yanıtları saklar ve isteğin header'larına da uyar. Tarayıcılar yenileme sırasında genellikle Cache-Control: max-age=0 ya da no-cache gönderir ve middleware, RFC 9111'e uygun olarak önbellekteki yanıtı atlayıp isteği uygulamaya geçirir; yani son sözü istemci söyler. Tek deposu bellektir ve ne seçici geçersiz kılma ne de kilitleme vardır. MVC'deki [ResponseCache] attribute'u esasen yalnızca header'ları ayarlar; bu da onu tarayıcılarda ya da bir CDN'de önbellekleme için de kullanışlı kılar. Middleware olmadan çalışmayan tek seçeneği VaryByQueryKeys'tir.
.NET 7'den beri mevcut olan output caching, önbellekleme politikasını sunucuda tanımlar ve istemcinin istek header'ları bu politikayı aşamaz. Deposu değiştirilebilir: varsayılan olarak bellektir ve .NET 8'den beri Microsoft.AspNetCore.OutputCaching.StackExchangeRedis paketi ve AddStackExchangeRedisOutputCache metoduyla Redis'e geçebilirsiniz. Girdileri tag ile geçersiz kılınır ve stampede'leri önlemek için kaynak kilitleme kullanır. Yanıtın bir ETag'i varsa ve istemci onu If-None-Match ile geri gönderirse ya da If-Modified-Since gönderirse ve girdi hâlâ tazeyse 304 döndürür. Varsayılanları temkinlidir: yalnızca GET ve HEAD isteklerine verilen 200 yanıtları önbelleğe alınır, cookie ayarlayan ya da kimliği doğrulanmış bir isteğe verilen yanıt önbelleğe alınmaz ve varsayılan sona erme süresi 60 saniyedir.
using Microsoft.AspNetCore.OutputCaching;
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("ExamList", policy => policy
.Expire(TimeSpan.FromSeconds(30))
.SetVaryByQuery("grade")
.Tag("exams"));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache(); // after the auth middleware
app.MapGet("/api/exams", (int grade, ExamCatalog catalog, CancellationToken ct) =>
catalog.ListAsync(grade, ct))
.CacheOutput("ExamList");
app.MapPost("/api/admin/exams/{id:int}/publish",
async (int id, ExamCatalog catalog, IOutputCacheStore store, CancellationToken ct) =>
{
await catalog.PublishAsync(id, ct); // write to SQL Server first
await catalog.InvalidateAsync(id, ct); // cached data in HybridCache
await store.EvictByTagAsync("exams", ct); // cached HTTP responses
return Results.NoContent();
}).RequireAuthorization("ExamAdmin");
Bu kodda ListAsync ve PublishAsync aynı servisteki varsayımsal metotlardır. Varsayılan olarak her query parametresi dahil URL'nin tamamı anahtarın parçasıdır; SetVaryByQuery("grade") anahtarın query kısmını bu tek parametreyle sınırlar, böylece utm parametreleri gibi ilgisiz parametreler önbelleği parçalamaz. Middleware sırası da önemlidir: dokümantasyona göre UseOutputCache, authentication ve authorization'dan önce gelirse bir istek, yetkili bir kullanıcıya yönelik içerik yerine yetkisiz bir kullanıcı için önbelleğe alınmış içeriği alabilir. Bu katmanın IMemoryCache'ten ayrı kendi sınırları da vardır: OutputCacheOptions içindeki SizeLimit varsayılan olarak 100 MB'tır ve MaximumBodySize değerinden (varsayılan 64 MB) büyük bir yanıt önbelleğe alınmaz.
Anahtarlar ve TTL'ler
Bir anahtar, sonucu değiştiren her şeyi içermeli ve başka hiçbir şeyi içermemelidir. Birkaç pratik kural:
- Ham girdi değil, iç ID'ler. Anahtarları iç ID'lerden ve bilinen bir listedeki değerlerden oluşturun. Microsoft'un dokümantasyonu, keyfî kullanıcı girdisinden oluşturulan bir anahtarın hem bir güvenlik riski hem de belleği anlamsız anahtar seliyle doldurmaya açık bir kapı olduğu konusunda uyarır.
- Parçalar arasında bir ayraç. Ayraç olmadan, 123 numaralı siparişiyle 42 numaralı müşteri ile 23 numaralı siparişiyle 421 numaralı müşteri aynı anahtara düşer.
- Anahtarda bir sürüm. Bir DTO'nun şekli değişirse
v2:gibi bir önek ekleyin. Bir deploy'dan sonra eski değerler hâlâ Redis'tedir ve yeni sınıfa doğru şekilde deserialize edilemeyebilir. - Uygulama başına bir önek.
AddStackExchangeRedisCacheiçindekiInstanceNameher uygulamanın anahtarlarını ayrı tutar; böylece aynı Redis'teki iki servis birbirinin anahtarlarının üzerine yazmaz. Anahtarları kısa da tutun: HybridCache,MaximumKeyLengthdeğerinden (varsayılan 1024 karakter) uzun bir anahtarı önbelleğe almaz.
TTL'leri, her verinin ne kadar bayatlığa tahammül edebildiğine göre seçin. Dokümantasyona göre HybridCache'te değer L2'den okunduğunda LocalCacheExpiration kalan toplam ömrü (Expiration) asla aşmaz. Kural olarak L1'i daha kısa tutun; bir sonraki bölümün gösterdiği gibi geçersiz kılma diğer sunucuların L1'ine ulaşmaz, dolayısıyla L1 ömrü pratikte o sunuculardaki bayatlığın üst sınırıdır. Birçok anahtar birlikte yazılıyorsa, hepsinin süresi aynı anda dolmasın diye TTL'lerine biraz rastgele jitter ekleyin. HybridCache'teki stampede koruması da süreç başınadır: bir anahtarın süresi L2'de de dolduğunda on sunucu aynı anahtar için veritabanına on taneye kadar eşzamanlı sorgu gönderebilir.
Geçersiz kılma: tag'ler neyi yapar, neyi yapmaz
RemoveAsync bir anahtarı mevcut sunucunun L1'inden ve L2'den kaldırır. Bir grup anahtar için HybridCache'te tag'ler vardır: tag'leri GetOrCreateAsync çağrısına verirsiniz ve daha sonra RemoveByTagAsync o tag'e sahip her girdiyi geçersiz kılar. Ama bu geçersiz kılma mantıksaldır. Ne IMemoryCache'in ne de IDistributedCache'in tag kavramı olduğu için RemoveByTagAsync hiçbir şey silmez; yalnızca "bu tag'e sahip olup bu andan önce oluşturulmuş her girdi yok sayılacak" diye bir kural kaydeder. Sonraki okuma o girdiyi miss olarak ele alır ve factory'yi çağırır; eski değerler normal şekilde süreleri dolana kadar bellekte ve Redis'te kalır. RemoveByTagAsync("*") tag'siz girdiler dahil tüm HybridCache verisini geçersiz kılar, ama "exam*" gibi desenler desteklenmez.
Daha büyük sınırlama birden çok sunucuyla ortaya çıkar. Dokümantasyona göre anahtar ya da tag ile geçersiz kılma mevcut sunucuya ve L2'ye uygulanır, ama diğer sunucuların bellek içi önbellekleri değişmez. Dolayısıyla A sunucusu bir sınavı yayımlayıp önbelleğini geçersiz kılarsa, B sunucusu LocalCacheExpiration süresi dolana kadar önceki sürümü döndürmeye devam edebilir. Üç seçeneğiniz var: LocalCacheExpiration süresini kabul edebileceğiniz bayatlık düzeyine indirmek; sunucular arasında aynı olması gereken veriler için L1'i HybridCacheEntryFlags.DisableLocalCache ile kapatmak; ya da aynı API'nin başka bir uygulamasına bakmak. Microsoft'un dokümantasyonu böyle bir uygulama olarak FusionCache'i anıyor; FusionCache'in kendi dokümantasyonuna göre bir backplane yapılandırıldığında geçersiz kılma diğer sunuculara da ulaşıyor.
Output caching'de bir tag'i bir politikada ya da bir endpoint grubunda Tag ile eklersiniz ve girdileri depodan IOutputCacheStore.EvictByTagAsync ile çıkarırsınız. Ama varsayılan depo sürecin kendi belleğinde durur ve dokümantasyona göre her sunucunun kendi ayrı önbelleği vardır; dolayısıyla birkaç sunucuyla bu çağrı girdileri yalnızca geçersiz kılma isteğini alan sunucuda temizler. Paylaşılan geçersiz kılma için Redis deposunu kullanın. Dokümantasyon, tag'lerin ihtiyaç duyduğu atomik işlemlerden yoksun olduğu için IDistributedCache'in output cache deposu olarak kullanılmasını önermez.
Serialization maliyeti ve önbelleğe alınmaması gerekenler
Her süreç dışı önbellek, her yazmada serialization, her okumada deserialization ve buna ek olarak bir ağ gidiş-dönüşü demektir. HybridCache string'leri ve byte[] değerlerini kendisi ele alır, diğer tipler için System.Text.Json kullanır; AddSerializer ya da AddSerializerFactory ile protobuf gibi başka bir serializer takabilirsiniz. Dokümantasyona göre HybridCache varsayılan olarak IDistributedCache tabanlı kodun davranışını korur: her okuma deserialization içerir ve her çağıran kendi instance'ını alır; böylece paylaşılan bir nesne eşzamanlı olarak değiştirilemez. Tipiniz sealed ise ve [ImmutableObject(true)] attribute'una sahipse HybridCache instance'ları yeniden kullanabilir ve her çağrıdaki deserialization maliyetini azaltabilir. MaximumPayloadBytes değerinden (varsayılan 1 MB) büyük bir değer saklanmaz; yalnızca loglanır. Tüm navigation'larıyla EF Core entity'lerini değil, küçük DTO'ları önbelleğe alın.
Ve önbellekte yeri olmayan birkaç şey:
- Paylaşılan bir önbellekte kullanıcıya özel hassas veriler. Token'lar, parolalar, kimlik bilgileri ve bir Redis komutunun çıktısında görmek istemeyeceğiniz diğer her şey. Paylaşılan bir önbellekte, anahtarı oluştururken yapılan tek bir hata bir kullanıcının başka bir kullanıcının verisini görmesine yeter. Output caching varsayılan olarak kimliği doğrulanmış isteklere verilen yanıtları önbelleğe almaz; bu varsayılanı özel bir politikayla kaldırırsanız yanıtın gerçekten herkes için aynı olduğundan ya da anahtarın kullanıcıya göre değiştiğinden emin olun.
- Hızla değişen veriler. Veri neredeyse okunduğu sıklıkta değiştiğinde hit oranı düşüktür ve önbellek yalnızca bir bayatlık kaynağı daha ekler. Sayaçlar, canlı stok ve bir oturumda kalan süre buna örnektir.
- Verinin tek kopyası. Önbellek bir kopyadır; kaybedilmesi pahalıya patlayacak her şey önce veritabanına yazılmalıdır. Sınav sistemleri için bu durumların daha kapsamlı bir listesi Backend Notları 1'de.
Kısaca karar: tek bir sunucuda küçük ve nadiren değişen veriler, sınırı belirlenmiş bir IMemoryCache'e; sunucular arasında paylaşılan veriler, L2 olarak Redis ve kısa ömürlü bir L1 ile HybridCache'e; herkese açık HTTP yanıtları output caching'e gider; response caching ise gerçekten standart HTTP önbellekleme semantiği istediğiniz durumlar içindir.
NovaTech'te de Redis, .NET mikroservislerinin yığınında SQL Server ve EF Core'un yanında yer alıyor ve Redis'ten okuyan her serviste aynı kararlar gündeme geliyor. Minimal API'lerde output caching, 19 Ekim'de Notlar bölümünde yayımlanacak Backend Notları 3'ün de konularından biri.Minimal API'lerde output caching, Backend Notları 3'ün de konularından biri. Önbelleklemenin etkisini bir zirveden önce ölçmek için Locust ile yük testi rehberine bakın. Kendi sisteminizde önbelleklemeye ya da backend performansına bir de başka bir gözün bakmasını isterseniz Benimle çalışın sayfası nasıl yardımcı olabileceğimi anlatıyor.
Sık sorulan sorular
ASP.NET Core'da response caching ile output caching arasındaki fark nedir?
Response caching standart HTTP önbellekleme semantiğini izler: istemci Cache-Control: no-cache veya max-age=0 gönderirse yanıt yeniden üretilir ve tek deposu bellektir. .NET 7'den beri mevcut olan output caching ise önbellekleme politikasını sunucuda tanımlar, değiştirilebilir bir depoya sahiptir, girdileri tag ile geçersiz kılar ve eşzamanlı isteklerin stampede oluşturmasını önlemek için kaynak kilitleme (resource locking) kullanır.
HybridCache'teki RemoveByTagAsync veriyi önbellekten siler mi?
Hayır. Tag ile geçersiz kılma mantıksaldır: o tag'e sahip girdileri bu andan önce oluşturulmuşlarsa yok saymak için bir kural kaydeder; eski değerler normal şekilde süreleri dolana kadar bellekte ve Redis'te kalır. Dokümantasyona göre bu geçersiz kılma diğer sunucuların bellek içi önbelleğine ulaşmaz.
HybridCache .NET 8'de çalışır mı?
Evet. Microsoft.Extensions.Caching.Hybrid paketinin Mart 2025'ten beri kararlı bir sürümü var ve .NET 8'de çalışıyor; dokümantasyona göre .NET Framework 4.7.2'yi bile destekliyor.
IMemoryCache'teki SizeLimit hangi birimle ölçülür?
Bir birimi yoktur; birimi kendiniz tanımlarsınız, örneğin girdi başına bir birim. SizeLimit ayarlandığında her girdinin bir Size değeri olmalıdır; bu yüzden sınırı AddMemoryCache'in kaydettiği paylaşılan önbelleğe değil, ayrı bir MemoryCache instance'ına koyun.
HybridCache'in stampede koruması birden çok sunucuda çalışır mı?
Hayır. Koordinasyon, tüm sunucular aynı Redis'i kullansa bile yalnızca tek bir HybridCache instance'ının çağıranları arasında gerçekleşir. Dolayısıyla her sunucu anahtar başına aynı anda en fazla bir factory çalıştırır, ama birkaç sunucu aynı factory'yi aynı anda çalıştırabilir.
Mahdi Molavi