Mert Özen Her Satırda Daha İleri
Backend Caching — #10

.NET 11 ile Caching'de Gelenler: MemoryCache Metric'leri, HybridCache Factory Context ve IOutputCachePolicyProvider

Mert Özen 8 Kas 2026 18 dk 4 görüntülenme
.NET 11 ile Caching'de Gelenler: MemoryCache Metric'leri, HybridCache Factory Context ve IOutputCachePolicyProvider

.NET 11 caching'e üç şey getiriyor: MemoryCache, TrackStatistics açıkken dotnet.cache.requests, evictions, entries ve estimated_size metric'lerini kendiliğinden yayınlıyor; HybridCache factory'si süreyi değere göre seçebiliyor; IOutputCachePolicyProvider policy'leri çalışma anında çözüyor. Ölçtük: yeni HybridCache overload'u bugünkü paketle atomik değil, 20 soğuk key'de factory 20 yerine 40 kez çalıştı; özel provider ise AddPolicy tanımlarını devreden çıkarıyor.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve .NET 11 RC (Release Candidate) 3 ile derlenip denenmiştir; .NET 11 örnekleri 11.0.100-rc.3.26522.148 SDK'sıyla (Software Development Kit), Microsoft.Extensions.Caching.Memory 11.0.0-rc.3, Microsoft.Extensions.Caching.Hybrid 10.11.0, OpenTelemetry 1.20.1 ve Redis 8.2 ile koşturuldu. .NET 11'in GA (General Availability) sürümü 10 Kasım 2026'da bekleniyor; buradaki davranışları GA sonrası yeniden doğrula.

Önce Tahmin Et

Bookstore'un kitap detayı HybridCache ile cache'leniyor ve log'larda tuhaf bir trafik var: bir bot, var olmayan kitap ID'lerini tek tek deniyor. Her deneme cache'i ıskalayıp veritabanına gidiyor. "Böyle bir kitap yok" cevabını cache'lemek istiyorsun ama 30 dakika değil, birkaç saniye; o kitap beş dakika sonra kataloğa eklenebilir. .NET 10'da entry option'larını factory çalışmadan önce vermek zorundaydın, yani süre değerden bağımsızdı. .NET 11'de factory'ye bir context geliyor: kitap yoksa context.Expiration = TimeSpan.FromSeconds(5) diyorsun ve iş bitiyor.

İki soru. Birincisi: 20 soğuk key var, her birine 64 eşzamanlı worker art arda beşer istek atıyor. Eski overload'la repository tam 20 kez çağrılıyordu, key başına bir. Yeni overload'la kaç kez çağrılır? İkincisi: output cache için kendi IOutputCachePolicyProvider'ını kaydettin. AddOutputCache içinde AddPolicy("legacy", ...) ile tanımladığın policy çalışmaya devam eder mi?

Geriye Bakış: .NET 7'den .NET 10'a Cache API'leri

Serinin son yazısında önce dört yıla bakalım. .NET 7 (Kasım 2022) iki şey getirdi: ASP.NET Core'a output cache middleware'i, MemoryCache'e de TrackStatistics ve GetCurrentStatistics(). İlkini ASP.NET Core Web API (Application Programming Interface) serisinin ".NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching" yazısı tanıttı. İkincisi sayaçları tutuyordu, metric sistemine taşımak senin işindi.

.NET 8 (Kasım 2023) output cache'e Redis store'unu ekledi: Microsoft.AspNetCore.OutputCaching.StackExchangeRedis paketi (8.0.0, 14 Kasım 2023) ve AddStackExchangeRedisOutputCache ile cache'lenmiş yanıtlar sunucular arasında paylaşılabildi (serinin altıncı yazısı). .NET 9 ile HybridCache geldi; Microsoft.Extensions.Caching.Hybrid paketi .NET 9 çıktığında preview'dü, ilk kararlı sürüm 9.3.0, 11 Mart 2025'te yayımlandı. Girişini Web API serisinin ".NET 9 Yenilikleri: ASP.NET Core'da Yerleşik OpenAPI, Scalar ve HybridCache" yazısı, derinini serinin dördüncü yazısı anlattı. Bu seri de .NET 10 LTS (Long Term Support) üzerinde kuruldu.

Seri boyunca üç boşluğa çarptık: in-memory cache'in hit ratio'su için kendi sayacını yazıyordun, HybridCache'te süreyi değere göre seçemiyordun, output cache policy'leri açılışta sabitleniyordu. .NET 11 üçüne de cevap getiriyor; her birini çalıştırıp sınırlarını ölçeceğiz.

MemoryCache Metric'leri: TrackStatistics Artık OpenTelemetry Konuşuyor

.NET 11'de MemoryCache, TrackStatistics açıkken Microsoft.Extensions.Caching.Memory.MemoryCache adlı bir meter üzerinden dört observable instrument yayınlıyor. Ek paket yok, adapter yok. Ölçümleri bir exporter kurmadan görmenin en kısa yolu bir MeterListener:

