Cache'i Ölçmek ve Test Etmek: Hit Ratio, Metric'ler, Bellek Kullanımı ve Tutarlılık Testleri
Cache'in faydası hit ratio'yla değil p95 ve veritabanı yüküyle kanıtlanır: rapor cache'i p95'i 245 ms'den 1,3 ms'ye, 3.001 sorguyu 1'e indirdi; %87 hit ratio'lu kitap cache'i p95'i değiştirmedi ama sorguları %87 azalttı; %16 hit ratio'lu cache kaldırılmalı. .NET 10'da TrackStatistics, Meter ve HybridCache EventSource ile ölçülür; FakeTimeProvider ve Testcontainers testleri tag invalidation'ın diğer sunucuya ulaşmadığını yakaladı.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; örnekler Microsoft.Extensions.Caching.Hybrid 10.11.0, StackExchange.Redis 3.4.31, OpenTelemetry 1.20.1, Microsoft.Extensions.TimeProvider.Testing 10.11.0, Testcontainers.Redis 4.16.0, xUnit v3 4.0.2, Redis 8.2 ve k6 2.3 ile koşturuldu.
Önce Tahmin Et
Bookstore'da kitap detayı için ayrı bir IMemoryCache var: 10.000 entry sınırı, 60 saniye TTL (Time to Live). Sorgu, ISBN (International Standard Book Number) üzerindeki unique index'ten tek satır okuyor. İki ayrı trafik dağılımı deniyoruz: birinde 50.000 kitabın hepsi eşit olasılıkla isteniyor, ötekinde isteklerin yüzde 90'ı en popüler 1.000 kitaba gidiyor. Saniyede 1.000 istekte, 30 saniye boyunca.
Üç soru. Eşit dağılımda hit ratio kaç çıkar? Popüler dağılımda hit ratio yüzde 87 olursa p95 ne kadar düşer? Ve bu cache'lerden hangisini kaldırmalısın?
Hangi Sayılar, Nereden?
Serinin ilk yazısında "cache'i koyduğun gün ölçümü de koy" demiştik. .NET 10'da bu ölçümün parçaları farklı yerlerden geliyor ve bazıları hazır değil. Tabloda L1 (level 1, process içi cache) ve L2 (level 2, Redis gibi ortak cache) ayrımı serinin önceki yazılarındaki gibi:
| Sayı | .NET 10'da kaynağı | Not |
|---|---|---|
| L1 hit, miss, entry sayısı, tahmini boyut | TrackStatistics ve GetCurrentStatistics() | Metric'e çevirmek sana kalıyor |
| Eviction sayısı ve nedeni | PostEvictionCallbacks ve kendi Counter'ın | HybridCache'in L1'inde erişilemiyor |
| L2 hit/miss, factory çağrısı, stampede join | Microsoft-Extensions-HybridCache EventSource | Process içinden EventListener ile |
| L2 latency, uygulama tarafı | IDistributedCache decorator'ı ve Histogram | Ağ ve client dahil |
| Redis hit/miss, eviction, komut süresi | INFO stats, INFO commandstats, redis-cli --latency | Sunucu tarafı |
| Veritabanı yükü | Entity Framework Core (EF Core) interceptor'ı | Cache'in asıl korumak istediği şey |
.NET 11'de MemoryCache bu metric'lerin bir kısmını kendisi yayınlıyor; o, serinin onuncu yazısının konusu. Burada .NET 10'da bugün çalışanı kuruyoruz.
IMemoryCache İstatistiklerini Metric'e Çevirmek
MemoryCacheOptions.TrackStatistics açıkken GetCurrentStatistics() toplam hit, toplam miss, anlık entry sayısı ve SizeLimit varsa tahmini boyutu dönüyor; kapalıyken null. Bu değerleri System.Diagnostics.Metrics ile OpenTelemetry'nin okuyabileceği instrument'lara çeviriyoruz:
// IMemoryCache istatistiklerini ve eviction'ları OpenTelemetry'nin okuyabileceği metric'lere çevirir
public sealed class CacheMetrics
{
public const string MeterName = "Bookstore.Caching";
private readonly ConcurrentDictionary<string, IMemoryCache> _caches = new();
private readonly Counter<long> _evictions;
private readonly Histogram<double> _l2Duration;
public CacheMetrics(IMeterFactory meterFactory)
{
Meter meter = meterFactory.Create(MeterName);
// hit/miss sayaçları cache'in kendi istatistiğinden gelir, exporter okurken hesaplanır
meter.CreateObservableCounter("bookstore.cache.requests", () => Observe(stats =>
[(stats.TotalHits, "hit"), (stats.TotalMisses, "miss")]), unit: "{request}");
meter.CreateObservableGauge("bookstore.cache.entries", () => Observe(stats =>
[(stats.CurrentEntryCount, null)]), unit: "{entry}");
meter.CreateObservableGauge("bookstore.cache.estimated_size", () => Observe(stats =>
[(stats.CurrentEstimatedSize ?? 0, null)]), unit: "{size_unit}");
_evictions = meter.CreateCounter<long>("bookstore.cache.evictions", unit: "{entry}");
_l2Duration = meter.CreateHistogram<double>("bookstore.cache.l2.duration", unit: "ms");
}
public void Track(string name, IMemoryCache cache) => _caches[name] = cache;
public void RecordEviction(string cache, EvictionReason reason) =>
_evictions.Add(1, new("cache", cache), new("reason", reason.ToString()));
public void RecordL2(string operation, double milliseconds) =>
_l2Duration.Record(milliseconds, new KeyValuePair<string, object?>("operation", operation));
private IEnumerable<Measurement<long>> Observe(Func<MemoryCacheStatistics, (long Value, string? Result)[]> select)
{
foreach ((string name, IMemoryCache cache) in _caches)
{
if (cache.GetCurrentStatistics() is not { } stats) continue;
foreach ((long value, string? result) in select(stats))
{
yield return result is null
? new Measurement<long>(value, new KeyValuePair<string, object?>("cache", name))
: new Measurement<long>(value, new("cache", name), new("result", result));
}
}
}
}
Hit ve miss toplamları zaten cache'in içinde tutuluyor, o yüzden ObservableCounter kullanıyoruz: callback yalnız exporter metric'i okurken (burada 10 saniyede bir) çalışıyor, istek yolunda hiçbir maliyet yok. Entry sayısı ve boyut anlık değer, onlar ObservableGauge. Her cache bir cache tag'iyle ayrılıyor; tag'e asla key koyma, her key ayrı bir zaman serisi olur. Eviction ise istatistikte yok, onu kitap cache'inin kendisi sayıyor:
// kitap detayı için ayrı bir L1: entry sayısıyla sınırlı, istatistikleri ve eviction'ları ölçülüyor
public sealed class BookCache
{
private readonly MemoryCache _cache;
private readonly CacheMetrics _metrics;
private readonly TimeSpan _ttl;
public BookCache(CacheMetrics metrics, IConfiguration configuration)
{
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = configuration.GetValue("BookCacheEntries", 10_000),
TrackStatistics = true
});
_metrics = metrics;
_ttl = TimeSpan.FromSeconds(configuration.GetValue("BookTtlSeconds", 60));
metrics.Track("books", _cache);
}
public IMemoryCache Inner => _cache;
public Task<BookDto?> GetOrLoadAsync(string isbn, Func<Task<BookDto?>> load) =>
_cache.GetOrCreateAsync($"v1:book:{isbn}", entry =>
{
entry.Size = 1;
entry.AbsoluteExpirationRelativeToNow = _ttl;
entry.RegisterPostEvictionCallback((_, _, reason, _) => _metrics.RecordEviction("books", reason));
return load();
});
}
Kayıt tarafında HybridCache'in kullandığı paylaşılan IMemoryCache'in de istatistiklerini açıyoruz. Onun SizeLimit'i byte cinsinden, çünkü HybridCache L1 entry'sinin Size'ını serialize edilmiş payload'ın byte sayısı olarak veriyor. Metric'leri OpenTelemetry console exporter'ı ile görüyoruz; üretimde onun yerine OTLP (OpenTelemetry Protocol) exporter'ı gelir:
// L1 istatistikleri açık; SizeLimit byte cinsinden (HybridCache entry Size'ını payload byte'ı olarak verir)
builder.Services.AddMemoryCache(o =>
{
o.TrackStatistics = true;
o.SizeLimit = builder.Configuration.GetValue<long>("L1SizeLimitBytes", 64 * 1024 * 1024);
});
IConnectionMultiplexer redis = await ConnectionMultiplexer.ConnectAsync(builder.Configuration["Redis"]!);
builder.Services.AddSingleton(redis);
builder.Services.AddSingleton<CacheMetrics>();
builder.Services.AddSingleton<BookCache>();
builder.Services.AddSingleton<IDistributedCache>(sp => new TimedDistributedCache(
new RedisCache(Options.Create(new RedisCacheOptions { ConnectionMultiplexerFactory = () => Task.FromResult(redis) })),
sp.GetRequiredService<CacheMetrics>()));
builder.Services.AddHybridCache();
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("bookstore-catalog"))
.WithMetrics(metrics => metrics
.AddMeter(CacheMetrics.MeterName)
.AddView("bookstore.cache.l2.duration", new ExplicitBucketHistogramConfiguration { Boundaries = [0.25, 0.5, 1, 2, 5, 10] })
.AddConsoleExporter((_, reader) => reader.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 10_000));
TimedDistributedCache, Redis'in IDistributedCache'ini saran ve her GetAsync/SetAsync süresini Stopwatch.GetTimestamp ile RecordL2'ye yazan otuz satırlık bir decorator. Eşit dağılımlı k6 koşusunun sonunda exporter'ın son çıktısından kitap cache'ine ait satırlar:
Metric Name: bookstore.cache.requests, Unit: {request}, Metric Type: LongSum
(2026-11-08T02:41:50.0450610Z, 2026-11-08T02:42:30.0406080Z] cache: books result: hit
Value: 4813
(2026-11-08T02:41:50.0450610Z, 2026-11-08T02:42:30.0406080Z] cache: books result: miss
Value: 25044
Metric Name: bookstore.cache.entries, Unit: {entry}, Metric Type: LongGauge
(2026-11-08T02:41:50.0456340Z, 2026-11-08T02:42:30.0406130Z] cache: books
Value: 9858
Metric Name: bookstore.cache.evictions, Unit: {entry}, Metric Type: LongSum
(2026-11-08T02:41:50.0456500Z, 2026-11-08T02:42:30.0406170Z] cache: books reason: Replaced
Value: 1
(2026-11-08T02:41:50.0456500Z, 2026-11-08T02:42:30.0406170Z] cache: books reason: Capacity
Value: 15185
İlk tahminin cevabı: 4.813 / (4.813 + 25.044), yani yüzde 16,1. Cache dolu (9.858 entry, sınır 10.000) ve 30 saniyede 15.185 entry kapasite yüzünden atıldı: cache, işe yarayacak kadar uzun tutamadığı veriyi sürekli yazıp siliyor. Hit ratio'yu dashboard'da PromQL (Prometheus Query Language) ya da benzeri bir sorguyla bu iki sayaçtan hesaplarsın; uygulamada hesaplayıp gauge olarak yayınlamak, farklı instance'ları toplarken yanlış ortalama verir.
HybridCache'in Kendi Sayaçları
HybridCache, L2 ve factory tarafını Microsoft-Extensions-HybridCache adlı bir EventSource'ta, total-local-cache-hits, total-distributed-cache-misses, total-data-query, total-stampede-joins gibi sayaçlarla yayınlıyor. Bu sayaçlar yalnız biri dinlerken artıyor. dotnet-counters gibi global bir araç kurmadan process içinden okumak için küçük bir EventListener yeterli:
// HybridCache'in kendi EventSource sayaçlarını process içinden okur (dotnet-counters'a gerek yok)
public sealed class HybridCacheCounters : EventListener
{
private readonly ConcurrentDictionary<string, double> _values = new();
public IReadOnlyDictionary<string, double> Snapshot => _values;
protected override void OnEventSourceCreated(EventSource source)
{
if (source.Name == "Microsoft-Extensions-HybridCache")
{
EnableEvents(source, EventLevel.Verbose, EventKeywords.All,
new Dictionary<string, string?> { ["EventCounterIntervalSec"] = "1" });
}
}
protected override void OnEventWritten(EventWrittenEventArgs e)
{
if (e.EventName != "EventCounters" || e.Payload is not [IDictionary<string, object> counter]) return;
// PollingCounter "Mean" alanında son değeri taşır
_values[(string)counter["Name"]] = Convert.ToDouble(counter["Mean"]);
}
}
Kitapları bu kez HybridCache ile, L1'de 5 saniye ve L2'de 5 dakika tutan bir endpoint'ten okuyoruz; popüler dağılım, saniyede 1.000 istek, 30 saniye. /stats endpoint'i interceptor'ın sorgu sayısını, iki IMemoryCache'in istatistiğini ve EventSource sayaçlarını birlikte dönüyor:
$ curl -s localhost:17545/stats
{"queries":3924,"books":{"hits":0,"misses":0,"hitRatio":0,"entries":0,"size":0},"hybridL1":{"hits":21638,"misses":8213,"hitRatio":0.725,"entries":3924,"size":268697},"hybrid":{"current-data-query":0,"current-distributed-cache-fetches":0,"total-data-query":3924,"total-distributed-cache-hits":4273,"total-distributed-cache-misses":3924,"total-distributed-cache-writes":3924,"total-local-cache-hits":21638,"total-local-cache-misses":8213,"total-local-cache-writes":8197,"total-stampede-joins":16,"total-tag-invalidations":0}}
Okuması şöyle: 29.851 isteğin 21.638'i L1'den döndü (yüzde 72,5), L1'i kaçıran 8.213 isteğin 4.273'ü Redis'te buldu, 3.924'ü veritabanına gitti. Toplam cache hit oranı yüzde 86,9; veritabanının gördüğü sorgu sayısı da total-data-query ile birebir aynı. 3.924 kitap L1'de 268.697 byte, entry başına 68 byte tutuyor; bellek bütçesini bu sayıdan çıkarırsın. total-stampede-joins ise aynı key için factory'yi bekleyip sonucunu paylaşan 16 çağrı. Bir rakam daha önemli: L1 miss sayısı veritabanı sorgu sayısı değil. Kategori raporunda L1 29 miss gördü ama veritabanına 1 sorgu gitti, çünkü eşzamanlı miss'ler tek factory çağrısında birleşti.
Redis Tarafı: INFO, Komut Süreleri ve Latency
Aynı koşunun Redis'teki karşılığı, koşudan önce CONFIG RESETSTAT ile sıfırlanmış sayaçlardan:
$ docker exec chwork-redis-d redis-cli INFO stats | grep -E "keyspace_(hits|misses)|evicted_keys"
evicted_keys:0
keyspace_hits:4273
keyspace_misses:3927
$ docker exec chwork-redis-d redis-cli INFO commandstats | grep -E "hmget|hmset"
cmdstat_hmget:calls=8198,usec=21075,usec_per_call=2.57,rejected_calls=0,failed_calls=0
cmdstat_hmset:calls=3924,usec=17994,usec_per_call=4.59,rejected_calls=0,failed_calls=0
keyspace_hits 4.273 ile HybridCache'in L2 hit'i birebir aynı; miss'teki 3 fark, bağlantı açılışındaki birkaç başka okuma. evicted_keys sıfır, çünkü bu container'da maxmemory tanımlı değil; dolarsa ne olacağı serinin üçüncü yazısındaki eviction policy konusu. Redis, bir HMGET'i ortalama 2,57 mikrosaniyede işliyor. Uygulamanın aynı çağrılar için ölçtüğü histogram ise başka bir hikâye anlatıyor:
Metric Name: bookstore.cache.l2.duration, Unit: ms, Metric Type: Histogram
(2026-11-08T02:50:25.0806260Z, 2026-11-08T02:51:05.0764660Z] operation: get
Value: Sum: 14017.185399999971 Count: 8198 Min: 0.107 Max: 260.0854
(-Infinity,0.25]:3372
(0.25,0.5]:3829
(0.5,1]:817
(1,2]:32
(2,5]:23
(5,10]:1
(10,+Infinity]:124
8.198 okumanın yüzde 87,8'i yarım milisaniyenin altında, ama 124'ü 10 ms'yi aştı ve en yavaşı 260 ms sürdü; ortalama 1,7 ms. Container içinden redis-cli --latency-history -i 3 ise ağı neredeyse hiç görmüyor:
min: 0, max: 2, avg: 0.20 (253 samples) -- 3.01 seconds range
min: 0, max: 3, avg: 0.19 (251 samples) -- 3.00 seconds range
Üç sayı üç ayrı katmanı ölçüyor: sunucunun komutu işleme süresi (mikrosaniyeler), sunucuya en yakın noktadan round trip (0,2 ms) ve uygulamanın gerçekten beklediği süre (ağ, Docker Desktop'ın sanal ağı, multiplexer kuyruğu ve thread pool dahil). L2 latency için alarm kuracaksan üçüncüsünü kullan; "Redis yavaş" şikâyeti çoğu zaman sunucu tarafında hiç görünmez.
Cache Gerçekten İşe Yarıyor mu?
Hit ratio tek başına karar vermez; asıl soru cache'in p95'i ve veritabanı yükünü ne kadar değiştirdiği. Her satır, uygulama yeniden başlatılıp Redis ve sayaçlar sıfırlandıktan sonra 30 saniyelik bir constant-arrival-rate koşusu. k6 tarafında dağılımı tek bir fonksiyon belirliyor:
function bookId() {
if (__ENV.DIST === 'hot' && Math.random() < 0.9) return 1 + Math.floor(Math.random() * 1000);
return 1 + Math.floor(Math.random() * 50000);
}
| Endpoint ve trafik | p95 cache'siz | p95 cache'li | Sorgu cache'siz | Sorgu cache'li | Hit ratio |
|---|---|---|---|---|---|
| Kategori raporu, 100 istek/sn | 245,3 ms | 1,3 ms | 3.001 | 1 | %99 |
| Kitap detayı, eşit dağılım, 1.000 istek/sn | 1,2 ms | 1,4 ms | 29.873 | 25.044 | %16,1 |
| Kitap detayı, popüler dağılım, 1.000 istek/sn | 1,2 ms | 1,1 ms | 29.867 | 3.870 | %87 |
İkinci tahminin cevabı: p95 neredeyse hiç değişmiyor (1,2 ms'den 1,1 ms'ye). Index'li tek satır okuma zaten ucuz; isteğin süresini HTTP (Hypertext Transfer Protocol) katmanı ve serialization belirliyor. Popüler dağılımdaki cache'in kazancı gecikme değil, veritabanı yükü: sorgu sayısı yüzde 87 azaldı. Veritabanı paylaşılan ve pahalı bir kaynaksa bu cache yerinde duruyor, ama gerekçesi "hızlandırıyor" değil "veritabanını koruyor" olarak yazılmalı.
Eşit dağılımdaki cache ise kaldırılmalı. Hit ratio yüzde 16, p95 biraz kötüleşmiş (1,2'den 1,4 ms'ye), sorgular yalnız yüzde 16 azalmış ve bunun için 9.858 entry bellekte tutulup 30 saniyede 15.185 eviction yaşanmış. Cache ayrıca her yazma yolunda invalidation sorumluluğu getiriyor. Kural olarak şunu kullanabilirsin: hit ratio düşükse ve kaynak sorgu ucuzsa cache'i kaldır; hit ratio düşük ama sorgu pahalıysa önce key tasarımına ve TTL'e bak (çok parçalı key, gereğinden kısa TTL); hit ratio yüksek ama p95 değişmiyorsa cache'in tek işi veritabanını korumaktır, onu ölçüp öyle savun. Kategori raporu her üç ölçüde de kazanıyor ve tartışmaya gerek bırakmıyor.
FakeTimeProvider ile Expiration Testi
Expiration testini Thread.Sleep ile yazmak hem yavaş hem kırılgan. Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider saati elle ilerletmeni sağlıyor. Ama MemoryCacheOptions.Clock .NET 10'da hâlâ Microsoft.Extensions.Internal.ISystemClock tipinde; TimeProvider alan bir option yok, o yüzden iki satırlık bir köprü gerekiyor:
// MemoryCacheOptions.Clock hâlâ ISystemClock bekliyor; TimeProvider'ı ona bağlayan köprü
public sealed class TimeProviderClock(TimeProvider time) : ISystemClock
{
public DateTimeOffset UtcNow => time.GetUtcNow();
}
public sealed class MemoryCacheExpirationTests
{
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 11, 8, 9, 0, 0, TimeSpan.Zero));
private MemoryCache CreateCache() => new(new MemoryCacheOptions { Clock = new TimeProviderClock(_time) });
[Fact]
public void Absolute_expiration_removes_entry_after_ttl()
{
using MemoryCache cache = CreateCache();
cache.Set("v1:category-menu", "menu", TimeSpan.FromMinutes(5));
_time.Advance(TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(59));
Assert.True(cache.TryGetValue("v1:category-menu", out _));
_time.Advance(TimeSpan.FromSeconds(2));
Assert.False(cache.TryGetValue("v1:category-menu", out _));
}
[Fact]
public void Sliding_expiration_is_extended_by_reads()
{
using MemoryCache cache = CreateCache();
cache.Set("v1:cart:42", "cart", new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(1) });
// her 50 saniyede bir okunan entry ölmez
for (int i = 0; i < 5; i++)
{
_time.Advance(TimeSpan.FromSeconds(50));
Assert.True(cache.TryGetValue("v1:cart:42", out _));
}
_time.Advance(TimeSpan.FromSeconds(61));
Assert.False(cache.TryGetValue("v1:cart:42", out _));
}
}
HybridCache'te tablo biraz daha karışık. L1 süresi altta yatan IMemoryCache'in saatine bakıyor, yani yukarıdaki köprü orada da şart. Tag invalidation zamanlarını ise HybridCache DI (dependency injection) container'ındaki TimeProvider'dan alıyor (kaynak kodda services.GetService<TimeProvider>() ?? TimeProvider.System). İkisini aynı sahte saate bağlayan bir "sunucu" kurucusu ve onu kullanan küçük bir menü service'i yazdık:
public static class Instance
{
// bir "sunucu": kendi L1'i, isteğe bağlı ortak Redis L2'si ve testin kontrol ettiği saat
public static ServiceProvider Create(FakeTimeProvider time, MenuStore store, string? redis = null)
{
ServiceCollection services = new();
services.AddSingleton<TimeProvider>(time);
services.AddMemoryCache(o => o.Clock = new TimeProviderClock(time));
if (redis is not null)
{
services.AddStackExchangeRedisCache(o => o.Configuration = redis);
}
services.AddHybridCache();
services.AddSingleton(store);
services.AddSingleton<CategoryMenu>();
return services.BuildServiceProvider();
}
}
CategoryMenu, LocalCacheExpiration'ı 1 dakika, Expiration'ı 30 dakika olan ve category-menu tag'i taşıyan bir GetOrCreateAsync ile iki invalidation method'u (InvalidateByTagAsync, InvalidateKeyAsync) içeriyor; MenuStore ise veritabanı rolünde, kaç kez okunduğunu sayıyor. Tag testinin ilk hâlinde (adı Tag_invalidation_with_a_frozen_clock'tu) Advance satırı yoktu ve test kırıldı. Son hâli ve ilk hâlinin çıktısı:
[Fact]
public async Task Tag_invalidation_needs_a_moving_clock()
{
await using ServiceProvider server = Instance.Create(_time, _store);
CategoryMenu menu = server.GetRequiredService<CategoryMenu>();
await menu.GetAsync();
_store.Name = "Literature";
await menu.InvalidateByTagAsync();
// saat donmuşsa invalidation ile aynı tick'te yazılan entry de geçersiz sayılır
_time.Advance(TimeSpan.FromMilliseconds(1));
for (int i = 0; i < 5; i++) Assert.Equal("Literature", await menu.GetAsync());
Assert.Equal(2, _store.Loads);
}
failed Bookstore.Catalog.Tests.HybridCacheTimeTests.Tag_invalidation_with_a_frozen_clock (53ms)
Assert.Equal() Failure: Values differ
Expected: 2
Actual: 6
Değer her seferinde doğru geldi ama factory beş okumanın beşinde de çalıştı. HybridCache bir entry'yi, yaratılma zamanı tag'in invalidation zamanından büyük değilse geçersiz sayıyor; gerçek saatte aradaki birkaç tick bunu çözüyor, donmuş sahte saatte ise invalidation'dan sonra yazılan her entry aynı zamana düşüyor. Kısacası: IMemoryCache ve HybridCache'in L1'i sahte saatle tam test edilebiliyor, tag testlerinde invalidation'dan sonra saati ilerletmen gerekiyor, Redis'in TTL'i ise sunucu saatinde işlediği için sahte saatten hiç etkilenmiyor.
Testcontainers ile Redis ve Tutarlılık Testleri
Çok sunuculu davranışı test etmek için gerçek bir Redis gerekiyor. Testcontainers her test sınıfı için bir container açıp kapatıyor; ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test" yazısındaki altyapının cache tarafındaki karşılığı:
// test sınıfı başına bir Redis container'ı: sabit isim ve port, test bitince silinir
public sealed class RedisFixture : IAsyncLifetime
{
private readonly RedisContainer _container = new RedisBuilder("redis:8.2")
.WithName("chwork-redis-test-d")
.WithPortBinding(17555, 6379)
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync() => await _container.StartAsync();
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
public Task FlushAsync() => _container.ExecAsync(["redis-cli", "FLUSHALL"]);
// HybridCache L2'ye yazmayı çağırana cevap döndükten sonra yapar; testte o yazmayı bekle
public async Task WaitForL2Async(string key, string expected)
{
await using ConnectionMultiplexer connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
IDatabase db = connection.GetDatabase();
for (int i = 0; i < 100; i++)
{
RedisValue data = await db.HashGetAsync(key, "data");
if (data.HasValue && data.ToString().Contains(expected, StringComparison.Ordinal)) return;
await Task.Delay(20);
}
throw new TimeoutException($"{key} never contained {expected}");
}
}
Testcontainers.Redis 4.16'da parametresiz RedisBuilder() obsolete; image'ı constructor'a vermezsen CS0618 uyarısı alıyorsun. WaitForL2Async ilk koşuda kırılan bir testten doğdu: A sunucusu değeri döndürdükten hemen sonra B sunucusu okuyunca factory iki kez çalıştı. HybridCache'in kaynak kodundaki yorum açık: L2 yazması, bekleyen çağrılar serbest bırakıldıktan sonra arka planda yapılıyor. Üretimde bu birkaç milisaniyelik bir pencere; testte ise rastgele kırılan bir test. Üç test, iki sunucunun aynı Redis'i paylaştığı durumu sabitliyor:
[Fact]
public async Task Second_server_reads_from_redis_without_running_the_factory()
{
await using ServiceProvider serverA = Instance.Create(_time, _store, redis.ConnectionString);
await using ServiceProvider serverB = Instance.Create(_time, _store, redis.ConnectionString);
Assert.Equal("Fiction", await serverA.GetRequiredService<CategoryMenu>().GetAsync());
await redis.WaitForL2Async(CategoryMenu.Key, "Fiction");
Assert.Equal("Fiction", await serverB.GetRequiredService<CategoryMenu>().GetAsync());
Assert.Equal(1, _store.Loads);
}
[Fact]
public async Task Key_removal_reaches_other_server_after_its_local_expiration()
{
(CategoryMenu menuA, CategoryMenu menuB) = await TwoWarmServersAsync();
_store.Name = "Literature";
await menuA.InvalidateKeyAsync();
// A'nın L1'i ve Redis temiz; B'nin L1'i bundan habersiz
Assert.Equal("Literature", await menuA.GetAsync());
Assert.Equal("Fiction", await menuB.GetAsync());
// B'nin LocalCacheExpiration'ı (1 dk) dolunca Redis'e gider
_time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1));
Assert.Equal("Literature", await menuB.GetAsync());
}
[Fact]
public async Task Tag_invalidation_alone_does_not_reach_a_server_that_already_read_the_tag()
{
(CategoryMenu menuA, CategoryMenu menuB) = await TwoWarmServersAsync();
_store.Name = "Literature";
await menuA.InvalidateByTagAsync();
_time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1));
// B, tag'in durumunu daha önce Redis'ten okuyup sakladı; Redis'teki eski değeri geçerli sayıyor
Assert.Equal("Fiction", await menuB.GetAsync());
Assert.Equal(1, _store.Loads);
}
Üçüncü test bir uyarıyı belgeliyor. Key ile silmede Redis'teki kayıt gidiyor, B'nin L1'i dolunca B factory'yi çalıştırıp yeni değeri alıyor. Tag ile silmede ise Redis'teki eski payload yerinde kalıyor ve geçerliliği tag'in invalidation zamanına bakılarak belirleniyor. HybridCache'in GitHub'daki kaynak kodunda B bu zamanı bir tag için ilk ihtiyaç duyduğunda Redis'ten okuyup process içinde saklıyor ve sonra bir daha sormuyor; test, 10.11.0'ın da böyle davrandığını gösteriyor. Sonuç: menüyü daha önce okumuş B, L1'i dolduktan sonra da Redis'teki eski menüyü geçerli sayıyor; bu durum biri o key'i yeniden yazana ya da L2 süresi (burada 30 dakika) dolana kadar sürüyor. Pratik çözüm, yazma yolunda bildiğin key'leri RemoveAsync ile de silmek ya da yeni değeri hemen yeniden hesaplatmak; serinin dördüncü yazısındaki backplane'li yaklaşım ise bunu kökten çözüyor. Bu test, ileride bir paket sürümü davranışı değiştirirse kırılıp haber verecek.
.NET 10 SDK'sıyla (Software Development Kit) dotnet test, xUnit v3'ün kullandığı Microsoft.Testing.Platform için VSTest modunu artık desteklemiyor ve "Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later" hatası veriyor. Çözüm, test projesinin üstündeki global.json'a "test": { "runner": "Microsoft.Testing.Platform" } eklemek. Ryuk'u kapattığımız için container'ın silinmesi tamamen DisposeAsync'e kalıyor; CI (Continuous Integration) ortamında Ryuk açık kalmalı, çökmüş bir test koşusunun container'larını o temizliyor:
$ DOTNET_CLI_UI_LANGUAGE=en TESTCONTAINERS_RYUK_DISABLED=true dotnet test --no-build --output detailed
Running tests from bin/Debug/net10.0/Ch09.Tests.dll (net10.0|arm64)
passed Bookstore.Catalog.Tests.MemoryCacheExpirationTests.Absolute_expiration_removes_entry_after_ttl (10ms)
passed Bookstore.Catalog.Tests.MemoryCacheExpirationTests.Sliding_expiration_is_extended_by_reads (0ms)
passed Bookstore.Catalog.Tests.HybridCacheTimeTests.Local_entry_expires_on_the_fake_clock (23ms)
passed Bookstore.Catalog.Tests.HybridCacheTimeTests.Tag_invalidation_needs_a_moving_clock (1ms)
passed Bookstore.Catalog.Tests.HybridCacheRedisTests.Key_removal_reaches_other_server_after_its_local_expiration (100ms)
passed Bookstore.Catalog.Tests.HybridCacheRedisTests.Second_server_reads_from_redis_without_running_the_factory (25ms)
passed Bookstore.Catalog.Tests.HybridCacheRedisTests.Tag_invalidation_alone_does_not_reach_a_server_that_already_read_the_tag (30ms)
bin/Debug/net10.0/Ch09.Tests.dll (net10.0|arm64) passed (1s 166ms)
Test run summary: Passed!
total: 7
failed: 0
succeeded: 7
skipped: 0
duration: 1s 658ms
$ docker ps -a --filter name=chwork-redis-test -q | wc -l
0
Üretimde Dikkat
Hit ratio'yu cache başına izle ve tek bir global oran yayınlama; yüzde 99'luk bir menü cache'i, yüzde 16'lık bir kitap cache'ini ortalamada gizler. Alarmı da hit ratio'ya değil, hit ratio ile veritabanı sorgu sayısının birlikte bozulmasına kur: deploy sonrası soğuk cache'te oran düşer ve bu normaldir, ama 15 dakika sonra hâlâ düşükse bir key şeması değişmiş ya da TTL kısalmıştır.
Metric'lerin maliyetini düşük tut. Observable instrument'lar yalnız export sırasında çalışıyor; Counter.Add ise her eviction'da çağrılıyor, o yüzden tag sayısını sınırlı tut (cache ve reason yeterli). Redis tarafında keyspace_misses'ın keyspace_hits'e oranı ve evicted_keys'in artışı L2'nin sağlığını, INFO commandstats ise sunucunun kendi yükünü gösterir; uygulamanın gördüğü L2 süresiyle ikisini karıştırma.
Cache'i kaldırma kararını da ölçümle ver ve geri dönüşü kolay tut: endpoint'i bir configuration bayrağıyla cache'siz çalıştırabiliyorsan, yukarıdaki tabloyu üretimde bir saatlik bir karşılaştırmayla yeniden üretebilirsin. Metric'leri bir collector'a göndermek ve dashboard'a dökmek ilerideki Logging serisinin OpenTelemetry tarafında.
Sık Düşülen Tuzaklar
L1 miss'ini veritabanı sorgusu sanmak. Coalescing yüzünden 29 miss tek sorgu olabilir; veritabanı yükünü interceptor'dan ya da total-data-query'den oku.
Key'i metric tag'i yapmak. Her ISBN ayrı bir zaman serisi açar ve metric backend'ini cache'ten önce sen çökertirsin.
Hit ratio'yu hedef sanmak. Yüzde 87 hit ratio p95'i değiştirmedi; cache'in neyi iyileştirdiğini her seferinde ayrı ayrı ölç.
FakeTimeProvider'ın her şeyi ilerlettiğini sanmak. MemoryCacheOptions.Clock köprü ister, Redis'in TTL'i hiç ilerlemez, tag testlerinde saat donmuş kalmamalıdır.
Testte L2 yazmasını beklememek. HybridCache L2'ye arka planda yazar; ikinci sunucudan hemen okuyan test zaman zaman kırılır.
Tag invalidation'ın diğer sunuculara ulaştığını varsaymak. Ulaşıp ulaşmadığını bir integration testiyle sabitle.
Küçük Bir Deneme
★☆☆ BookCacheEntries'i 50.000'e çıkarıp eşit dağılımlı koşuyu tekrarla. Hit ratio, eviction sayısı ve p95 nasıl değişiyor? Cache'i kaldırma kararın değişti mi?
★★☆ CategoryMenu'ya tag invalidation'dan hemen sonra menüyü yeniden okuyan bir RenameAsync ekle ve üçüncü testin bir kopyasını bu method'la yaz. B, L1'i dolduktan sonra yeni değeri görüyor mu? Neden?
★★★ TimedDistributedCache'e hit ve miss sonuçlarını ayıran bir tag ekle ve aynı koşuyu tekrarla. Redis'te bulunan ve bulunamayan okumaların süre dağılımı farklı mı? Farkı nasıl açıklarsın?
Bu yazıda .NET 10'da cache'i ölçmenin bugün çalışan yollarını kurduk ve testlerle iki sürprizi sabitledik: donmuş saatte tag invalidation ve diğer sunucuya ulaşmayan tag. Serinin son yazısında .NET 11'e bakıyoruz: MemoryCache'in yerleşik metric'leri, HybridCache'te factory içinden entry option'larını ayarlamak ve output cache için IOutputCachePolicyProvider.