Distributed Cache ile MSSQL Yükünü Azaltmak
Distributed Cache ile MSSQL Yükünü Azaltmak
Sorun — Veritabanı Her Şeyi Taşıyor
Uygulama büyüdükçe şu tablo kaçınılmazdır:
- Her API isteği veritabanına gidiyor
- Aynı sorgu saniyede onlarca kez çalışıyor
- MSSQL CPU kullanımı sürekli yüksek
- Yanıt süreleri arttı, kullanıcı şikayetleri başladı
- Daha güçlü sunucu almak geçici çözüm oluyor
Veritabanı güçlü bir araçtır — ama her isteğin birincil durak noktası olmamalıdır. Aynı ürün kataloğunu her kullanıcı için defalarca sorgulamak, aynı döviz kurlarını her işlemde veritabanından çekmek, değişmeyen konfigürasyon verilerini sürekli okumak — bunların hepsi önlenebilir yük demektir.
Cache bu sorunu çözer. Ama nasıl kullandığınız, ne kadar fayda sağladığınızı belirler.
Cache Türleri — Hangisi Ne Zaman?
In-Memory Cache
Veriler uygulama sürecinin belleğinde tutulur. En hızlı seçenektir çünkü ağ gecikmesi yoktur.
Avantajları:
- Microsaniye erişim süresi
- Kurulum gerektirmez
- Serileştirme maliyeti yoktur
Dezavantajları:
- Tek sunucu — yatay ölçeklendirme yapıldığında her instance kendi cache'ini tutar
- Uygulama yeniden başladığında cache sıfırlanır
- Büyük veri setlerinde bellek baskısı oluşturur
// Program.cs
builder.Services.AddMemoryCache();
// Kullanım
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _context;
public ProductService(IMemoryCache cache, AppDbContext context)
{
_cache = cache;
_context = context;
}
public async Task<List<ProductDto>> GetActiveCategoriesAsync()
{
const string cacheKey = "active_categories";
if (_cache.TryGetValue(cacheKey, out List<ProductDto> cached))
return cached;
var data = await _context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync();
_cache.Set(cacheKey, data, TimeSpan.FromMinutes(15));
return data;
}
}
Distributed Cache
Veriler merkezi bir cache sunucusunda (Redis, SQL Server, NCache) tutulur. Tüm uygulama instance'ları aynı cache'i paylaşır.
Avantajları:
- Yatay ölçeklendirmede tutarlılık
- Uygulama yeniden başlasa bile cache yaşar
- Merkezi yönetim ve izleme
Dezavantajları:
- Ağ gecikmesi (genellikle 1–5ms)
- Ek altyapı maliyeti
- Serileştirme/deserileştirme overhead'i
Kullanıcı → API Server 1 ─┐
Kullanıcı → API Server 2 ─┼──→ Redis → MSSQL (cache miss'te)
Kullanıcı → API Server 3 ─┘
.NET 8'de IDistributedCache
.NET, IDistributedCache interface'i ile farklı cache backend'lerini soyutlar. Redis'ten SQL Server cache'e geçmek kod değişikliği gerektirmez — sadece kayıt değişir.
Redis Kurulumu
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp:";
});
// appsettings.json
{
"ConnectionStrings": {
"Redis": "localhost:6379,abortConnect=false,connectTimeout=5000"
}
}
In-Memory Fallback (Redis Olmadan Test İçin)
// Development ortamında Redis yerine in-memory kullan
if (builder.Environment.IsDevelopment())
{
builder.Services.AddDistributedMemoryCache();
}
else
{
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "MyApp:";
});
}
Cache Service — Tekrar Kullanılabilir Soyutlama
IDistributedCache doğrudan kullanmak serileştirme kodunu her yere yayar. Üzerine ince bir soyutlama katmanı kurmak hem tekrar kullanımı artırır hem de test edilebilirliği sağlar.
public interface ICacheService
{
Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
Task SetAsync<T>(string key, T value, TimeSpan? expiry = null, CancellationToken ct = default);
Task RemoveAsync(string key, CancellationToken ct = default);
Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiry = null, CancellationToken ct = default);
}
public class CacheService : ICacheService
{
private readonly IDistributedCache _cache;
private readonly ILogger<CacheService> _logger;
private static readonly TimeSpan DefaultExpiry = TimeSpan.FromMinutes(10);
public CacheService(IDistributedCache cache, ILogger<CacheService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default)
{
try
{
var bytes = await _cache.GetAsync(key, ct);
if (bytes is null) return default;
return JsonSerializer.Deserialize<T>(bytes);
}
catch (Exception ex)
{
// Cache hatası uygulamayı durdurmamalı
_logger.LogWarning(ex, "Cache okuma hatası. Key: {Key}", key);
return default;
}
}
public async Task SetAsync<T>(
string key,
T value,
TimeSpan? expiry = null,
CancellationToken ct = default)
{
try
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiry ?? DefaultExpiry
};
await _cache.SetAsync(key, bytes, options, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache yazma hatası. Key: {Key}", key);
}
}
public async Task RemoveAsync(string key, CancellationToken ct = default)
{
try
{
await _cache.RemoveAsync(key, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache silme hatası. Key: {Key}", key);
}
}
// Cache-aside pattern — en yaygın kullanım
public async Task<T> GetOrSetAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan? expiry = null,
CancellationToken ct = default)
{
var cached = await GetAsync<T>(key, ct);
if (cached is not null) return cached;
var value = await factory();
await SetAsync(key, value, expiry, ct);
return value;
}
}
Kayıt:
builder.Services.AddSingleton<ICacheService, CacheService>();
Cache Key Stratejisi
Cache key tasarımı, özellikle parametreli sorgularda kritiktir. Tutarsız key'ler ya cache miss'e ya da yanlış veriye yol açar.
// Kötü — çakışma riski, okunaksız
_cache.GetOrSetAsync("products", ...);
_cache.GetOrSetAsync("product1", ...);
_cache.GetOrSetAsync($"p_{id}", ...);
// İyi — hiyerarşik, tutarlı, çakışmasız
public static class CacheKeys
{
// Sabit listeler
public static string ActiveCategories() => "categories:active";
public static string ExchangeRates() => "exchange-rates:current";
public static string SystemConfig() => "config:system";
// Parametreli
public static string ProductById(Guid id) => $"products:{id}";
public static string ProductsByCategory(int categoryId) => $"products:category:{categoryId}";
public static string CustomerOrders(Guid customerId, int page) =>
$"orders:customer:{customerId}:page:{page}";
// Prefix ile toplu silme
public static string ProductPrefix() => "products:";
public static string OrderPrefix(Guid customerId) => $"orders:customer:{customerId}:";
}
Cache-Aside Pattern — Gerçek Kullanım Örnekleri
1. Ürün Kataloğu — Sık Okunan, Nadir Değişen
public class ProductQueryHandler : IRequestHandler<GetProductsByCategoryQuery, List<ProductDto>>
{
private readonly AppDbContext _context;
private readonly ICacheService _cache;
public ProductQueryHandler(AppDbContext context, ICacheService cache)
{
_context = context;
_cache = cache;
}
public async Task<List<ProductDto>> Handle(
GetProductsByCategoryQuery query,
CancellationToken ct)
{
var cacheKey = CacheKeys.ProductsByCategory(query.CategoryId);
return await _cache.GetOrSetAsync(
cacheKey,
async () => await _context.Products
.AsNoTracking()
.Where(p => p.CategoryId == query.CategoryId && p.IsActive)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Stock))
.ToListAsync(ct),
expiry: TimeSpan.FromMinutes(30),
ct: ct);
}
}
2. Döviz Kurları — Kısa Süreli, Sık Erişilen
public class ExchangeRateService
{
private readonly IDbConnection _connection;
private readonly ICacheService _cache;
public ExchangeRateService(IDbConnection connection, ICacheService cache)
{
_connection = connection;
_cache = cache;
}
public async Task<ExchangeRateDto> GetCurrentRatesAsync(CancellationToken ct = default)
{
return await _cache.GetOrSetAsync(
CacheKeys.ExchangeRates(),
async () =>
{
var rates = await _connection.QuerySingleAsync<ExchangeRateDto>(@"
SELECT UsdTry, EurTry, GbpTry, UpdatedAt
FROM ExchangeRates
WHERE IsActive = 1
ORDER BY UpdatedAt DESC
OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY");
return rates;
},
expiry: TimeSpan.FromMinutes(5), // Kurlar 5 dakikada bir güncelleniyor
ct: ct);
}
}
3. Kullanıcı Profili — Kişiselleştirilmiş, Orta Ömürlü
public class UserProfileQueryHandler
: IRequestHandler<GetUserProfileQuery, UserProfileDto>
{
private readonly AppDbContext _context;
private readonly ICacheService _cache;
private readonly ICurrentUserService _currentUser;
public UserProfileQueryHandler(
AppDbContext context,
ICacheService cache,
ICurrentUserService currentUser)
{
_context = context;
_cache = cache;
_currentUser = currentUser;
}
public async Task<UserProfileDto> Handle(
GetUserProfileQuery query,
CancellationToken ct)
{
var cacheKey = $"user:profile:{_currentUser.UserId}";
return await _cache.GetOrSetAsync(
cacheKey,
async () => await _context.Users
.AsNoTracking()
.Where(u => u.Id == _currentUser.UserId)
.Select(u => new UserProfileDto(
u.Id, u.Name, u.Email,
u.Preferences, u.LastLoginAt))
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("Kullanıcı bulunamadı."),
expiry: TimeSpan.FromHours(1),
ct: ct);
}
}
Cache Invalidation — En Zor Kısım
Cache'in en kritik ve en çok hata yapılan bölümü invalidation'dır. Yanlış invalidation iki sonuç doğurur: ya eski veri gösterilir ya da gereksiz yere veritabanına gidilir.
Doğrudan Silme — Command Tamamlandığında
public class UpdateProductCommandHandler
: IRequestHandler<UpdateProductCommand, Unit>
{
private readonly AppDbContext _context;
private readonly ICacheService _cache;
public UpdateProductCommandHandler(AppDbContext context, ICacheService cache)
{
_context = context;
_cache = cache;
}
public async Task<Unit> Handle(UpdateProductCommand command, CancellationToken ct)
{
var product = await _context.Products.FindAsync(command.ProductId, ct)
?? throw new NotFoundException(nameof(Product), command.ProductId);
product.Update(command.Name, command.Price, command.Stock);
await _context.SaveChangesAsync(ct);
// İlgili cache key'lerini temizle
await _cache.RemoveAsync(CacheKeys.ProductById(product.Id), ct);
await _cache.RemoveAsync(CacheKeys.ProductsByCategory(product.CategoryId), ct);
return Unit.Value;
}
}
Prefix ile Toplu Silme — Redis IServer
Bazen bir grup key'i tek seferde temizlemeniz gerekir. Örneğin bir kategorinin tüm sayfalı sonuçlarını:
public class RedisCacheService : ICacheService
{
private readonly IConnectionMultiplexer _redis;
private readonly IDistributedCache _cache;
private readonly string _instanceName;
public RedisCacheService(
IConnectionMultiplexer redis,
IDistributedCache cache,
IConfiguration config)
{
_redis = redis;
_cache = cache;
_instanceName = config["Redis:InstanceName"] ?? "MyApp:";
}
public async Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default)
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var fullPrefix = $"{_instanceName}{prefix}";
var keys = server.Keys(pattern: $"{fullPrefix}*").ToArray();
if (keys.Any())
{
var db = _redis.GetDatabase();
await db.KeyDeleteAsync(keys);
}
}
}
// Kullanım — bir müşterinin tüm sipariş cache'ini temizle
await _cache.RemoveByPrefixAsync(CacheKeys.OrderPrefix(customerId), ct);
MediatR Pipeline ile Otomatik Invalidation
Cache invalidation mantığını handler'lara dağıtmak yerine pipeline'da yönetmek daha temiz bir yaklaşımdır:
// Hangi command'ların hangi cache'leri temizleyeceğini tanımlayan interface
public interface ICacheInvalidator
{
IEnumerable<string> KeysToInvalidate { get; }
}
// Command'a invalidation bilgisi ekle
public record UpdateProductCommand(
Guid ProductId,
string Name,
decimal Price,
int CategoryId
) : IRequest<Unit>, ICacheInvalidator
{
public IEnumerable<string> KeysToInvalidate =>
[
CacheKeys.ProductById(ProductId),
CacheKeys.ProductsByCategory(CategoryId),
CacheKeys.ActiveCategories()
];
}
// Pipeline behavior
public class CacheInvalidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ICacheService _cache;
public CacheInvalidationBehavior(ICacheService cache)
{
_cache = cache;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var response = await next();
// İşlem başarıyla tamamlandıysa invalidation yap
if (request is ICacheInvalidator invalidator)
{
var tasks = invalidator.KeysToInvalidate
.Select(key => _cache.RemoveAsync(key, ct));
await Task.WhenAll(tasks);
}
return response;
}
}
Hangi Veriler Cache'lenir?
Her veri cache'e uygun değildir. Şu kriterlere göre karar verin:
✅ Cache'lenmeye uygun:
- Ürün kataloğu, kategori listesi (sık okunan, nadir değişen)
- Döviz kurları, fiyat listeleri (periyodik güncellenen)
- Sistem konfigürasyonları, sabit listeler
- Kullanıcı profili, tercihler
- Raporlama ve dashboard verileri
- Referans veriler (ülke, şehir, para birimi listeleri)
❌ Cache'lenmemeli:
- Gerçek zamanlı stok bilgisi (yanlış stok gösterimi riski)
- Finansal işlem sonuçları (tutarsızlık riski)
- Oturum ve kimlik doğrulama verileri (güvenlik riski)
- Sık değişen, kişisel bildirimler
- Yazma ağırlıklı veriler
Cache Expiry Stratejisi
public static class CacheExpiry
{
// Neredeyse hiç değişmeyen — ülke listesi, para birimleri
public static TimeSpan Static => TimeSpan.FromHours(24);
// Nadir değişen — kategori listesi, sistem config
public static TimeSpan Long => TimeSpan.FromHours(2);
// Periyodik güncellenen — ürün kataloğu, fiyatlar
public static TimeSpan Medium => TimeSpan.FromMinutes(30);
// Sık değişen — döviz kuru, anlık veriler
public static TimeSpan Short => TimeSpan.FromMinutes(5);
// Oturum bazlı — kullanıcı profili, sepet
public static TimeSpan Session => TimeSpan.FromMinutes(20);
}
Sliding vs Absolute Expiry
var options = new DistributedCacheEntryOptions();
// Absolute — her ne olursa olsun X dakika sonra sona erer
options.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
// Sliding — son erişimden X dakika sonra sona erer
// Her erişimde süre yenilenir
options.SlidingExpiration = TimeSpan.FromMinutes(10);
// İkisini birlikte kullanmak — önerilen
// Aktif kullanımda sliding, maksimum süre absolute ile sınırlı
options.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2);
options.SlidingExpiration = TimeSpan.FromMinutes(20);
Pratik kural: Kullanıcı bazlı veriler (profil, sepet) için sliding expiry, referans veriler için absolute expiry tercih edin.
Cache-Stampede Koruması
Popüler bir cache key sona erdiğinde, yüzlerce eş zamanlı istek veritabanına yönelir ve aynı veriyi çekmeye çalışır. Bu cache stampede'dir.
public class StampedeProtectedCacheService : ICacheService
{
private readonly ICacheService _inner;
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public StampedeProtectedCacheService(ICacheService inner)
{
_inner = inner;
}
public async Task<T> GetOrSetAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan? expiry = null,
CancellationToken ct = default)
{
// Önce lock olmadan dene — çoğu istek buradan döner
var cached = await _inner.GetAsync<T>(key, ct);
if (cached is not null) return cached;
// Cache miss — lock al, sadece bir istek veritabanına gitsin
var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
try
{
// Lock aldıktan sonra tekrar kontrol et
// Bir önceki istek zaten cache'e yazmış olabilir
cached = await _inner.GetAsync<T>(key, ct);
if (cached is not null) return cached;
var value = await factory();
await _inner.SetAsync(key, value, expiry, ct);
return value;
}
finally
{
semaphore.Release();
_locks.TryRemove(key, out _);
}
}
}
Redis İzleme — Neye Bakmalısınız?
# Redis CLI ile anlık komut izleme
redis-cli monitor
# Cache hit/miss oranı
redis-cli info stats | grep keyspace
# Bellek kullanımı
redis-cli info memory | grep used_memory_human
# Bağlı istemci sayısı
redis-cli info clients | grep connected_clients
# En çok erişilen key'ler
redis-cli --hotkeys
Hedef metrikler:
| Metrik | İyi | Kötü |
|---|---|---|
| Cache Hit Rate | > %80 | < %50 |
| Ortalama Gecikme | < 2ms | > 10ms |
| Bellek Kullanımı | < %80 | > %90 |
| Evicted Keys | 0 | Sürekli artıyor |
Denetim Listesi
✅ IDistributedCache doğrudan kullanmak yerine soyutlama katmanı oluşturuldu
✅ Cache key'leri hiyerarşik ve tutarlı biçimde tanımlandı
✅ Development ortamında in-memory, production'da Redis kullanılıyor
✅ Cache hataları uygulamayı durdurmadan loglanıyor
✅ Her veri tipi için uygun expiry süresi belirlendi
✅ Command tamamlandığında ilgili cache key'leri temizleniyor
✅ Cache stampede koruması kritik key'lerde uygulandı
✅ Redis hit rate ve bellek kullanımı izleniyor
✅ Finansal ve gerçek zamanlı veriler cache'lenmedi
Temel Çıkarımlar
- Cache her sorunu çözmez — önce yavaş sorguları ve eksik index'leri düzeltin
- Her veri cache'lenemez — yazma ağırlıklı ve gerçek zamanlı veriler cache'e girmemeli
- Cache invalidation kaçınılmazdır — bunu tasarımın başında planlayın
- Cache hatası uygulamayı çökertmemeli — her zaman veritabanına fallback yapın
- Cache hit rate'i izleyin — düşük hit rate cache'in yanlış kullanıldığının işaretidir
- Distributed cache yatay ölçeklendirmede zorunludur, tek sunucuda in-memory yeterli olabilir
Cache veritabanınızı gizlemez — onu korur. Doğru veriyi, doğru süre için, doğru yerde sakladığınızda MSSQL'iniz gerçekten önemli olan işlere odaklanabilir.