static List<string> Collect(bool showScope = false)
{
    List<string> lines = [];
    using MeterListener listener = new();
    listener.InstrumentPublished = (instrument, l) =>
    {
        if (instrument.Meter.Name == MeterName)
            l.EnableMeasurementEvents(instrument);
    };
    listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
    {
        string tagText = string.Join(",", tags.ToArray().Select(t => $"{t.Key}={t.Value}"));
        string scope = showScope ? $"  scope={instrument.Meter.Scope?.GetType().Name ?? "null"}" : "";
        lines.Add($"{instrument.Name,-27} {value,3}  {tagText}{scope}");
    });
    listener.Start();
    listener.RecordObservableInstruments();
    return lines;
}

Önce bir kitap cache'i: üç entry, 50 ms ömürlü bir dördüncü, iki hit, bir miss, bir elle silme ve süresi dolmuş entry'ye bir okuma. Karşılaştırma için istatistikleri kapalı bir cache'e de bir kez baktık:

// 1) TrackStatistics kapalı: hiç ölçüm yok
using (MemoryCache silent = new(new MemoryCacheOptions()))
{
    silent.Set("k", 1);
    Console.WriteLine($"TrackStatistics=false -> {Collect().Count} measurements");
}

// 2) TrackStatistics açık, SizeLimit yok
using MemoryCache books = new(new MemoryCacheOptions { TrackStatistics = true, Name = "books" });
for (int i = 1; i <= 3; i++) books.Set($"book:{i}", $"Book {i}");
books.Set("book:4", "Book 4", TimeSpan.FromMilliseconds(50));
books.TryGetValue("book:1", out _);
books.TryGetValue("book:2", out _);
books.TryGetValue("book:9", out _);
books.Remove("book:3");                 // elle silmek eviction sayılmaz
Thread.Sleep(100);
books.TryGetValue("book:4", out _);     // süresi dolmuş: miss + eviction
Print("books (no SizeLimit)", Collect(), "books");
TrackStatistics=false -> 0 measurements
--- books (no SizeLimit)
dotnet.cache.requests         2  dotnet.cache.name=books,dotnet.cache.request.result=hit
dotnet.cache.requests         2  dotnet.cache.name=books,dotnet.cache.request.result=miss
dotnet.cache.evictions        1  dotnet.cache.name=books
dotnet.cache.entries          2  dotnet.cache.name=books

TrackStatistics kapalıyken tek bir ölçüm bile yok; bu bir opt-in. dotnet.cache.requests counter'ı hit ile miss'i dotnet.cache.request.result tag'iyle ayırıyor. Süresi dolmuş book:4 okunurken hem miss hem eviction sayıldı; elle Remove ettiğin book:3 ise sayılmadı, çünkü kaynak kodda sayaç yalnız expiration ve compaction yollarında artıyor. dotnet.cache.entries anlık entry sayısı. Dördüncü instrument listede yok, çünkü SizeLimit vermedik:

// 3) SizeLimit ile estimated_size da yayınlanır
using MemoryCache covers = new(new MemoryCacheOptions { TrackStatistics = true, Name = "covers", SizeLimit = 100 });
covers.Set("cover:1", new byte[10], new MemoryCacheEntryOptions { Size = 40 });
covers.Set("cover:2", new byte[10], new MemoryCacheEntryOptions { Size = 40 });
covers.Set("cover:3", new byte[10], new MemoryCacheEntryOptions { Size = 40 }); // limit aşılıyor, eklenmez
covers.TryGetValue("cover:3", out _);
Print("covers (SizeLimit=100)", Collect(), "covers");
covers.Compact(0.5);                    // compaction eviction sayılır
Print("covers after Compact(0.5)", Collect(), "covers");
--- covers (SizeLimit=100)
dotnet.cache.requests         0  dotnet.cache.name=covers,dotnet.cache.request.result=hit
dotnet.cache.requests         1  dotnet.cache.name=covers,dotnet.cache.request.result=miss
dotnet.cache.evictions        0  dotnet.cache.name=covers
dotnet.cache.entries          2  dotnet.cache.name=covers
dotnet.cache.estimated_size  80  dotnet.cache.name=covers
--- covers after Compact(0.5)
dotnet.cache.requests         0  dotnet.cache.name=covers,dotnet.cache.request.result=hit
dotnet.cache.requests         1  dotnet.cache.name=covers,dotnet.cache.request.result=miss
dotnet.cache.evictions        1  dotnet.cache.name=covers
dotnet.cache.entries          1  dotnet.cache.name=covers
dotnet.cache.estimated_size  40  dotnet.cache.name=covers

