ASP.NET Core Output Cache Derinlemesine: Policy'ler, Vary-By, Tag ile Eviction, Resource Locking ve Redis Store
Output cache'te güvenliği ve verimi policy'ler, vary-by kuralları ve middleware sırası belirler: Bookstore'da resource locking 100 eşzamanlı istekte handler'ı 1 kez çalıştırdı (kapalıyken 100), UseOutputCache'i authentication'dan önce koymak 10.0.3 runtime'ında Ada'nın kişisel önerilerini anonim kullanıcıya ve Grace'e servis etti. Redis store iki instance'a ortak cache ve eviction verir, lock ise instance başına kalır.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; örnekler ASP.NET Core 10.0.13 runtime'ı, Microsoft.AspNetCore.OutputCaching.StackExchangeRedis 10.0.14 ve Redis 8.2 ile koşturuldu. Middleware sırası bölümündeki bir karşılaştırma bilerek eski 10.0.3 runtime'ında çalıştırıldı.
Önce Tahmin Et
Bookstore'da giriş yapmış kullanıcıya kişisel kitap önerileri dönen bir endpoint var: /api/me/recommendations. Endpoint RequireAuthorization() ve CacheOutput() ile işaretli. Bir refactor sırasında app.UseOutputCache() satırı app.UseAuthentication()'ın üstüne taşınıyor ve kimse fark etmiyor. Ada giriş yapıp önerilerini açıyor, ardından giriş yapmamış bir ziyaretçi, sonra da Grace aynı URL'yi (Uniform Resource Locator) istiyor.
Üç soru. Anonim ziyaretçi ne görür: 401 mi, Ada'nın önerileri mi? Grace ne görür? Cevap, sunucudaki .NET 10 runtime'ının hangi patch sürümü olduğuna bağlı mı? Bir de bonus: 300 ms süren satış raporu endpoint'i output cache'li ve cache boş; aynı anda 100 istek geliyor. Handler kaç kez çalışır?
Policy'ler: Base, Named ve Kendi IOutputCachePolicy'n
Output cache'e giriş 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ındaydı: AddOutputCache, UseOutputCache, CacheOutput ve Age header'ı. HTTP (Hypertext Transfer Protocol) tarafındaki Cache-Control, ETag ve 304 ise serinin beşinci yazısının konusuydu. Bu yazıda output cache'i Bookstore'un katalog API'sinde gerçekten kuruyoruz ve her davranışı bir handler çalışma sayacıyla ölçüyoruz: her yanıtın içinde execution numarası var, aynı numara iki kez geliyorsa yanıt cache'ten gelmiştir.
builder.Services.AddOutputCache(options =>
{
// base policy: /api/books altındaki her yanıt "books" tag'ini taşır
options.AddBasePolicy(b => b
.With(c => c.HttpContext.Request.Path.StartsWithSegments("/api/books"))
.Tag("books"));
options.AddPolicy("Book", b => b.AddPolicy<BookTagPolicy>().Expire(TimeSpan.FromMinutes(10)));
options.AddPolicy("BookList", b => b.SetVaryByQuery("page", "sort").Expire(TimeSpan.FromMinutes(2)));
options.AddPolicy("Menu", b => b.SetVaryByHeader("Accept-Language").Expire(TimeSpan.FromMinutes(30)));
options.AddPolicy("Prices", b => b
.VaryByValue(ctx => new KeyValuePair<string, string>("currency", ctx.Request.Cookies["currency"] ?? "USD"))
.Expire(TimeSpan.FromMinutes(1)));
options.AddPolicy("Report", b => b.Expire(TimeSpan.FromSeconds(30)));
options.AddPolicy("ReportNoLock", b => b.Expire(TimeSpan.FromSeconds(30)).SetLocking(false));
options.AddPolicy("MembersShared", new MembersSharedPolicy());
options.MaximumBodySize = 64 * 1024;
});
Üç katman var. Base policy her isteğe uygulanır; burada With ile yalnız /api/books yoluna daraltıldı. Named policy'yi endpoint .CacheOutput("Book") ile seçer ve base policy'nin üstüne eklenir. Üçüncüsü görünmez olan: OutputCachePolicyBuilder varsayılan olarak içine Microsoft'un default policy'sini koyar. "Yalnız 200, yalnız GET ve HEAD, Set-Cookie yoksa, kullanıcı authenticated değilse" kuralları buradan gelir ve sorgu string'inin tamamını key'e katar. İstemiyorsan AddPolicy, AddBasePolicy ve CacheOutput'un excludeDefaultPolicy parametreli overload'larını kullanırsın.
Bu görünmez katmanın bir bedeli var. Learn'deki "her şeyi tek tag'le silebilmek için" örneğindeki gibi options.AddBasePolicy(b => b.Tag("all")) yazdım ve sayaçları dönen /stats endpoint'ini iki kez çağırdım, araya bir kitap isteği koyarak:
$ curl -s -i localhost:17531/stats | grep -E "Age|\{"
{}
$ curl -s localhost:17531/api/books/1 > /dev/null
$ curl -s -i localhost:17531/stats | grep -E "Age|\{"
Age: 0
{}
Kitap handler'ı çalıştı ama /stats hâlâ boş sayaç dönüyor, çünkü base policy default policy'yi de getirdi ve uygulamadaki her GET endpoint'ini cache'lenebilir yaptı. Aynı satırı excludeDefaultPolicy: true ile yazınca /stats cache'lenmedi, tag yine her entry'ye eklendi ve all tag'i ile eviction kitap entry'sini sildi.
Kural yetmediğinde IOutputCachePolicy'yi kendin yazarsın. Interface'in üç method'u isteğin üç anına denk geliyor: CacheRequestAsync handler'dan önce (lookup ve storage'a izin, key, tag, süre), ServeFromCacheAsync cache'ten yanıt dönülürken, ServeResponseAsync handler bittikten sonra ama yanıt saklanmadan önce (status code ve header'a bakıp vazgeçmek için). Kitap detayına route'tan gelen id ile dinamik bir tag ekleyen policy:
// route'taki id'den kitaba özel tag ("book:42") ekler; tek kitap tek başına evict edilebilsin
public sealed class BookTagPolicy : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
if (context.HttpContext.Request.RouteValues["id"] is string id)
context.Tags.Add($"book:{id}");
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask;
}
Tag("...") yalnız sabit string alır; entity'ye göre değişen tag'in tek yolu bu tür bir policy. Policy'leri isteğe göre dinamik çözen IOutputCachePolicyProvider .NET 11 ile geliyor; onu serinin onuncu yazısında göreceğiz. Web API serisindeki response caching ile output cache arasındaki farkı da burada netleştirelim, çünkü ikisi aynı sorunun iki farklı cevabı:
| Konu | Response caching | Output cache |
|---|---|---|
| Kararı kim verir | HTTP header'ları; istemcinin no-cache'i sunucu cache'ini atlatır | Sunucudaki policy; istemci header'ı atlatamaz |
| Depolama | Yalnız process belleği | IOutputCacheStore: bellek, Redis ya da kendi store'un |
| Programatik silme | Yok | Tag ile EvictByTagAsync |
| Stampede koruması | Yok | Resource locking, varsayılan açık |
| 304 revalidation | Yok | ETag ve If-Modified-Since ile kendiliğinden |
Varsayılan Kurallar Gerçekte Ne Yapıyor
Default policy'nin dört kuralını bir script ile denedim: her senaryo için iki aynı istek, ardından handler'ın kaç kez çalıştığı. Satırlarda status code, Age header'ı (yoksa -) ve yanıtta Set-Cookie olup olmadığı var:
GET /api/books/7 200 age=- cookie=no 200 age=0 cookie=no runs=1
GET /api/books/999 (404) 404 age=- cookie=no 404 age=- cookie=no runs=2
GET /api/home (new visitor) 200 age=- cookie=yes 200 age=- cookie=yes runs=2
GET /api/home (Cookie: visitor_id) 200 age=- cookie=no 200 age=0 cookie=no runs=1
GET /api/home (new visitor again) 200 age=0 cookie=no 200 age=0 cookie=no runs=0
GET /api/me/recommendations (ada) 200 age=- cookie=no 200 age=- cookie=no runs=2
GET /api/bestsellers (Authorization) 200 age=- cookie=no 200 age=- cookie=no runs=2
GET /api/catalog/export (93 KB) 200 age=- cookie=no 200 age=- cookie=no runs=2
Beklenenler: 404 saklanmadı, oturum cookie'siyle gelen Ada'nın isteği saklanmadı, 93 KB'lık export MaximumBodySize'ı (burada 64 KB, varsayılanı 64 MB) aştığı için sessizce saklanmadı. Authorization header'ı taşıyan istek ise cache'e hiç bakmadı bile; default policy bu header'ı görünce hem lookup'ı hem storage'ı kapatıyor. Bearer token'la çağrılan bir API'de output cache bu yüzden kendiliğinden devre dışıdır.
Asıl ilginç olan /api/home. Endpoint ilk kez gelen ziyaretçiye analytics için visitor_id cookie'si veriyor. Yeni ziyaretçinin yanıtında Set-Cookie olduğu için iki istek de cache'e girmedi. Cookie'si olan ziyaretçinin yanıtında Set-Cookie yok, o yanıt saklandı. Sonra yeni bir ziyaretçi geldi ve handler hiç çalışmadı: cache'teki yanıt döndü, Set-Cookie yok. Yani cache dolu olduğu sürece hiçbir yeni ziyaretçi o cookie'yi almıyor. Output cache, yanıtı handler'dan önce verdiği için yanıtı süsleyen her yan etki (cookie, sayaç, log) cache hit'te kaybolur.
Vary-By: Key'e Ne Girer, Ne Girmez
Varsayılan key; şema, host, port, path ve sorgu string'inin tamamından oluşur. Path büyük-küçük harf duyarsızdır (UseCaseSensitivePaths ile değişir). Header'lar ve cookie'ler key'e girmez. Script her isteğin execution numarasını ve Age'ini yazıyor:
GET /api/bestsellers?utm_source=mail execution=1 {"top":[7,42,13]}
GET /api/bestsellers?utm_source=social execution=2 {"top":[7,42,13]}
GET /api/books?page=2&utm_source=mail execution=1 {"items":20}
GET /api/books?page=2&utm_source=social execution=1 {"items":20} Age: 0
GET /api/books?page=2&sort=price execution=2 {"items":20}
GET /api/categories/menu (Accept-Language: tr-TR) execution=1 {"first":"Roman"}
GET /api/categories/menu (Accept-Language: en-US) execution=2 {"first":"Fiction"}
GET /api/categories/menu (Accept-Language: tr-TR) execution=1 {"first":"Roman"} Age: 0
GET /api/prices-naive/5 (Cookie: currency=USD) execution=1 {"id":5,"currency":"USD","price":15}
GET /api/prices-naive/5 (Cookie: currency=EUR) execution=1 {"id":5,"currency":"USD","price":15} Age: 0
GET /api/prices/5 (Cookie: currency=USD) execution=1 {"id":5,"currency":"USD","price":15}
GET /api/prices/5 (Cookie: currency=EUR) execution=2 {"id":5,"currency":"EUR","price":13.8}
İlk iki satır, default policy'nin kampanya linklerine ne yaptığını gösteriyor: UTM (Urchin Tracking Module) parametresinin her değeri ayrı bir entry, yani her bülten ve her sosyal medya paylaşımı cache'i sıfırdan doldurur. BookList policy'sindeki SetVaryByQuery("page", "sort") key'i yalnız bu iki parametreyle sınırladı; utm_source değişse de aynı entry döndü, sort değişince yeni entry oluştu.
SetVaryByHeader("Accept-Language") menüyü dile göre ayırdı. Burada dikkat: key'e header'ın ham değeri girer. Tarayıcılar tr-TR,tr;q=0.9,en-US;q=0.8 gibi onlarca farklı kombinasyon gönderir ve her biri ayrı entry olur. Uygulama zaten iki dil destekliyorsa header yerine çözülmüş kültürü VaryByValue ile key'e koymak entry sayısını ikiye indirir.
Son dört satır bu yazının en pahalı hatası. /api/prices-naive fiyatı currency cookie'sine göre hesaplıyor ama default policy cookie'yi key'e katmıyor. Euro isteyen kullanıcı, önceki kullanıcının dolar yanıtını aldı. Request cookie'si yanıtı cache dışı bırakmaz (yalnız response'taki Set-Cookie bırakır), bu yüzden handler'ın okuduğu her cookie, header ve claim ya key'e girmeli ya da endpoint cache'lenmemeli. Düzeltilmiş Prices policy'si cookie değerini VaryByValue ile key'e ekliyor ve iki para birimi iki ayrı entry oluyor.
Tag ile Eviction
Fiyat değişince yalnız o kitabın detay yanıtını silmek istiyoruz. Admin endpoint'i IOutputCacheStore'u inject edip BookTagPolicy'nin eklediği tag'i evict ediyor:
app.MapPut("/api/admin/books/{id:int}/price", async (int id, decimal price, Catalog catalog, IOutputCacheStore store, CancellationToken ct) =>
{
if (!catalog.UpdatePrice(id, price)) return Results.NotFound();
await store.EvictByTagAsync($"book:{id}", ct);
return Results.NoContent();
});
GET /api/books/42 price=12 execution=1
GET /api/books/43 price=13 execution=2
GET /api/books?page=1 execution=1
PUT price of book 42 (evict book:42) 204
GET /api/books/42 price=19.9 execution=3
GET /api/books/43 price=13 execution=2 Age: 0
GET /api/books?page=1 execution=1 Age: 0
POST evict tag books 204
GET /api/books/43 price=13 execution=4
GET /api/books?page=1 execution=2
42 numaralı kitabın detayı yeniden üretildi, 43 ve liste sayfası cache'te kaldı. Hassas eviction'ın sınırı da burada görünüyor: liste sayfası da 42'nin fiyatını içeriyorsa o sayfa eski fiyatı göstermeye devam eder. Ya listeleri de içerdikleri kitapların tag'leriyle işaretlersin (sayfa başına 20 tag, yönetmesi zor) ya da fiyat değişikliğinde base policy'nin verdiği kaba books tag'ini evict edersin. Bookstore'da fiyat günde birkaç kez değiştiği için ikincisini seçtik; listeyi iki dakikada bir yeniden üretmek ucuz.
Serinin dördüncü yazısında HybridCache'in RemoveByTagAsync'inin mantıksal olduğunu, değerlerin süreleri dolana kadar durduğunu gördük. Output cache'in iki store'u farklı: bellek store'u tag'e bağlı key'leri MemoryCache'ten gerçekten siliyor, Redis store'u da tag'in sorted set'ini tarayıp value key'lerini siliyor. Ama bellek store'u process içinde; üç instance'lı bir dağıtımda eviction'ı alan instance yalnız kendi kopyasını siler.
Resource Locking: Aynı Yanıt İçin Tek Çalışma
Tahmindeki rapor sorusu. İki endpoint aynı işi yapıyor (300 ms bekleyip rapor dönüyor), biri varsayılan Report policy'siyle, diğeri SetLocking(false) eklenmiş ReportNoLock ile. Küçük bir console uygulaması 100 isteği aynı anda başlatıyor; uygulamayı her turda yeniden başlatarak üç kez koşturdum, üç turda da sonuç aynıydı:
100 concurrent GET /api/reports/sales: 200 x100
100 concurrent GET /api/reports/sales-nolock: 200 x100
{"report":1,"report-nolock":100}
Tahminin cevabı: 1. Locking açıkken middleware aynı key için ilk isteği handler'a gönderiyor, diğer 99'u onun sonucunu bekletip aynı yanıtı veriyor. Kapalıyken boş cache'e gelen 100 isteğin hepsi handler'ı çalıştırdı; raporun arkasında pahalı bir SQL (Structured Query Language) sorgusu olsaydı veritabanı aynı sorguyu 100 kez görecekti. Stampede'in teorisi System Design serisinin "Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede" yazısında; output cache'te korumayı bedavaya alıyorsun, kapatmak için ölçülmüş bir sebebin olmalı.
Middleware Sırası: Yetkisiz Kullanıcıya Cache Servis Etmek
Learn'ün kuralı net: authentication ve authorization kullanan uygulamada UseOutputCache, UseAuthentication ve UseAuthorization'dan sonra gelir, CORS (Cross-Origin Resource Sharing) varsa UseCors'tan da sonra. Tahmindeki hatalı ve doğru sıra:
// yanlış: cache lookup, kimlik doğrulama ve yetki kontrolünden önce yapılıyor
app.UseOutputCache();
app.UseAuthentication();
app.UseAuthorization();
// doğru
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();
Kimlik doğrulama örnekte bookstore_session cookie'sini okuyan küçük bir authentication handler'ı; Authorization header'ı yok, tıpkı cookie tabanlı bir web uygulamasında olduğu gibi. Aynı senaryoyu hatalı sırayla hem bilerek eski 10.0.3 runtime'ında hem bu yazının runtime'ında koşturdum:
== order=wrong
runtime 10.0.3
ada GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
anon GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
grace GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
ada GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
anon GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
== order=wrong
runtime 10.0.13
ada GET /api/me/recommendations 200 {"user":"ada","picks":["Book for ada"]} execution=1
anon GET /api/me/recommendations 401
grace GET /api/me/recommendations 200 {"user":"grace","picks":["Book for grace"]} execution=2
ada GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
anon GET /api/members/deals 200 {"deal":"Members save 20%"} execution=1
10.0.3'te tahminin en kötü cevabı gerçekleşti: anonim ziyaretçi de Grace de Ada'nın kişisel önerilerini aldı. Sebep sırada. Output cache middleware'i çalıştığında authentication henüz çalışmamış, User anonim ve Authorization header'ı yok; default policy isteği cache'lenebilir sayıyor. Handler Ada için yanıt üretiyor ve yanıt saklanıyor. Sonraki istekler authorization'a hiç ulaşmadan cache'ten dönüyor.
10.0.13'te kişisel öneriler sızmadı. dotnet/aspnetcore deposunda "Avoid caching responses for authenticated users" başlıklı bir güvenlik düzeltmesi var; etiketlere baktığımda v10.0.9'da olmayan kontrol v10.0.10'da geliyor. Default policy artık yanıtı saklamadan önce de kullanıcının authenticated olup olmadığına bakıyor. Ama ikinci endpoint hâlâ sızdırıyor. /api/members/deals bütün üyelere aynı içeriği gösterdiği için bilerek authenticated istekleri cache'leyen özel bir policy kullanıyor:
// her giriş yapmış üyeye aynı içerik: authenticated isteği cache'lemek burada bilinçli bir karar
public sealed class MembersSharedPolicy : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
bool isGet = HttpMethods.IsGet(context.HttpContext.Request.Method);
context.EnableOutputCaching = true;
context.AllowCacheLookup = isGet;
context.AllowCacheStorage = isGet;
context.AllowLocking = true;
context.ResponseExpirationTimeSpan = TimeSpan.FromMinutes(5);
context.Tags.Add("deals");
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 || response.Headers.SetCookie.Count > 0)
context.AllowCacheStorage = false;
return ValueTask.CompletedTask;
}
}
Policy doğru; hata sırada. Cache lookup authorization'dan önce yapıldığı için cache'teki üye içeriği anonim isteğe 200 ile döndü. Doğru sırayla aynı runtime'da anonim istek iki endpoint'te de 401 aldı, Grace kendi önerilerini gördü. Patch'li runtime bir güvenlik ağı; sıranın yerini tutmaz.
Redis Store ile Çok Sunucu
Bellek store'uyla her instance'ın kendi cache'i, kendi eviction'ı var. Ortak store için AddStackExchangeRedisCache değil, ayrı paketteki AddStackExchangeRedisOutputCache:
builder.Services.AddStackExchangeRedisOutputCache(o =>
{
o.Configuration = "localhost:17530,asyncTimeout=500";
o.InstanceName = "bookstore:";
});
İki instance'ı (A: 17531, B: 17532) aynı Redis'e bağladım. İlk denemede istekleri doğrudan localhost:17531 ve localhost:17532'ye attım ve B, A'nın cache'lediği yanıtı kullanmak yerine handler'ı yeniden çalıştırdı. Redis'te iki ayrı value key vardı; key'in içinde host ve port var. Load balancer arkasında iki instance da aynı public host'u görür; testte bunu Host: shop.bookstore.test header'ıyla taklit edince:
A: GET /api/books/42 renderedBy=A execution=1
B: GET /api/books/42 renderedBy=A execution=1 Age: 0
stats A={"book":1} B={}
B: POST evict book:42 -> 204
A: GET /api/books/42 renderedBy=A execution=2
B: GET /api/books/42 renderedBy=A execution=2 Age: 0
B hiç handler çalıştırmadan A'nın ürettiği yanıtı döndü; B'de yapılan eviction A'nın da cache'ini temizledi. Redis'teki yapı:
$ docker exec chwork-redis-c redis-cli --no-raw --scan | sort
"bookstore:__MSOCT"
"bookstore:__MSOCT_book:42"
"bookstore:__MSOCT_books"
"bookstore:__MSOCV_GET\x1eHTTP\x1eSHOP.BOOKSTORE.TEST/API/BOOKS/42\x1eQ\x1e*="
$ docker exec chwork-redis-c redis-cli --no-raw ZRANGE 'bookstore:__MSOCT_book:42' 0 -1 WITHSCORES
1) "GET\x1eHTTP\x1eSHOP.BOOKSTORE.TEST/API/BOOKS/42\x1eQ\x1e*="
2) "1794104918113"
Value key bir string; TTL'i (Time to Live) policy'deki 10 dakika (600 s), boyutu 188 byte. Key'in sonundaki Q\x1e*= default policy'nin "bütün sorgu parametreleri" kuralı. Her tag bir sorted set: üyeleri cache key'leri, skorları milisaniye cinsinden bitiş zamanı. Paketin kaynağına göre store yaklaşık beş dakikada bir süresi geçmiş tag üyelerini temizleyen bir döngü çalıştırıyor. Microsoft Learn'ün IDistributedCache'i output cache için önermemesinin sebebi de bu yapı: tag'leme atomik işlemler istiyor, IDistributedCache'in arayüzünde bunlar yok. Redis dışında bir store gerekiyorsa IOutputCacheStore'u o depolamaya doğrudan yazarsın.
Resource locking ise Redis'e taşınmıyor. Boş cache'e iki instance'a aynı anda 50'şer istek gönderdim, üç turda da sonuç aynı:
50 concurrent GET /api/reports/sales: 200 x50
50 concurrent GET /api/reports/sales: 200 x50
stats A={"report":1} B={"report":1}
Lock process içinde; N instance en fazla N kez çalıştırır. Çoğu endpoint için kabul edilebilir bir sayı. Gerçekten tek bir hesaplama gerekiyorsa distributed lock gerekir, onu serinin sekizinci yazısında stampede ile birlikte ele alacağız.
Üretimde Dikkat
Redis store'da her cache hit bir ağ gidiş dönüşü. Redis yavaşladığında ne olduğunu container'ı docker pause ile dondurarak denedim. Middleware hatayı yutup isteği handler'a düşürüyor, yani istek başarılı, ama önce bekliyor:
== redis=localhost:17530
200 11.943515s
200 11.984493s
Unable to query output cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 5941ms elapsed, timeout is 5000ms), command=GET, next: G
Unable to write to output-cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 5980ms elapsed, timeout is 5000ms), command=SETEX, next:
== redis=localhost:17530,asyncTimeout=500
200 1.962664s
200 1.983567s
Unable to query output cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 959ms elapsed, timeout is 500ms), command=GET, next: GET
Unable to write to output-cache.
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 984ms elapsed, timeout is 500ms), command=SETEX, next: G
Log'da önce Unable to query output cache, sonra Unable to write to output-cache var; okuma ve yazma ayrı ayrı timeout'a takıldığı için istek 12 saniye sürdü. asyncTimeout=500 ile 2 saniyeye indi, 1'e değil: StackExchange.Redis dokümanına göre async timeout'lar HeartbeatInterval'da (varsayılan 1000 ms) kontrol ediliyor. Daha hızlı düşmek için circuit breaker gerekir; Redis düşerken ayakta kalmayı sekizinci yazıda ele alacağız.
Bellek store'unun SizeLimit'i varsayılan 100 MB ve dolunca yeni yanıtlar eskiler çıkana kadar saklanmıyor. MaximumBodySize'ın varsayılanı 64 MB; tek bir büyük export cache'in yarısını yiyebilir, bu yüzden gerçek yanıt boyutlarına bakıp düşür. Deploy'da da dikkat: key'de uygulama sürümü yok. JSON (JavaScript Object Notation) şekli değişen bir sürüm çıktığında Redis'teki eski yanıtlar TTL dolana kadar yeni instance'lardan servis edilir; deploy sonrası excludeDefaultPolicy: true ile eklenmiş bir all tag'ini evict et ya da sürümü VaryByValue ile key'e koy. Hit oranını ölçmek için Age header'ı taşıyan yanıtların oranı iyi bir başlangıç; ayrıntılı ölçüm serinin dokuzuncu yazısında.
Sık Düşülen Tuzaklar
UseOutputCache'i authentication'dan önce koymak. Patch'li runtime default policy'yi korur, bilinçli olarak authenticated isteği cache'leyen policy'leri korumaz.
AddBasePolicy(b => b.Tag("all")) yazmak. Base policy default policy'yi getirir ve her GET endpoint'i cache'lenebilir olur; yalnız tag eklemek istiyorsan excludeDefaultPolicy: true.
Handler'ın okuduğu cookie'yi ya da header'ı key'e katmamak. Para birimi, dil, tenant, A/B test grubu: hepsi ya vary-by'a girer ya endpoint cache'lenmez.
Cache'li endpoint'e yan etki koymak. Cookie, sayaç ve log cache hit'te çalışmaz; yeni ziyaretçiye cookie vermek gibi işleri cache'lenmeyen bir endpoint'e ya da istemciye taşı.
Instance'ların farklı Host görmesi. Redis store paylaşılsa da key'de host olduğu için iç adresle gelen istekler ayrı entry üretir.
Purge endpoint'ini açık bırakmak. Örnekteki admin endpoint'leri sade kalsın diye yetkisiz; gerçek uygulamada EvictByTagAsync çağıran her endpoint yetki ister, yoksa herkes cache'ini boşaltıp veritabanını yükün altına sokabilir.
Küçük Bir Deneme
★☆☆ /api/categories/menu'yü Accept-Language: tr-TR,tr;q=0.9 ve Accept-Language: tr ile çağır. Kaç entry oluştu? Sonra SetVaryByHeader'ı çözülmüş kültürü dönen bir VaryByValue ile değiştir ve sayıyı tekrar ölç.
★★☆ /api/home'daki cookie sorununu çöz: cookie'yi veren kodu ayrı bir middleware'e taşı ve o middleware'i UseOutputCache'ten önce çalıştır. Yeni ziyaretçi cookie'yi alıyor mu, /api/home hâlâ cache'ten dönüyor mu? Gördüğün sonucu default policy'nin Set-Cookie kuralıyla açıkla.
★★★ IOutputCacheStore'u saran bir decorator yaz: her GetAsync çağrısında hit ya da miss sayısını artırsın, her EvictByTagAsync'te tag'i loglasın. İki instance'lı Redis kurulumunda raporu 100 istekle çalıştır ve hit oranını hesapla.
Output cache'te eviction'ı tek bir admin endpoint'inden yaptık, ama gerçek bir sistemde veri bir sürü yoldan değişir: admin paneli, import job'ı, başka bir service'in event'i. Bir sonraki yazıda invalidation stratejilerine geçiyoruz: TTL, explicit silme, event ile invalidation, versioned key'ler ve "veritabanına yaz, sonra sil" sırasının neden yine de yanlış veri bırakabildiği.