.NET Core
.NET'te Caching Stratejileri: In-Memory, Distributed ve Redis
Caching, backend sistemlerdeki en etkili performans iyileştirmelerinden biri — ama yalnızca tutarlılık ve invalidation kuralları bilinçli şekilde tasarlandığında. Yüksek trafikli API'lerde caching'in ortalama yanıt süresini 200ms'den 5ms'nin altına düşürdüğünü, veritabanı yükünü de %80'den fazla azalttığını gözlemledim. Asıl mesele hangi katmanda cache'leyeceğinizi ve verinin ne zaman eskimesine izin vereceğinizi bilmek.
Bu yazıda modern .NET'teki tüm caching mekanizmalarını gerçek kod örnekleriyle inceliyoruz.
Cache Türleri ve Karşılaştırma
IMemoryCache ile In-Memory Cache
En basit ve en hızlı seçenek. Veri, uygulama sürecinin belleğinde yaşar — serileştirme maliyeti yok, ağ atlaması yok. Dezavantajı: her uygulama instance'ının kendi izole cache'i olması. Load balancer arkasında birden fazla replica çalıştırıyorsanız bu yöntem tek başına yetmez.
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly IProductRepository _repository;
public ProductService(IMemoryCache cache, IProductRepository repository)
{
_cache = cache;
_repository = repository;
}
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"product:{id}";
if (_cache.TryGetValue(cacheKey, out Product? cached))
return cached;
var product = await _repository.GetByIdAsync(id);
if (product is not null)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(2),
Size = 1,
Priority = CacheItemPriority.High
};
options.RegisterPostEvictionCallback((key, value, reason, state) =>
{
// Eviction'ı loglayarak izleme yapılabilir
});
_cache.Set(cacheKey, product, options);
}
return product;
}
}Program.cs'de kayıt:
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 1024; // Maksimum cache girişi sayısı
});Sıkça gördüğüm bir hata: SizeLimit hiç ayarlanmıyor. Bu olmadan cache sınırsız büyür ve production'da memory pressure sorunlarıyla karşılaşırsınız. Her zaman bir üst sınır belirleyin ve her girişe Size değeri atayın.
IDistributedCache ve Redis ile Distributed Cache
Load balancer arkasında birden fazla instance çalıştırıyorsanız ortak bir cache'e ihtiyacınız var. IDistributedCache, .NET'in bu iş için sunduğu soyutlamadır ve en yaygın backend Redis'tir.
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "myapp:";
});public class CatalogService
{
private readonly IDistributedCache _cache;
private readonly ICatalogRepository _repository;
private readonly ILogger<CatalogService> _logger;
public CatalogService(
IDistributedCache cache,
ICatalogRepository repository,
ILogger<CatalogService> logger)
{
_cache = cache;
_repository = repository;
_logger = logger;
}
public async Task<List<Category>> GetCategoriesAsync(CancellationToken ct = default)
{
var cacheKey = "categories:all";
var cached = await _cache.GetStringAsync(cacheKey, ct);
if (cached is not null)
{
return JsonSerializer.Deserialize<List<Category>>(cached)!;
}
var categories = await _repository.GetAllCategoriesAsync(ct);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
SlidingExpiration = TimeSpan.FromMinutes(15)
};
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(categories),
options,
ct);
return categories;
}
}Dikkat edilmesi gereken nokta: IDistributedCache her şeyi byte[] veya string olarak serileştirir. Bu, her okuma ve yazma işleminde serileştirme maliyeti ödediğiniz anlamına gelir. Yoğun erişilen yollarda, soyutlama katmanından kaçınmak için doğrudan StackExchange.Redis IConnectionMultiplexer kullanmayı düşünebilirsiniz.
Output Caching (.NET 7+)
Output caching, middleware seviyesinde çalışarak HTTP yanıtlarının tamamını cache'ler. Kullanıcıya göre değişmeyen, okuma ağırlıklı public endpointler için çok güçlü bir araçtır.
// Program.cs
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));
options.AddPolicy("CatalogPolicy", builder =>
builder
.Expire(TimeSpan.FromMinutes(30))
.Tag("catalog")
.SetVaryByQuery("page", "sort"));
});
app.UseOutputCache();[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[OutputCache(PolicyName = "CatalogPolicy")]
public async Task<IActionResult> GetProducts(
[FromQuery] int page = 1,
[FromQuery] string sort = "name")
{
var products = await _service.GetProductsAsync(page, sort);
return Ok(products);
}
[HttpPost]
public async Task<IActionResult> CreateProduct(
CreateProductDto dto,
IOutputCacheStore cacheStore)
{
var product = await _service.CreateAsync(dto);
// "catalog" tag'ine sahip tüm yanıtları geçersiz kıl
await cacheStore.EvictByTagAsync("catalog", default);
return CreatedAtAction(nameof(GetProducts), new { id = product.Id }, product);
}
}HybridCache (.NET 9)
.NET 9 ile gelen HybridCache, in-memory ve distributed cache'i tek bir API altında birleştirir. Serileştirme, stampede koruması ve iki seviyeli cache yönetimini otomatik olarak halleder. Yeni projelerde artık bunu öneriyorum.
// Program.cs
builder.Services.AddHybridCache(options =>
{
options.MaximumPayloadBytes = 1024 * 1024; // 1 MB
options.MaximumKeyLength = 256;
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(30),
LocalCacheExpiration = TimeSpan.FromMinutes(5)
};
});
// Distributed cache backend'i de kaydedin
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});public class OrderService
{
private readonly HybridCache _cache;
private readonly IOrderRepository _repository;
public OrderService(HybridCache cache, IOrderRepository repository)
{
_cache = cache;
_repository = repository;
}
public async Task<OrderSummary> GetOrderSummaryAsync(
int orderId, CancellationToken ct = default)
{
return await _cache.GetOrCreateAsync(
$"order:{orderId}",
async token => await _repository.GetSummaryAsync(orderId, token),
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
},
cancellationToken: ct);
}
public async Task InvalidateOrderAsync(int orderId)
{
await _cache.RemoveAsync($"order:{orderId}");
}
}HybridCache önce yerel in-memory cache'i kontrol eder, sonra distributed cache'e bakar ve ancak ikisi de boşsa factory delegate'i çağırır. Aynı key için eşzamanlı istekleri birleştirir — bu da cache stampede sorununu tasarım düzeyinde ortadan kaldırır.
Cache-Aside Pattern Detaylı
Cache-aside (lazy-loading olarak da bilinir), en yaygın kullanılan cache pattern'idir. Uygulama önce cache'i kontrol eder; miss olursa kaynaktan yükler ve cache'i doldurur.
public class CacheAsideService<T> where T : class
{
private readonly IDistributedCache _cache;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public CacheAsideService(IDistributedCache cache, ILogger logger)
{
_cache = cache;
_logger = logger;
}
public async Task<T?> GetOrSetAsync(
string key,
Func<Task<T?>> factory,
TimeSpan? absoluteExpiration = null,
TimeSpan? slidingExpiration = null,
CancellationToken ct = default)
{
// 1. Cache'i kontrol et
var cached = await _cache.GetStringAsync(key, ct);
if (cached is not null)
{
_logger.LogDebug("Cache HIT: {Key}", key);
return JsonSerializer.Deserialize<T>(cached, _jsonOptions);
}
_logger.LogDebug("Cache MISS: {Key}", key);
// 2. Kaynaktan yükle
var value = await factory();
if (value is null) return null;
// 3. Cache'e yaz
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(10),
SlidingExpiration = slidingExpiration ?? TimeSpan.FromMinutes(2)
};
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
await _cache.SetStringAsync(key, serialized, options, ct);
return value;
}
public async Task InvalidateAsync(string key, CancellationToken ct = default)
{
await _cache.RemoveAsync(key, ct);
_logger.LogInformation("Cache INVALIDATED: {Key}", key);
}
}Cache-aside'ın riski, veri kaynağı ile cache'in birbirinden sapabilmesidir. Başka bir servis veya doğrudan veritabanı güncellemesi veriyi değiştirirse, cache bundan haberdar olmaz. İşte burada invalidation stratejileri devreye girer.
Cache Invalidation Stratejileri
Phil Karlton'ın ünlü sözü: "Bilgisayar biliminde sadece iki zor şey var — cache invalidation ve isimlendirme." İşte production'da gerçekten işe yarayan stratejiler.
Zamana Dayalı Sona Erme (TTL)
En basit yaklaşım. Bir TTL belirlersiniz ve verinin o süre boyunca eski olabileceğini kabul edersiniz. Eventual consistency'nin yeterli olduğu veriler için uygundur.
// Sık değişen veri için kısa TTL
var stockOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
};
// Nadir değişen veri için uzun TTL
var configOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
SlidingExpiration = TimeSpan.FromHours(6)
};Event Tabanlı Invalidation
Veri değiştiğinde, cache silme işlemini tetikleyen bir event yayınlayın. Dağıtık sistemler için en güvenilir yaklaşımdır.
public class ProductUpdatedHandler : INotificationHandler<ProductUpdatedEvent>
{
private readonly IDistributedCache _cache;
private readonly IOutputCacheStore _outputCache;
public ProductUpdatedHandler(
IDistributedCache cache,
IOutputCacheStore outputCache)
{
_cache = cache;
_outputCache = outputCache;
}
public async Task Handle(
ProductUpdatedEvent notification,
CancellationToken ct)
{
// Belirli girişi sil
await _cache.RemoveAsync($"product:{notification.ProductId}", ct);
// Bu ürünü içerebilecek liste cache'lerini sil
await _cache.RemoveAsync($"products:category:{notification.CategoryId}", ct);
// Output cache yanıtlarını temizle
await _outputCache.EvictByTagAsync("catalog", ct);
}
}Versiyonlu Anahtarlar
Cache girişlerini silmek yerine anahtarı değiştirin. Bu yöntem, cache'siz bir pencere olmadan atomik geçiş yapmanız gereken durumlarda kullanışlıdır.
public class VersionedCacheService
{
private readonly IDistributedCache _cache;
private readonly IMemoryCache _versionCache;
public async Task<string> GetVersionedKeyAsync(string baseKey)
{
var version = await GetCurrentVersionAsync(baseKey);
return $"{baseKey}:v{version}";
}
public async Task InvalidateByVersionAsync(string baseKey)
{
var currentVersion = await GetCurrentVersionAsync(baseKey);
var newVersion = currentVersion + 1;
await _cache.SetStringAsync(
$"{baseKey}:version",
newVersion.ToString(),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7)
});
// Eski versiyonlu girişler TTL ile doğal olarak silinir
}
private async Task<long> GetCurrentVersionAsync(string baseKey)
{
var version = await _cache.GetStringAsync($"{baseKey}:version");
return version is not null ? long.Parse(version) : 1;
}
}Cache Stampede Önleme
Cache stampede, popüler bir cache girişi sona erdiğinde onlarca (veya binlerce) eşzamanlı isteğin aynı anda cache'i boş bulup hep birlikte veritabanına yüklenmesidir. Bir trafik zirvesinde bunun veritabanını çökerttiğini bizzat gördüm — cache tam da en kötü anda sona ermişti.
SemaphoreSlim ile Kilitleme
public class StampedeProtectedCache<T> where T : class
{
private readonly IDistributedCache _cache;
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public async Task<T?> GetOrCreateAsync(
string key,
Func<Task<T?>> factory,
TimeSpan expiration,
CancellationToken ct = default)
{
// Önce kilitlemeden cache'i kontrol et
var cached = await _cache.GetStringAsync(key, ct);
if (cached is not null)
return JsonSerializer.Deserialize<T>(cached);
// Key bazında kilit al
var keyLock = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await keyLock.WaitAsync(ct);
try
{
// Kilit aldıktan sonra tekrar kontrol et
cached = await _cache.GetStringAsync(key, ct);
if (cached is not null)
return JsonSerializer.Deserialize<T>(cached);
// Buraya sadece tek bir istek ulaşır
var value = await factory();
if (value is not null)
{
await _cache.SetStringAsync(
key,
JsonSerializer.Serialize(value),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration
},
ct);
}
return value;
}
finally
{
keyLock.Release();
}
}
}Jitter ile Sona Erme
TTL değerlerine rastgelelik ekleyerek tüm girişlerin aynı anda sona ermesini engelleyin.
public static class CacheExpirationExtensions
{
private static readonly Random _jitter = new();
public static DistributedCacheEntryOptions WithJitter(
this DistributedCacheEntryOptions options,
double jitterPercentage = 0.1)
{
if (options.AbsoluteExpirationRelativeToNow.HasValue)
{
var baseTtl = options.AbsoluteExpirationRelativeToNow.Value;
var jitterRange = baseTtl.TotalMilliseconds * jitterPercentage;
var jitter = _jitter.NextDouble() * jitterRange * 2 - jitterRange;
options.AbsoluteExpirationRelativeToNow =
baseTtl + TimeSpan.FromMilliseconds(jitter);
}
return options;
}
}
// Kullanım
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
}.WithJitter(0.2); // ±%20 jitter → TTL 8-12 dakika arasıArka Plan Yenileme (Stale-While-Revalidate)
Eski değeri hemen sunup arka planda yenileyin. Kritik olmayan veriler için cache miss'lerindeki gecikme artışlarını ortadan kaldırır.
public class StaleWhileRevalidateCache<T> where T : class
{
private readonly IMemoryCache _cache;
public async Task<T?> GetOrCreateAsync(
string key,
Func<Task<T>> factory,
TimeSpan freshDuration,
TimeSpan staleDuration)
{
if (_cache.TryGetValue(key, out CacheWrapper<T>? wrapper))
{
if (wrapper!.IsStale && !wrapper.IsRefreshing)
{
wrapper.IsRefreshing = true;
// Arka planda yenile
_ = Task.Run(async () =>
{
var fresh = await factory();
_cache.Set(key, new CacheWrapper<T>(fresh, freshDuration),
freshDuration + staleDuration);
});
}
return wrapper.Value;
}
var value = await factory();
_cache.Set(key, new CacheWrapper<T>(value, freshDuration),
freshDuration + staleDuration);
return value;
}
}
public class CacheWrapper<T>
{
public T Value { get; }
public DateTime FreshUntil { get; }
public bool IsStale => DateTime.UtcNow > FreshUntil;
public bool IsRefreshing { get; set; }
public CacheWrapper(T value, TimeSpan freshDuration)
{
Value = value;
FreshUntil = DateTime.UtcNow.Add(freshDuration);
}
}Cache Performansını İzleme
Ölçmediğiniz şeyi iyileştiremezsiniz. Cache hit oranı en önemli metriktir — düşerse ya TTL'leriniz çok kısa, ya cache boyutunuz yetersiz ya da invalidation'ınız çok agresiftir.
IMemoryCache ile Özel Metrikler
public class InstrumentedCacheService
{
private readonly IMemoryCache _cache;
private readonly ILogger<InstrumentedCacheService> _logger;
private static long _hits;
private static long _misses;
public async Task<T?> GetAsync<T>(string key)
{
if (_cache.TryGetValue(key, out T? value))
{
Interlocked.Increment(ref _hits);
return value;
}
Interlocked.Increment(ref _misses);
return default;
}
public double GetHitRatio()
{
var total = Interlocked.Read(ref _hits) + Interlocked.Read(ref _misses);
return total == 0 ? 0 : (double)Interlocked.Read(ref _hits) / total;
}
}Redis İçin Health Check
public class RedisCacheHealthCheck : IHealthCheck
{
private readonly IConnectionMultiplexer _redis;
public RedisCacheHealthCheck(IConnectionMultiplexer redis)
{
_redis = redis;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct = default)
{
try
{
var db = _redis.GetDatabase();
var latency = await db.PingAsync();
var data = new Dictionary<string, object>
{
["latency_ms"] = latency.TotalMilliseconds,
["connected_clients"] = _redis.GetServer(
_redis.GetEndPoints().First()).Info("clients")
};
return latency.TotalMilliseconds < 100
? HealthCheckResult.Healthy("Redis yanıt veriyor", data)
: HealthCheckResult.Degraded("Redis gecikmesi yüksek", null, data);
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Redis'e ulaşılamıyor", ex);
}
}
}Metrik Endpoint'i
app.MapGet("/metrics/cache", (InstrumentedCacheService cache) =>
{
return Results.Ok(new
{
HitRatio = cache.GetHitRatio(),
Timestamp = DateTime.UtcNow
});
});Production'da bu metrikleri Prometheus'a aktarıyorum ve hit oranı %70'in altına düştüğünde alarm kuruyorum. Ani bir düşüş genellikle veri erişim kalıplarında bir değişiklik olduğu veya bir deployment'ın key formatını değiştirdiği anlamına gelir.
Sık Yapılan Caching Hataları
Bunlar gerçek production sistemlerinde karşılaştığım hatalar:
1. Null Sonuçları Koruma Olmadan Cache'lemek
// KÖTÜ: Olmayan ürün null döner ve her seferinde DB'ye sorarsınız
var product = await _cache.GetAsync<Product>(key);
if (product is null)
{
product = await _db.FindAsync(id); // Her istekte çağrılır
if (product is not null)
await _cache.SetAsync(key, product);
}
// İYİ: Yokluğu da kısa TTL ile cache'leyin
var product = await _db.FindAsync(id);
if (product is not null)
{
await _cache.SetAsync(key, product, TimeSpan.FromMinutes(10));
}
else
{
// Tekrarlanan DB sorgularını önlemek için "bulunamadı" durumunu cache'le
await _cache.SetAsync(key, NullSentinel.Instance, TimeSpan.FromMinutes(1));
}2. Fazla Genel Cache Anahtarları Kullanmak
// KÖTÜ: Kullanıcı, dil veya filtreden bağımsız aynı anahtar
var key = "products";
// İYİ: Anahtar gerçek sorgu parametrelerini yansıtsın
var key = $"products:cat={categoryId}:page={page}:lang={locale}:sort={sortBy}";3. Cache Çöktüğünde Zarif Geri Dönüş Olmaması
// KÖTÜ: Redis çökerse tüm API çöker
var cached = await _cache.GetStringAsync(key); // Exception fırlatır!
// İYİ: Yakala ve kaynağa düş
public async Task<T?> SafeGetAsync<T>(string key) where T : class
{
try
{
var data = await _cache.GetStringAsync(key);
return data is not null ? JsonSerializer.Deserialize<T>(data) : null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache okuma başarısız: {Key}, kaynağa düşülüyor", key);
return null; // Zarif bir şekilde DB'ye düş
}
}4. Deployment Sonrası Serileştirme Format Uyumsuzluğu
Cache'lenen nesnenin yapısını değiştirdiğinizde (özellik ekleme/kaldırma), eski cache girişleri deserializasyon'da hata verir. Cache anahtarlarınızı her zaman versiyonlayın veya eksik özellikleri tolere eden bir format kullanın.
5. Bellek Sınırı Koymamak
IMemoryCache'te SizeLimit veya Redis'te maxmemory ayarlanmadan cache, OOM kill'e neden olana veya rastgele anahtarları silene kadar büyür. Her zaman üst sınır belirleyin.
Sonuç
İyi bir caching mimarisi; hız, tutarlılık ve operasyonel netlik arasında bir denge kurar. .NET 9 kullanıyorsanız HybridCache ile başlayın — iki seviyeli caching ve stampede korumasını kutudan çıkar çıkmaz sunar. Eski sürümlerde sıcak veriler için IMemoryCache'i, paylaşılan durum için Redis destekli IDistributedCache'i birlikte kullanın. Cache politikasını bir ürün kararı olarak ele alın, sırf teknik bir detay olarak değil: TTL, invalidation stratejisi ve izleme, özellikle birlikte tasarlanmalı — sonradan eklenmemeli.
Caching mimarinizi birlikte tasarlayabiliriz — konuşalım.
İlgili Makaleler
ASP.NET Core ile RESTful API Geliştirme
ASP.NET Core ile production-ready REST API geliştirmenin temellerini öğrenin. Controller, routing ve best practice'ler.
.NET ile Mikroservis Mimarisi: Tasarım ve Uygulama
.NET ile mikroservis mimarisi tasarlayın. Service communication, Docker ve orchestration stratejileri.
.NET Performance Optimizasyonu: Profiling ve Best Practices
.NET uygulamalarının performansını optimize edin. Profiling araçları, memory management ve async patterns.
Flutter Projeniz mi Var?
iOS, Android ve web için yüksek performanslı Flutter uygulamaları geliştiriyorum.
İletişime Geç