dotnet.cache.estimated_size yalnız SizeLimit tanımlıyken yayınlanıyor ve birimi "1": değer byte değil, entry'lere senin verdiğin Size birimlerinin toplamı. Limit 100 iken üçüncü kapak hiç eklenmedi (40 + 40 + 40 > 100); entry sayısı 2'de, tahmini boyut 80'de kaldı ve reddedilen ekleme eviction sayılmadı. Compact(0.5) bir entry'yi attı ve bu eviction sayacına yansıdı. SizeLimit ve compaction'ın mekaniği serinin ikinci yazısında.

IMeterFactory, Name ve OpenTelemetry'ye Bağlamak

Gerçek uygulamada MeterListener yazmazsın; OpenTelemetry'ye (OTel) meter adını verirsin. Bookstore API'sinde HybridCache'in L1'i (level 1, process içi cache) olarak da kullanılan IMemoryCache'e bir isim verip istatistikleri açtık:

builder.Services.AddMemoryCache(o =>
{
    o.TrackStatistics = true;
    o.Name = "catalog-l1";
});
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:17560");
builder.Services.AddHybridCache();
builder.Services.AddSingleton<BookRepository>();
builder.Services.AddSingleton<BookCatalog>();

builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics
    .AddMeter("Microsoft.Extensions.Caching.Memory.MemoryCache")
    .AddConsoleExporter((_, reader) => reader.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 5000));

/api/books/42'yi 10 kez, olmayan /api/books/5001'i 3 kez çağırdıktan sonra console exporter'ın çıktısından ilgili blok:

Metric Name: dotnet.cache.requests, Description: Total cache requests., Unit: {request}, Metric Type: LongSum
Instrumentation scope (Meter):
    Name: Microsoft.Extensions.Caching.Memory.MemoryCache
    Tags:
        dotnet.cache.name: catalog-l1
(2026-11-08T02:14:11.0191630Z, 2026-11-08T02:14:22.9168960Z] dotnet.cache.name: catalog-l1 dotnet.cache.request.result: hit
Value: 11
(2026-11-08T02:14:11.0191630Z, 2026-11-08T02:14:22.9168960Z] dotnet.cache.name: catalog-l1 dotnet.cache.request.result: miss
Value: 2

Bu bölümün en pratik sonucu şu: HybridCache'in varsayılan implementasyonu L1 olarak DI'daki (Dependency Injection) IMemoryCache'i kullanıyor, dolayısıyla L1 hit ratio'su tek satır ek kod yazmadan dashboard'a geliyor. 13 isteğin 11'i L1'den döndü, 2'si factory'ye gitti. L2'nin (level 2, Redis gibi process dışı ortak cache) hit'leri bu metric'te görünmez; onlar L1 miss olarak sayılır. Output cache'in varsayılan store'u da kendi MemoryCache'ini yalnız SizeLimit ile yaratıyor, onun trafiği bu meter'a düşmez.

İki yeni parça daha var. MemoryCacheOptions.Name (varsayılanı "Default") her ölçüme dotnet.cache.name tag'i olarak ekleniyor. Yeni MemoryCache(IOptions<MemoryCacheOptions>, ILoggerFactory?, IMeterFactory?) constructor'ı meter'ı IMeterFactory üzerinden yaratıyor; factory yoksa instrument'lar process genelinde paylaşılan tek bir meter'a bağlanıyor. DI constructor'ı çözebildiği parametrelere göre seçtiği için küçük bir tuzak var; ASP.NET Core'da sorun yok ama düz bir ServiceCollection'da:

// 4) DI: AddMemoryCache, IMeterFactory'li constructor'ı seçer
ServiceCollection services = new();
services.AddLogging();
services.AddMetrics();
services.AddMemoryCache(o => { o.TrackStatistics = true; o.Name = "catalog-l1"; });
using ServiceProvider sp = services.BuildServiceProvider();
IMemoryCache l1 = sp.GetRequiredService<IMemoryCache>();
l1.Set("menu", "Fiction");
l1.TryGetValue("menu", out _);
Print("DI catalog-l1", Collect(showScope: true), "catalog-l1");
# AddLogging() olmadan
dotnet.cache.requests         1  dotnet.cache.name=catalog-l1,dotnet.cache.request.result=hit  scope=null
# AddLogging() ile
dotnet.cache.requests         1  dotnet.cache.name=catalog-l1,dotnet.cache.request.result=hit  scope=DefaultMeterFactory

AddLogging() olmadan ILoggerFactory çözülemedi, DI tek parametreli constructor'a düştü ve cache paylaşılan meter'a bağlandı. Ölçüm yine geliyor ama meter seviyesindeki tag'ler ve factory'nin yönettiği yaşam döngüsü kayboluyor. Bir not daha: hit/miss tag'inin adı preview sürümlerinde dotnet.cache.request.type idi, şimdi dotnet.cache.request.result; preview döneminde dashboard kurduysan filtreni güncelle.

Bu metric'ler için .NET 11'e geçmeyi beklemen gerekmiyor. Microsoft.Extensions.Caching.Memory 11.0.0-rc.3 paketini .NET 10 üzerindeki bir ASP.NET Core uygulamasına ekledik; paketteki assembly framework'ünkinin önüne geçti:

