IMemoryCache Derinlemesine: SizeLimit, Eviction, Change Token ve GetOrCreate'in Gizli Stampede'i
SizeLimit dolu bir IMemoryCache'te limiti aşan entry hiç cache'lenmez: 100'lük cache'e eklenen 101. kitap reddedildi, arka plandaki compaction önce Low priority'leri, sonra en eski erişilenleri atıp cache'i 85'e indirdi. GetOrCreateAsync atomik değil; boş cache'e gelen 100 eşzamanlı istek factory'yi 100 kez çalıştırdı, şeritli SemaphoreSlim ve HybridCache bunu 1'e indirdi. Expiration timer'sız çalışır, grup invalidation change token ile kurulur.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; örnekler Microsoft.Extensions.Caching.Memory 10.0.14, 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 kitap kataloğu için ayrı bir MemoryCache açtın: SizeLimit = 100, CompactionPercentage = 0.15. İçine her biri Size = 1 olan 100 kitap koydun; ilk 10'u CacheItemPriority.Low, son 5'i NeverRemove, gerisi Normal. Sonra 1'den 20'ye kadar olan kitapları okudun, yani en son kullanılanlar onlar. Şimdi 101. kitabı High priority ile ekliyorsun ve hemen ardından TryGetValue("book:101") çağırıyorsun.
İki soru. Birincisi: TryGetValue ne döner, ve 200 ms sonra cache'te kaç entry kalır? Hangi kitaplar gider? İkincisi: cache boşken aynı key için 100 eşzamanlı istek IMemoryCache.GetOrCreateAsync çağırıyor. Factory kaç kez çalışır?
Nereden Devam Ediyoruz
ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core Web API'de Caching: IMemoryCache, Redis ve Response Caching" yazısı IMemoryCache'in temelini kurdu: Set, TryGetValue, absolute ve sliding expiration, veri değişince key'i silmek. Serinin ilk yazısında da hangi verinin hangi katmana gideceğine karar verdik. Bu yazıda en alttaki katmanın, process içindeki L1 (level 1) cache'in iç işleyişine iniyoruz: bellek sınırı nasıl konur, sınır dolunca kim atılır, expiration gerçekte ne zaman çalışır, bir grup entry tek hamlede nasıl silinir ve GetOrCreateAsync neden sandığın kadar güvenli değil.
Örneklerin hepsi küçük bir console uygulamasında, Bookstore'un SQLite veritabanıyla (200 yazar, 20.000 kitap, 40 kategori) çalışıyor. Kaynak kodda okuduğumuz yerler .NET 10'un release/10.0 dalındaki MemoryCache.cs.
Kendi Cache Instance'ın: SizeLimit Neden Paylaşılmaz
SizeLimit'in bir birimi yok. Cache, bir object'in bellekte kaç byte tuttuğunu ölçemez; sen her entry'ye bir Size verirsin, cache de bunların toplamını SizeLimit ile karşılaştırır. Birim senin seçimin: entry sayısı, kitap sayısı, tahmini byte. Kural tek: limit varsa her entry Size belirtmek zorunda.
using MemoryCache cache = new(new MemoryCacheOptions { SizeLimit = 1_000 });
try
{
cache.Set("book:1", "Dune");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
cache.Set("book:1", "Dune", new MemoryCacheEntryOptions { Size = 1 });
Console.WriteLine($"with Size=1 -> cached: {cache.TryGetValue("book:1", out _)}");
InvalidOperationException: Cache entry must specify a value for Size when SizeLimit is set.
with Size=1 -> cached: True
Bu kural, AddMemoryCache() ile gelen ortak IMemoryCache'e limit koymayı tehlikeli yapıyor. O instance'ı framework'ün kendisi ve kullandığın kütüphaneler de paylaşıyor; hiçbiri senin Size sözleşmeni bilmiyor. Limiti AddMemoryCache(o => o.SizeLimit = ...) ile koyduğun gün, Size vermeden Set çağıran ilk kütüphane exception fırlatır. Microsoft Learn de aynı şeyi söylüyor: limit istiyorsan kendine ait bir cache singleton'ı oluştur. .NET 8'den beri bunun en temiz yolu keyed service:
builder.Services.AddMemoryCache(); // framework ve kütüphanelerle paylaşılan, limitsiz cache'e dokunma
builder.Services.AddKeyedSingleton<IMemoryCache>("catalog", (sp, _) => new MemoryCache(
new MemoryCacheOptions
{
SizeLimit = 50_000, // birim bizim seçimimiz: cache'teki kitap özeti sayısı
CompactionPercentage = 0.10, // limit aşılınca %10 boşalt
TrackStatistics = true
},
sp.GetRequiredService<ILoggerFactory>())); // eviction callback hataları log'a düşsün
app.MapGet("/api/categories/{id:int}/books", async (int id, int page, CatalogDb db,
[FromKeyedServices("catalog")] IMemoryCache cache, CancellationToken ct) =>
{
string key = $"v1:books:cat:{id}:page:{page}";
if (cache.TryGetValue(key, out List<BookSummary>? books)) return books;
books = await db.Books.AsNoTracking()
.Where(b => b.CategoryId == id)
.OrderBy(b => b.Id).Skip((page - 1) * 20).Take(20)
.Select(b => new BookSummary(b.Id, b.Title, b.Price, b.Author!.Name))
.ToListAsync(ct);
cache.Set(key, books, new MemoryCacheEntryOptions
{
Size = books.Count, // sayfadaki kitap sayısı kadar yer kaplar
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
return books;
});
app.MapGet("/stats/catalog-cache", ([FromKeyedServices("catalog")] IMemoryCache cache) =>
cache.GetCurrentStatistics());
Üç kategoriden ikişer sayfa isteyip birini tekrarladıktan sonra:
$ curl -s localhost:17510/stats/catalog-cache
{"currentEntryCount":6,"currentEstimatedSize":120,"totalMisses":6,"totalHits":1}
Altı sayfa, her biri 20 kitap, toplam boyut 120. Size'ı sayfadaki kitap sayısı yaptığımız için SizeLimit = 50_000 "en fazla 50.000 kitap özeti" anlamına geliyor; bunu ortalama object boyutuyla çarpıp bellek bütçesine çevirmek kolay. GetCurrentStatistics .NET 7'den beri var ve yalnız TrackStatistics = true iken dolu döner. ILoggerFactory'yi constructor'a vermenin sebebini birazdan, callback'lerde göreceğiz.
Limit Dolunca: Reddedilen Entry, Priority ve Compaction
Şimdi tahmin sorusundaki senaryo. Entry'lere bir PostEvictionCallback bağlıyoruz ki kimin, hangi sebeple gittiğini görelim:
MemoryCacheEntryOptions Options(CacheItemPriority priority) => new MemoryCacheEntryOptions
{
Size = 1,
Priority = priority
}.RegisterPostEvictionCallback((key, _, reason, _) =>
{
lock (evicted) evicted.Add($"{key}({reason})");
});
for (int i = 1; i <= 100; i++)
{
CacheItemPriority priority = i switch
{
<= 10 => CacheItemPriority.Low,
> 95 => CacheItemPriority.NeverRemove,
_ => CacheItemPriority.Normal
};
cache.Set($"book:{i}", $"Book {i}", Options(priority));
await Task.Delay(1); // gerçek hayatta entry'ler farklı anlarda oluşur
}
await Task.Delay(20);
// en son okunanlar: Low öncelikli 1-10 ve Normal 11-20
for (int i = 1; i <= 20; i++) cache.TryGetValue($"book:{i}", out _);
cache.Set("book:101", "Book 101", Options(CacheItemPriority.High));
Console.WriteLine($"book:101 cached right after Set: {cache.TryGetValue("book:101", out _)}");
book:101 cached right after Set: False
after compaction: entries=85 size=85
evicted (16): book:1(Capacity), book:2(Capacity), book:3(Capacity), book:4(Capacity), book:5(Capacity), book:6(Capacity), book:7(Capacity), book:8(Capacity), book:9(Capacity), book:10(Capacity), book:21(Capacity), book:22(Capacity), book:23(Capacity), book:24(Capacity), book:25(Capacity), book:101(Capacity)
book:102 cached: True
Tahminin cevabı: False. Limiti aşacak entry cache'e hiç girmiyor; High priority onu kurtarmıyor ve kendi callback'i Capacity sebebiyle çalışıyor. Kaynak kodda UpdateCacheSizeExceedsCapacity true dönünce entry EvictionReason.Capacity ile işaretleniyor ve arka planda bir "overcapacity compaction" thread pool'a atılıyor. Bu compaction cache'i SizeLimit × (1 − CompactionPercentage) seviyesine, yani 85'e indiriyor. Önce Low bucket'ı boşalıyor: 1–10 en son okunanlar olmasına rağmen ilk gidenler, çünkü priority LRU'dan (Least Recently Used) önce geliyor. Sonra Normal bucket'ında en eski erişime sahip olanlar: 11–20'yi yakın zamanda okuduğumuz için kaldılar, 21–25 gitti. NeverRemove olan 96–100'e dokunulmadı. 102. kitap artık sığdığı için cache'te.
Buradan iki pratik sonuç çıkıyor. Birincisi, limit dolduğunda en yeni veri ilk istekte cache'lenmez; CompactionPercentage varsayılan değeri 0,05 olduğu için büyük bir cache'te her taşmada yalnızca yüzde 5 yer açılır ve dolu bir cache'te ret sık görülür. İkincisi, Microsoft Learn compaction sırasını "expired, priority, LRU, en erken absolute expiration, en erken sliding expiration" diye listeliyor, ama .NET 10 kaynağında bucket içinde yalnızca LastAccessed'e göre sıralama var; son iki adım kodda yalnızca bir yapılacak iş notu (TODO) olarak duruyor. Elle boşaltmak istersen MemoryCache.Compact(0.25) aynı algoritmayı entry sayısı üzerinden çalıştırır.
Expiration Bir Timer Değil
İkinci sürpriz, süresi dolan entry'nin ne zaman gerçekten silindiği. İki fiyatı 1 saniyelik absolute expiration ile koyup 3 saniye cache'e hiç dokunmuyoruz:
using MemoryCache cache = new(new MemoryCacheOptions
{
ExpirationScanFrequency = TimeSpan.FromSeconds(1)
});
Stopwatch clock = Stopwatch.StartNew();
void Log(string message) => Console.WriteLine($"[{clock.Elapsed.TotalSeconds:0.0}s] {message}");
MemoryCacheEntryOptions options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromSeconds(1))
.RegisterPostEvictionCallback((key, _, reason, _) => Log($"evicted {key}: {reason}"));
cache.Set("price:42", 19.90m, options);
cache.Set("price:43", 24.50m, options);
Log("set price:42 and price:43 with 1 s absolute expiration");
await Task.Delay(3000);
Log("3 s passed without touching the cache");
cache.TryGetValue("menu", out _);
Log("read an unrelated key");
[0.0s] set price:42 and price:43 with 1 s absolute expiration
[3.0s] 3 s passed without touching the cache
[3.0s] read an unrelated key
[3.0s] evicted price:43: Expired
[3.0s] evicted price:42: Expired
İkinci saniyede hiçbir şey olmadı. MemoryCache'in arka planda çalışan bir timer'ı yok; Set, TryGetValue, Remove gibi her işlem "son taramadan bu yana ExpirationScanFrequency geçti mi?" diye bakıyor, geçtiyse taramayı bir task'a veriyor. Varsayılan tarama aralığı 1 dakika; biz 1 saniyeye indirdik. Süresi dolmuş bir key'i doğrudan okursan zaten miss alırsın, doğruluk açısından sorun yok. Sorun iki yerde çıkıyor: trafiğin az olduğu bir instance'ta süresi dolmuş entry'ler bellekte ve Size toplamında kalmaya devam ediyor, ve callback'i "tam süresi dolunca yenile" diye kullanan kod beklediği anda tetiklenmiyor. Callback'in kendisi de thread pool'da çalışıyor; içinde fırlayan exception'ı cache yakalayıp kendi logger'ına "EvictionCallback invoked failed" diye yazıyor. new MemoryCache(options) ile oluşturduğun instance'ın logger'ı NullLoggerFactory'den geliyor, yani o hata sessizce kayboluyor. Keyed registration'da ILoggerFactory vermemizin sebebi bu.
Change Token ile Grup Invalidation
Kategori 3'e yeni bir kitap eklendiğinde o kategorinin bütün sayfaları geçersiz olmalı. Sayfa sayısını bilmeden key'leri tek tek silemezsin; IMemoryCache de prefix ile silme sunmuyor. Çözüm, her gruba bir CancellationTokenSource vermek ve entry'leri o token'a bağlamak:
// bir grubun tüm entry'lerini tek hamlede geçersiz kılmak için grup başına bir CancellationTokenSource
public sealed class CacheGroups
{
private readonly ConcurrentDictionary<string, CancellationTokenSource> _sources = new();
public IChangeToken GetToken(string group) =>
new CancellationChangeToken(_sources.GetOrAdd(group, _ => new CancellationTokenSource()).Token);
public void Invalidate(string group)
{
// sözlükten çıkar, sonra iptal et: sonraki GetToken yeni bir kaynak alır
if (_sources.TryRemove(group, out CancellationTokenSource? source))
{
source.Cancel();
}
}
}
public async Task<List<BookSummary>> GetPageAsync(int categoryId, int page, CancellationToken ct = default)
{
string key = $"v1:books:cat:{categoryId}:page:{page}";
if (cache.TryGetValue(key, out List<BookSummary>? cached)) return cached!;
// token'ı sorgudan ÖNCE al: yükleme sırasında invalidation olursa bu entry hiç cache'e girmez
IChangeToken groupToken = groups.GetToken($"category:{categoryId}");
await using CatalogDb db = CatalogDb.Create();
List<BookSummary> books = await db.Books.AsNoTracking()
.Where(b => b.CategoryId == categoryId)
.OrderBy(b => b.Id).Skip((page - 1) * 20).Take(20)
.Select(b => new BookSummary(b.Id, b.Title, b.Price, b.Author!.Name))
.ToListAsync(ct);
cache.Set(key, books, new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
.AddExpirationToken(groupToken)
.RegisterPostEvictionCallback((k, _, reason, _) => Console.WriteLine($" evicted {k}: {reason}")));
return books;
}
cached entries: 6, queries: 6
page 1 of category 3 still cached: False
page 1 of category 4 still cached: True
evicted v1:books:cat:3:page:3: TokenExpired
evicted v1:books:cat:3:page:1: TokenExpired
evicted v1:books:cat:3:page:2: TokenExpired
cached entries: 3
Cancel çağrısından hemen sonraki okuma miss alıyor, çünkü TryGetValue entry'nin token'ının HasChanged olup olmadığına bakıyor. Fiziksel silme ve callback'ler ise kaynak koddaki ExpirationTokensExpired method'unun açtığı bir task'ta, birkaç milisaniye sonra oluyor.
Koddaki en önemli satır yorumlu olanı. Token'ı sorgudan sonra alsaydın şu sıra mümkün olurdu: istek eski veriyi okur, tam o sırada admin kitap ekleyip grubu invalid eder, istek yeni oluşturulan token'ı alıp eski veriyi ona bağlar ve eski sayfa 10 dakika boyunca cache'te kalır. Token'ı önce alınca invalidation sorgu sırasında olursa token çoktan iptal edilmiş olur ve Set entry'yi expired kabul edip cache'e koymaz. Bu, serinin yedinci yazısında ayrıntılı göreceğimiz "eski değerin geri yazılması" race'inin process içi hâli.
Bir not daha: .NET 7'den önce bir factory içinde oluşturulan iç içe entry'ler dıştakinin token'larını ve expiration'ını otomatik devralıyordu. Artık MemoryCacheOptions.TrackLinkedCacheEntries varsayılan olarak false; bağımlılığı yukarıdaki gibi açıkça kur.
GetOrCreateAsync'in Gizli Stampede'i
Stampede'in teorisini System Design serisinin "Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede" yazısı anlattı. Burada soru daha dar: .NET'in en çok kullanılan cache method'u bu konuda ne yapıyor? Kaynak kod beş satır: TryGetValue başarısızsa entry oluştur, factory'yi await et, değeri yaz. Arada hiçbir kilit yok. Factory olarak yazarları satışa göre sıralayan bir sorgu kullanıyoruz ve cache boşken 100 isteği aynı anda serbest bırakıyoruz:
private static async Task<List<AuthorSales>> LoadTopAuthorsAsync(CancellationToken ct)
{
Interlocked.Increment(ref _factoryRuns);
await Task.Delay(20, ct); // ağ gecikmesi: SQL Server/PostgreSQL sürücüsü bu sürede thread'i bırakır
await using CatalogDb db = CatalogDb.Create();
return await db.Books.AsNoTracking()
.GroupBy(b => b.AuthorId)
.Select(g => new { AuthorId = g.Key, Books = g.Count(), Sold = g.Sum(b => b.Sold) })
.OrderByDescending(x => x.Sold).Take(10)
.Select(x => new AuthorSales(x.AuthorId, x.Books, x.Sold))
.ToListAsync(ct);
}
await MeasureAsync("GetOrCreateAsync", () => cache.GetOrCreateAsync(Key, entry =>
{
entry.SetOptions(Options);
return LoadTopAuthorsAsync(CancellationToken.None);
}));
Task.Delay(20) gerçek bir ağ sürücüsünü taklit ediyor. Microsoft.Data.Sqlite'ın async method'ları aslında senkron çalışıyor; gecikme olmadan aynı ölçümde factory 10 kez çalıştı, yani makinedeki çekirdek sayısı kadar. PostgreSQL gibi ağ üzerinden konuşan bir veritabanında sorgu ağda beklerken thread serbest kalır ve sayı eşzamanlı istek sayısına yaklaşır. Düzeltme için iki yol deniyoruz. Birincisi kilit, ama key başına bir SemaphoreSlim tutan bir sözlük her yeni key'le büyür. Sabit sayıda "şerit" kullanmak belleği sınırlar:
// key başına semaphore yerine sabit sayıda "şerit": bellek sınırsız büyümez
public sealed class StripedLocks(int stripes = 64)
{
private readonly SemaphoreSlim[] _locks = [.. Enumerable.Range(0, stripes).Select(_ => new SemaphoreSlim(1, 1))];
public SemaphoreSlim For(string key) => _locks[(key.GetHashCode() & int.MaxValue) % _locks.Length];
}
public static class MemoryCacheSingleFlightExtensions
{
public static async Task<T> GetOrCreateOnceAsync<T>(
this IMemoryCache cache, string key, Func<CancellationToken, Task<T>> factory,
MemoryCacheEntryOptions options, StripedLocks locks, CancellationToken ct = default)
{
if (cache.TryGetValue(key, out T? value)) return value!;
SemaphoreSlim gate = locks.For(key);
await gate.WaitAsync(ct);
try
{
// kilidi bekleyenlerden biri değeri çoktan yazmış olabilir: ikinci kontrol
if (cache.TryGetValue(key, out value)) return value!;
value = await factory(ct);
cache.Set(key, value, options);
return value;
}
finally
{
gate.Release();
}
}
}
İkinci yol internette sık önerilen "cache'e Lazy<Task<T>> koy" tavsiyesi: entry'nin değeri sonucu değil, sonucu üretecek task olsun. Dördü yan yana:
GetOrCreateAsync requests=100 factory runs=100 db queries=100 elapsed= 252.7 ms
GetOrCreateOnceAsync requests=100 factory runs= 1 db queries= 1 elapsed= 26.2 ms
Lazy<Task> in cache requests=100 factory runs= 11 db queries= 11 elapsed= 45.1 ms
HybridCache requests=100 factory runs= 1 db queries= 1 elapsed= 37.5 ms
Tahminin ikinci cevabı: 100 istek, 100 factory, 100 sorgu. Microsoft Learn bunu açıkça yazıyor: GetOrCreate ve GetOrCreateAsync atomik değil, aynı key'i kaçıran her istek factory'yi çalıştırıyor ve son yazılan değer kalıyor. Şeritli kilit bunu 1'e indiriyor. Lazy<Task> ise pencereyi daraltıyor ama kapatmıyor: GetOrCreate'in kendisi de atomik olmadığı için aynı anda kaçıran her thread kendi Lazy'sini oluşturup kendi factory'sini başlatıyor. Üstelik factory hata verirse faulted task, siz Remove edene kadar cache'te kalır ve herkese aynı exception'ı döner.
Şeritli kilidin de bedelleri var. Aynı şeride düşen farklı key'ler birbirini bekler, ve kilit yalnız bu process'te geçerli: üç instance'ta factory üç kez çalışır. Instance'lar arası tek hesaplama için distributed lock gerekir; neden tek başına yetmediğini Distributed Systems Temelleri serisinin "Distributed Lock, Lease ve Fencing Token: Kilit Neden Yetmez?" yazısında konuştuk. HybridCache aynı korumayı hazır veriyor ve timeout, iptal ve L2 (level 2, process dışı ortak cache) gibi ayrıntıları da çözüyor; bu yüzden yeni kodda kendi kilidini yazmak yerine ona geçmek daha doğru. HybridCache'in 11 ms daha yavaş görünmesinin sebebi de ilginç: mutable bir tip için her çağırana kopya veriyor. Bunun ayrıntısı serinin dördüncü yazısında.
Bellek Baskısı: Entity Graph'ı ve Paylaşılan Object
IMemoryCache object'in kendisini değil referansını saklar. Bu iki ayrı soruna yol açıyor. İlki, cache'lediğin şeyin arkasından nelerin geldiği. Aynı scope'ta önce yazarın kitap listesi yüklenmiş, sonra kitap detayı Include ile alınıp cache'e konmuş olsun:
await using CatalogDb db = CatalogDb.Create();
// aynı scope'ta daha önce yazarın kitap listesi yüklenmiş (ör. "yazarın diğer kitapları" bölümü)
List<Book> authorBooks = await db.Books.Where(b => b.AuthorId == 7).ToListAsync();
Book book = await db.Books.Include(b => b.Author).FirstAsync(b => b.Id == 650);
cache.Set("book:650", book, TimeSpan.FromMinutes(10));
Entry'yi silmeden önce ve sonra GC.GetTotalMemory(forceFullCollection: true) farkını ölçtük; ikinci satır aynı kitabın projection ile alınmış BookSummary record'u:
entity : book.Author.Books.Count = 100, freed on Remove: 92 KB
summary: BookSummary { Id = 650, Title = Book 650, Price = 10.0, AuthorName = Author 7 }, freed on Remove: 1 KB
Tek bir kitabı cache'ledin, bellekte 100 kitap kaldı. Change tracker'ın navigation fixup'ı book.Author.Books collection'ını aynı context'te yüklenmiş bütün kitaplarla doldurdu ve hepsi açıklamalarıyla birlikte cache'in referansıyla yaşıyor. Fixup'ın mekaniği EF Core serisinin "EF Core'da Change Tracking: Entity State'leri, AsNoTracking ve Identity Resolution" yazısında. Lazy loading proxy'leri kullanıyorsan iş daha da kötüleşir: entity, kendisini yükleyen DbContext'e de referans tutar. Kural basit: cache'e entity değil, ihtiyacın olan alanları taşıyan bir DTO (Data Transfer Object) ya da record koy.
İkinci sorun, herkesin aynı instance'ı paylaşması. Bir endpoint cache'ten gelen listeyi kendi işi için yerinde filtreliyor:
List<BookSummary> cheap = await GetCategoryAsync();
cheap.RemoveAll(b => b.Price >= 30);
request 1 sees 500 books
request 2 filtered in place to 200 cheap books
request 3 sees 200 books, same instance: True
Üçüncü istek 300 kitabı kaybetti ve hata TTL (Time to Live) dolana kadar herkese servis edilecek. Record'lar tek tek değiştirilemiyor ama onları tutan List<T> değiştirilebiliyor. Cache'e ToImmutableArray() ile üretilmiş bir ImmutableArray<BookSummary> ya da en azından IReadOnlyList<T> arkasında bir array koy; değiştirmesi gereken kod önce kopyalasın.
Üretimde Dikkat
Cache'i ölçmeden işletme. TrackStatistics = true ve GetCurrentStatistics() sana hit, miss, entry sayısı ve tahmini boyutu veriyor; bunları bir metric olarak dışarı vermenin yolu serinin dokuzuncu yazısının, .NET 11 ile gelen yerleşik metric'ler ise onuncu yazının konusu. Count property'sini istek yolunda okuma: Microsoft Learn, yüksek eşzamanlılıkta iç partition'ları topladığı için pahalı olduğunu yazıyor.
SizeLimit'i bellek bütçesinden türet. Ortalama entry boyutunu bir kez ölç (yukarıdaki GC.GetTotalMemory farkı yeterli bir yaklaşım), pod'un bellek limitinin cache'e ayırdığın payına böl. Size'ı her yerde aynı birimle ver; bir yerde entry sayısı, başka yerde byte kullanırsan limit anlamsızlaşır.
Limit yoksa runtime cache'i senin yerine küçültmez. Microsoft Learn açık: ASP.NET Core bellek baskısında cache'i kırpmaz, CompactOnMemoryPressure gibi eski seçenekler artık derleme hatası veriyor. Container'da çalışan bir API için limitsiz cache, OOMKilled (Out Of Memory Killed) ile biten bir hikâyenin ilk satırı.
NeverRemove'u az kullan. Bu entry'ler compaction'da hiç sayılmaz; SizeLimit'in çoğunu kaplarlarsa geri kalan her yeni entry limit nedeniyle reddedilir ve cache fiilen salt okunur hâle gelir.
Sık Düşülen Tuzaklar
Kullanıcı girdisinden key üretmek. Arama sonucunu v1:search:{q} key'iyle cache'leyen bir endpoint düşün; saldırgan her istekte rastgele bir q gönderiyor. 500.000 istek sonra, limitli ve limitsiz:
// saldırgan her istekte farklı bir arama terimi gönderiyor: /api/search?q=<rastgele>
for (int i = 0; i < 500_000; i++)
{
string query = Guid.NewGuid().ToString("N");
cache.Set($"v1:search:{query}", new int[64], new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(5),
Size = 1
});
}
SizeLimit=none: entries=500,000 retained=268 MB
SizeLimit=10000: entries=9,500 retained=10 MB
Limitsiz cache 268 MB'a çıktı ve sliding expiration yüzünden saldırı sürdükçe hiçbir entry ölmüyor; bu bir bellek DoS'u (Denial of Service). Limit belleği kurtarıyor ama tek başına yetmiyor: saldırganın entry'leri senin sıcak key'lerini compaction ile dışarı atar ve hit ratio çöker. Kullanıcı girdisini key'e koymadan önce normalize et ve sınırla (uzunluk, izin verilen değerler), sayfa ve sıralama gibi parametreleri sabit bir kümeye indir, gerekiyorsa endpoint'e rate limiting ekle (Web API serisinin ".NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching" yazısı).
Ortak IMemoryCache'e SizeLimit koymak. Yukarıda gördük: Size vermeyen ilk kütüphane exception fırlatır. Limitli cache her zaman senin instance'ın olsun.
GetOrCreateAsync'i pahalı bir factory ile "zaten cache'liyorum" diye kullanmak. Soğuk başlangıçta, deploy sonrasında ve popüler bir key'in süresi dolduğu her an factory eşzamanlı istek sayısı kadar çalışır.
Change token'ı veriyi okuduktan sonra almak. Invalidation ile yükleme çakışırsa eski veri yeni token'a bağlanır ve TTL boyunca kalır.
Cache'ten gelen object'i değiştirmek. Değişiklik o anda bütün istekleri etkiler ve sebebi log'larda görünmez.
Küçük Bir Deneme
★☆☆ Kapasite örneğinde CompactionPercentage'ı varsayılan 0,05'e çek ve 101'den 120'ye kadar art arda 20 kitap ekle. Kaçı cache'e girdi, kaç compaction tetiklendi? Callback listesinden çıkar.
★★☆ Expiration örneğinde ExpirationScanFrequency'yi varsayılanında (1 dakika) bırak ve 3 saniye sonra ilgisiz bir key okumak yerine price:42'yi oku. Hangi callback'ler çalışıyor, price:43'ün callback'i ne zaman gelir?
★★★ GetOrCreateOnceAsync'e hata yönetimi ekle: factory exception fırlattığında kilidi bekleyen 99 istek ne yapmalı? Her biri factory'yi tekrar mı denemeli, yoksa aynı hatayı mı almalı? İki davranışı da yazıp 100 eşzamanlı istekle sorgu sayısını karşılaştır.
Bu yazıda process içindeki cache'in sınırlarını gördük: limit senin sorumluluğun, expiration bir timer değil, grup invalidation token ile kurulur ve GetOrCreateAsync stampede'e karşı korumasız. Ama bu cache her instance'ın kendi belleğinde yaşıyor ve uygulama yeniden başladığında sıfırlanıyor. Bir sonraki yazıda ortak katmana çıkıyoruz: IDistributedCache ile Redis'i cache olarak işletmek, serialization, key tasarımı, TTL ve maxmemory-policy.