← Tüm yazılara dön

Distributed Cache ile MSSQL Yükünü Azaltmak

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.