runtime 10.0.13, Caching.Memory 11.0.0.0
dotnet.cache.requests 1 dotnet.cache.name=net10-l1,dotnet.cache.request.result=hit scope=DefaultMeterFactory

Üretimde bunu RC paketiyle değil, GA'daki 11.0.0 paketiyle yaparsın.

HybridCache Factory Context: Süreyi Değere Göre Seçmek

HybridCache'e iki yeni GetOrCreateAsync overload'u eklendi. İkisinde de factory, değeri üretirken bir HybridCacheEntryContext alıyor ve entry'nin nasıl cache'leneceğini değeri gördükten sonra değiştirebiliyor. Bookstore'un kitap kataloğu:

// veritabanı yerine: 100 ms gecikmeli, çağrı sayan bir repository
public sealed class BookRepository
{
    private int _calls;
    public int Calls => Volatile.Read(ref _calls);

    public async Task<Book?> FindAsync(int id, CancellationToken ct)
    {
        Interlocked.Increment(ref _calls);
        await Task.Delay(100, ct);
        return id <= 1000 ? new Book(id, $"Book {id}", 10m + id % 50) : null;
    }
}

public sealed class BookCatalog(HybridCache cache, BookRepository repository)
{
    private static readonly HybridCacheEntryOptions Defaults = new()
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };

    public ValueTask<Book?> GetAsync(int id, CancellationToken ct = default) =>
        cache.GetOrCreateAsync(
            $"v1:book:{id}",
            (repository, id),
            static async (state, context, token) =>
            {
                Book? book = await state.repository.FindAsync(state.id, token);
                if (book is null)
                {
                    // negative caching: olmayan kitap yalnız 5 saniye hatırlanır
                    context.Expiration = TimeSpan.FromSeconds(5);
                    context.LocalCacheExpiration = TimeSpan.FromSeconds(5);
                }
                return book;
            },
            Defaults,
            cancellationToken: ct);

    // .NET 10'daki klasik overload: süre değerden bağımsız
    public ValueTask<Book?> GetClassicAsync(int id, CancellationToken ct = default) =>
        cache.GetOrCreateAsync(
            $"v1:book-classic:{id}",
            (repository, id),
            static (state, token) => new ValueTask<Book?>(state.repository.FindAsync(state.id, token)),
            Defaults,
            cancellationToken: ct);
}
book 42 x3      -> repository calls: 1
book 5001 x3    -> repository calls: 2
after 6 s       -> repository calls: 3

Var olan kitap 30 dakika (L1'de 5) kalıyor, olmayan kitap 5 saniye: 6 saniye sonra 42 hâlâ cache'ten geldi, 5001 için repository üçüncü kez çağrıldı. HybridCache null değeri de cache'liyor; negative caching için ayrı bir "yok" işaretçisi gerekmemesinin sebebi bu. Yeni imzalar GetOrCreateAsync<TState, T>(string key, TState state, Func<TState, HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory, ...) ve state'siz GetOrCreateAsync<T>(string key, Func<HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory, ...). Context'teki property'ler ve bugünkü davranışları:

PropertyNe işe yararHybrid 10.11.0 ile ölçtüğümüz
ExpirationEntry'nin toplam ömrü (L2'ye giden süre)Çalışıyor
LocalCacheExpirationL1'deki ömürÇalışıyor
FlagsHybridCacheEntryFlags ile okuma/yazma kapatmaÇalışıyor; iki write bayrağı birlikte set edilirse sonuç hiçbir katmana yazılmıyor
LocalSizeL1 entry'sinin Size değeri (yeni, HybridCacheEntryOptions'ta da var)Yok sayılıyor
RevisionFactory bir değeri değiştirdikçe artan sayaç (yalnız okunur)Implementasyonlar için

Flags satırı, dotnet/runtime'daki API proposal'ında sayılan üçüncü senaryoya karşılık geliyor: geçici bir hatada exception fırlatmadan bir yedek değer dönüp onu cache'lememek.

HybridCache cache = sp.GetRequiredService<HybridCache>();
int runs = 0;
for (int i = 0; i < 3; i++)
    await cache.GetOrCreateAsync("v1:price:9", (context, ct) =>
    {
        runs++;
        // geçici bir hata: sonucu hiçbir katmana yazma
        context.Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite;
        return ValueTask.FromResult(-1m);
    });
Console.WriteLine($"flags via context: factory runs={runs} of 3");
flags via context: factory runs=3 of 3
$ docker exec chwork-redis-e redis-cli DBSIZE
0

Bir ayrıntı daha: varsayılan implementasyon context'i çağrıya verdiğin options ile dolduruyor. AddHybridCache(o => o.DefaultEntryOptions = ...) ile verdiğin global varsayılanları context'te göremezsin; options vermezsen context.Expiration null başlar. Örnekte Defaults'u açıkça geçmemizin sebebi bu.

Factory Context'in Bugünkü Bedeli: Atomik Değil

Yeni overload, HybridCache abstract class'ında gövdesi olan bir virtual method. dotnet/runtime'daki varsayılan gövde asıl GetOrCreateAsync'i iki write bayrağı kapalı çağırıyor, factory'nin context'te bıraktığı değerlerle sonra ayrı bir SetAsync yapıyor; kaynak koddaki yorum bunun bir race condition açtığını kabul ediyor. Microsoft.Extensions.Caching.Hybrid 10.11.0'daki DefaultHybridCache bu method'u override etmiyor, yani bugün bu gövdeyi çalıştırıyorsun. Ölçelim:

// 20 soğuk key; her key'e 64 worker, her biri art arda 5 kez
foreach ((string name, Func<int, ValueTask<Book?>> get) in new (string, Func<int, ValueTask<Book?>>)[]
         { ("classic", id => catalog.GetClassicAsync(id)), ("context", id => catalog.GetAsync(id)) })
{
    int before = repo.Calls;
    for (int id = 1; id <= 20; id++)
    {
        await Task.WhenAll(Enumerable.Range(0, 64).Select(async _ =>
        {
            for (int i = 0; i < 5; i++) await get(id);
        }));
    }
    Console.WriteLine($"{name,-8} 20 keys x 64 workers x 5 -> repository calls: {repo.Calls - before}");
}

Önce yalnız L1 ile, sonra Redis L2 ile üçer koşu:

# L2 yok
classic  20 keys x 64 workers x 5 -> repository calls: 20
context  20 keys x 64 workers x 5 -> repository calls: 40
classic  20 keys x 64 workers x 5 -> repository calls: 20
context  20 keys x 64 workers x 5 -> repository calls: 40
classic  20 keys x 64 workers x 5 -> repository calls: 20
context  20 keys x 64 workers x 5 -> repository calls: 40
# Redis L2
classic  20 keys x 64 workers x 5 -> repository calls: 20
context  20 keys x 64 workers x 5 -> repository calls: 31
classic  20 keys x 64 workers x 5 -> repository calls: 20
context  20 keys x 64 workers x 5 -> repository calls: 34
classic  20 keys x 64 workers x 5 -> repository calls: 20
context  20 keys x 64 workers x 5 -> repository calls: 29

Tahminin cevabı: L2'siz her koşuda 40, yani key başına iki; Redis'le 29 ile 34 arası. İlk dalgada 64 worker aynı iç çağrıda birleşiyor ve factory bir kez çalışıyor; bu kısım hâlâ stampede korumalı. Ama değer yazılmadan teslim edildiği için worker'lar ikinci isteklerine leader'ın SetAsync'i bitmeden giriyor, cache'i boş bulup factory'yi bir kez daha çalıştırıyor. Üçüncü istekten itibaren her şey hit; Redis'li koşuda zamanlama L2 okumalarıyla karıştığı için sayı oynuyor. Gerçek trafikte bu, her soğuk key ve her expiration anında veritabanına fazladan giden bir sorgu demek; stampede'in bütün resmi serinin sekizinci yazısında.

LocalSize için de aynı sebep geçerli. L1'i SizeLimit ile sınırladık ve factory'de context.LocalSize = 1 dedik:

L1 entries=3 estimatedSize=108

Üç entry 108 birim tuttu, yani entry başına 36: Book record'unun JSON (JavaScript Object Notation) payload'unun byte uzunluğu. 10.11.0 paketi LocalSize'ı tanımıyor ve L1 boyutunu kendi hesaplıyor. Bu iki açığı kapatan, DefaultHybridCache'e override ekleyen pull request dotnet/extensions'ta (#7568) açık; 8 Kasım 2026 itibarıyla henüz merge edilmedi. O sürüm gelene kadar kural basit: yeni overload'u yalnız süresi gerçekten değere bağlı olan ve sıcak olmayan key'lerde kullan (negative caching tam olarak bu), sıcak key'lerde klasik overload'da kal.

IOutputCachePolicyProvider ile Policy'leri Çalışma Anında Çözmek

.NET 10'a kadar named policy'ler AddOutputCache(options => options.AddPolicy(...)) ile uygulama açılırken sabitleniyordu. Bir policy adının var olup olmadığını dışarıdan doğrulamanın yolu bile yoktu; bu ihtiyacı dile getiren issue, route'larını configuration'dan okuyan YARP (Yet Another Reverse Proxy) için açılmıştı. .NET 11 ASP.NET Core'a, authorization ve CORS (Cross-Origin Resource Sharing) tarafından tanıdığın provider modelini getiriyor:

public interface IOutputCachePolicyProvider
{
    IReadOnlyList<IOutputCachePolicy> GetBasePolicies();
    ValueTask<IOutputCachePolicy?> GetPolicyAsync(string policyName);
}

Bookstore çok tenant'lı bir vitrin servis ediyor ve cache süreleri bir veritabanı tablosunda tutuluyor: "catalog" policy'si acme için 60 saniye, globex için 0 (globex canlı fiyat istiyor), diğer herkes için 60 saniye. Örnekte bu tabloyu 20 ms gecikmeli, yükleme sayan bir CachePolicyStore taklit ediyor. Provider ve iki policy:

public sealed class TenantOutputCachePolicyProvider(CachePolicyStore store) : IOutputCachePolicyProvider
{
    private static readonly IReadOnlyList<IOutputCachePolicy> BasePolicies = [new VaryByTenantPolicy()];
    private readonly ConcurrentDictionary<string, Task<IOutputCachePolicy?>> _policies = new();
    private int _lookups;
    public int Lookups => Volatile.Read(ref _lookups);

    public IReadOnlyList<IOutputCachePolicy> GetBasePolicies() => BasePolicies;

    public ValueTask<IOutputCachePolicy?> GetPolicyAsync(string policyName)
    {
        Interlocked.Increment(ref _lookups);
        // middleware aynı isteğe birden fazla kez soruyor: sonucu hatırla
        return new ValueTask<IOutputCachePolicy?>(_policies.GetOrAdd(policyName, LoadAsync));
    }

    private async Task<IOutputCachePolicy?> LoadAsync(string name) =>
        await store.LoadAsync(name) is { } settings ? new TenantSettingsPolicy(settings) : null;
}

// her isteğin cache key'ine tenant'ı ekler; kendi başına cache'i açmaz
public sealed class VaryByTenantPolicy : IOutputCachePolicy
{
    public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
    {
        context.CacheVaryByRules.VaryByValues["tenant"] = Tenant.From(context.HttpContext);
        return ValueTask.CompletedTask;
    }

    public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
    public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
}

public sealed class TenantSettingsPolicy(CachePolicySettings settings) : IOutputCachePolicy
{
    public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
    {
        string tenant = Tenant.From(context.HttpContext);
        TimeSpan duration = settings.TenantDurations.GetValueOrDefault(tenant, settings.DefaultDuration);
        HttpRequest request = context.HttpContext.Request;

        // varsayılan policy'nin kurallarını kendimiz yazıyoruz: yalnız GET/HEAD, kimliksiz istek
        bool cacheable = duration > TimeSpan.Zero
            && (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
            && StringValues.IsNullOrEmpty(request.Headers.Authorization)
            && context.HttpContext.User.Identity?.IsAuthenticated != true;

        context.EnableOutputCaching = true;
        context.AllowCacheLookup = cacheable;
        context.AllowCacheStorage = cacheable;
        context.AllowLocking = true;
        context.ResponseExpirationTimeSpan = duration;
        context.CacheVaryByRules.QueryKeys = "*";
        context.Tags.Add($"tenant:{tenant}");
        context.Tags.Add(settings.Name);
        return ValueTask.CompletedTask;
    }

    public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;

    public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
    {
        HttpResponse response = context.HttpContext.Response;
        if (response.StatusCode != StatusCodes.Status200OK || !StringValues.IsNullOrEmpty(response.Headers.SetCookie))
            context.AllowCacheStorage = false;
        return ValueTask.CompletedTask;
    }
}

Tenant.From, X-Tenant header'ını okuyan iki satırlık bir helper; gerçekte tenant'ı host adından ya da token'daki claim'den alırsın. Kayıt ve endpoint'ler:

builder.Services.AddResponseCompression();
builder.Services.AddOutputCache(options =>
    options.AddPolicy("legacy", b => b.Expire(TimeSpan.FromSeconds(30))));
builder.Services.AddSingleton<CachePolicyStore>();
builder.Services.AddSingleton<TenantOutputCachePolicyProvider>();
builder.Services.AddSingleton<IOutputCachePolicyProvider>(sp => sp.GetRequiredService<TenantOutputCachePolicyProvider>());

WebApplication app = builder.Build();
app.UseResponseCompression();
app.UseOutputCache();

app.MapGet("/api/catalog/menu", (HttpContext http, int page = 1) =>
        new { tenant = Tenant.From(http), page, generatedAt = DateTime.UtcNow.ToString("HH:mm:ss.fff") })
    .CacheOutput("catalog");

app.MapGet("/api/legacy", () => new { generatedAt = DateTime.UtcNow.ToString("HH:mm:ss.fff") })
    .CacheOutput("legacy");

app.MapPost("/api/admin/tenants/{tenant}/evict", async (string tenant, IOutputCacheStore store, CancellationToken ct) =>
{
    await store.EvictByTagAsync($"tenant:{tenant}", ct);
    return Results.NoContent();
});
$ curl -s -H "X-Tenant: acme" localhost:17561/api/catalog/menu
{"tenant":"acme","page":1,"generatedAt":"02:14:10.917"}
$ curl -s -H "X-Tenant: acme" localhost:17561/api/catalog/menu
{"tenant":"acme","page":1,"generatedAt":"02:14:10.917"}
$ curl -s -H "X-Tenant: initech" localhost:17561/api/catalog/menu
{"tenant":"initech","page":1,"generatedAt":"02:14:10.935"}
$ curl -s -H "X-Tenant: globex" localhost:17561/api/catalog/menu
{"tenant":"globex","page":1,"generatedAt":"02:14:10.940"}
$ curl -s -H "X-Tenant: globex" localhost:17561/api/catalog/menu
{"tenant":"globex","page":1,"generatedAt":"02:14:10.945"}
$ curl -s localhost:17561/debug/policies
{"lookups":8,"loads":1}
$ curl -s -X POST localhost:17561/api/admin/tenants/acme/evict -w "%{http_code}\n"
204
$ curl -s -H "X-Tenant: acme" localhost:17561/api/catalog/menu
{"tenant":"acme","page":1,"generatedAt":"02:14:10.961"}
$ curl -s -H "X-Tenant: initech" localhost:17561/api/catalog/menu
{"tenant":"initech","page":1,"generatedAt":"02:14:10.935"}
$ curl -s localhost:17561/api/legacy
{"generatedAt":"02:14:10.994"}
$ curl -s localhost:17561/api/legacy
{"generatedAt":"02:14:11.000"}
$ curl -s localhost:17561/debug/policies
{"lookups":14,"loads":2}

acme'nin ikinci isteği cache'ten geldi, initech kendi kopyasını aldı, globex her seferinde yeniden üretildi; acme'nin tag'ini evict edince yalnız acme yenilendi. GetBasePolicies her istekte çağrılıyor ve HttpContext almıyor; tenant'a göre karar bu yüzden policy'nin içinde, OutputCacheContext üzerinden veriliyor. GetPolicyAsync ise named policy'nin her aşamasında tekrar soruluyor: cache'lenebilen bir istekte iki kez (CacheRequestAsync ve ardından ServeFromCacheAsync ya da ServeResponseAsync), globex gibi storage'ı kapalı bir istekte bir kez. Beş istekte 8 lookup buradan geliyor; store'a ise bir kez gidildi.

İkinci tahminin cevabı da çıktıda: legacy cache'lenmedi, iki istek farklı zaman damgası döndü ve loads 2'ye çıktı. AddPolicy ve AddBasePolicy ile tanımladıklarını okuyan, varsayılan provider'dı; onu değiştirdiğin anda o tanımlar sessizce devreden çıkıyor. Varsayılan provider'ı sarmalayıp devralmak da mümkün değil, çünkü DefaultOutputCachePolicyProvider internal. Fluent builder'la policy üretmek de olmuyor:

BuilderProbe.cs(8,13): error CS1729: 'OutputCachePolicyBuilder' does not contain a constructor that takes 0 arguments

OutputCachePolicyBuilder'ın constructor'ları ve Build() method'u internal; provider'dan döndüreceğin her policy'yi IOutputCachePolicy olarak kendin yazıyorsun. Microsoft Learn'deki örnek provider'ı AddOutputCache'ten sonra AddTransient ile kaydediyor; biz dictionary tuttuğumuz için singleton seçtik. Middleware provider'ı constructor'ında bir kez aldığı için scoped kayıt uygun değil.

Response Compression Artık Her Yanıta Vary Ekliyor

Output cache'le aynı pipeline'da duran bir değişiklik daha var. Aynı küçük uygulamayı .NET 10 ve .NET 11 ile çalıştırdık:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCompression();
WebApplication app = builder.Build();
app.UseResponseCompression();

app.MapGet("/api/books", () => Enumerable.Range(1, 50).Select(i => new { id = i, title = $"Book {i}" }));

app.Run();
# .NET 10 (17563): Accept-Encoding yok, sonra gzip
HTTP/1.1 200 OK
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding
# .NET 11 (17564): Accept-Encoding yok, sonra gzip
HTTP/1.1 200 OK
Vary: Accept-Encoding
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding

.NET 10'da sıkıştırılmamış yanıtta Vary yoktu; araya giren bir shared cache ya da CDN (Content Delivery Network) bu yanıtı herkese aynı diye saklayabilirdi. .NET 11 compression açıkken Vary: Accept-Encoding'i her yanıta ekliyor. HTTP (Hypertext Transfer Protocol) cache'lerinin Vary'yi nasıl kullandığı serinin beşinci yazısında. Aynı sürümde Zstandard da varsayılan sağlayıcılara katıldı: Accept-Encoding: zstd, br, gzip gönderen istemciye .NET 10 br, .NET 11 zstd döndü.

Üretimde Dikkat

Önce takvim: buradaki her şey RC 3 ile ölçüldü. GA'dan sonra metric adlarını ve DefaultHybridCache'in yeni overload'u override edip etmediğini paket notlarından kontrol et.

TrackStatistics'in maliyeti düşük: kaynak kodda hit/miss sayaçları thread başına tutuluyor, toplama yalnız exporter ölçüm istediğinde yapılıyor. Dashboard'da hit ratio'yu dotnet.cache.name başına hit / (hit + miss) olarak çiz; estimated_size'ı SizeLimit'e oranla, eviction'daki ani artışları da TTL (Time to Live) ya da limit ayarının sinyali olarak oku. Bunları p95 ve veritabanı yüküyle yan yana okumak serinin dokuzuncu yazısının konusu.

GetBasePolicies her isteğin sıcak yolunda çalışıyor; allocation yapmayan statik bir liste dön. GetPolicyAsync'in sonucunu hatırla ama ayar tablosu değiştiğinde dictionary'yi temizleyip ilgili tag'leri evict etmeyi unutma; yoksa yeni süre ancak uygulama yeniden başladığında devreye girer.

Sık Düşülen Tuzaklar

Custom policy'de vary kurallarını unutmak. VaryByTenantPolicy'yi base policy listesinden çıkarıp QueryKeys = "*" satırını sildiğimiz bir kopyada:

$ curl -s -H "X-Tenant: acme" "localhost:17562/api/catalog/menu?page=1"
{"tenant":"acme","page":1,"generatedAt":"02:13:06.368"}
$ curl -s -H "X-Tenant: acme" "localhost:17562/api/catalog/menu?page=2"
{"tenant":"acme","page":1,"generatedAt":"02:13:06.368"}
$ curl -s -H "X-Tenant: initech" "localhost:17562/api/catalog/menu?page=1"
{"tenant":"acme","page":1,"generatedAt":"02:13:06.368"}

İkinci sayfa birinci sayfayı, initech acme'nin menüsünü aldı. Varsayılan policy query string'i key'e kendisi ekliyordu; kendi policy'ni yazınca bu da senin işin. Tenant'ı key'e katmamak ise bir performans hatası değil, tenant'lar arası veri sızıntısı.

Varsayılan güvenlik kurallarını kaybetmek. Yalnız 200, yalnız GET/HEAD, Authorization header'lı ve Set-Cookie dönen yanıtları cache'lememe kuralları internal DefaultPolicy'de yaşıyor. Kendi policy'n bunları yeniden yazmazsa, kullanıcıya özel bir yanıt herkese servis edilebilir.

Hata veren yüklemeyi sonsuza dek hatırlamak. Provider'daki ConcurrentDictionary'ye Task koyduğumuz için, veritabanı bir kez hata verirse faulted Task orada kalır. Üretimde başarısız yüklemeyi dictionary'den çıkar ya da süreli bir cache kullan.

estimated_size'ı byte sanmak ve birden fazla cache'e aynı Name'i vermek. İlki senin Size birimin; ikincisinde iki cache'in ölçümleri aynı tag'le karışır ve hit ratio anlamsızlaşır.

Küçük Bir Deneme

★☆☆ Metric örneğinde covers cache'inin SizeLimit'ini 50 yap ve CompactionPercentage'ı değiştir. estimated_size, entries ve evictions nasıl değişiyor? Reddedilen eklemeleri bu üç metric'ten fark edebilir misin?

★★☆ BookCatalog'a ikinci bir kural ekle: repository TimeoutException fırlatırsa factory yakalasın, null dönsün ve Flags ile sonucu hiçbir katmana yazmasın. Repository'yi her üç çağrıdan birinde timeout verecek şekilde değiştirip race ölçümünü tekrarla: sayılar nasıl değişiyor?

★★★ TenantOutputCachePolicyProvider'ı ayar tablosundaki değişikliği 30 saniyede bir fark edecek şekilde genişlet: değişen policy'yi dictionary'den çıkar ve o policy'nin tag'iyle EvictByTagAsync çağır. globex'in süresini 0'dan 10 saniyeye çektiğinde uygulamayı yeniden başlatmadan cache'lenmeye başlıyor mu?

Bu yazıyla seri tamamlandı. "Önce ölç, sonra cache'le" diye başladık; IMemoryCache'ten Redis'e, HybridCache'ten HTTP caching ve output cache'e, invalidation'dan stampede'e ve ölçmeye kadar katmanları tek tek geçtik. .NET 11 eksik üç parçayı getiriyor: ölçüm kutudan çıkıyor, süre değere göre seçilebiliyor, output cache policy'leri çalışma anında çözülüyor. Hepsinin altındaki soru ise ilk yazıdaki gibi duruyor: bu veri değiştiğinde onu kim silecek ve eski hâlini kim, ne kadar süre görebilir? Redis'in cache dışındaki yüzünü ilerideki Veritabanı › Redis serisinde, CDN katmanını ilerideki Domain, DNS (Domain Name System) ve CDN serisinde ele alacağız.