NET Core'da Cache Yönetimi (Cache Stampede) - 3

Mustafa Uçan
03 Mart 2026 21:15

Yazı serimize Cache Stampede sorunuyla devam ediyoruz. Bu sorun genellikle yüksek trafikli sistemleride karşılaştığımız bir durumdur. Cache Stampede bir anahtarın (key) süresi dolduğu (expire olduğu) tam o saniyede, o veriye ihtiyaç duyan binlerce eşzamanlı isteğin aynı anda veritabanına hücum etmesi durumudur.

Normal şartlarda saniyede 1 sorgu ile beslenen veritabanınız, cache'in boşalmasıyla bir anda binlerce sorguyu göğüslemek zorunda kalır. Bu durum gecikmelere, timeout hatalarına ve hatta veritabanınızın tamamen yanıt verememesine yol açabilir.

Bir Örnekle Canlandıralım

Bir e-ticaret siteniz olduğunu ve ana sayfadaki "Günün Fırsatları" listesini 5 dakikalığına cache’lediğinizi düşünelim.

  • Dakika 04:59: Her şey yolunda, istekler cache'ten dönüyor.
  • Dakika 05:00: Cache süresi doldu ve veri silindi.
  • Aynı Saniyede: Siteye giren 2.000 kullanıcı var.

Cache’te veriyi bulamayan bu 2.000 isteğin tamamı "o zaman ben veriyi kaynaktan getireyim" diyerek veritabanına gider. İşte biz bu kontrolsüz istekler topluluğuna Cache Stampede diyoruz.

Cache Stampede Sorunu Ne Zaman Oluşur?

  • Absolute Expiration Kullanımı: Verilerin süresinin dolması ve verilere ulaşılamama durumu
  • Cold Start : Uygulama veya cache sunucusu (Redis vb.) yeni ayağa kalktığında cache'in tamamen boş olması.
  • Eşzamanlı İstek: Beklenmedik anlık istek artışları.


Bu İzdihamı Nasıl Durdururuz? (Çözüm Stratejileri)

"Korunma yöntemleri" yerine, bu sorunu mimari seviyede nasıl çözeriz ona bakalım:

1. Locking (Kilitleme / Mutex)

En temel ve etkili yöntemdir. Cache'te veriyi bulamayan ilk thread, veritabanına gitmeden önce kapıyı kilitler (Lock). Diğer binlerce istek kapıda bekler. İlk thread veriyi getirip cache'e yazar ve kilidi açar. Kapı açıldığında diğer istekler veritabanına gitmek yerine artık cache'e yazılmış olan hazır veriyi kullanır.


Not: Distributed (dağıtık) bir yapınız varsa, Redis üzerinden Distributed Lock mekanizmasını kullanmanız gerekir.

Standart lock mekanizması sadece o anki sunucuyu korur. Ancak bizim ihtiyacımız olan, tüm sunucuların "bu kapı şu an kilitli" bilgisini ortak bir yerden (örneğin Redis üzerinden) okumasıdır. İşte burada devreye Distributed Lock (Dağıtık Kilitleme) mekanizması giriyor. .NET dünyasında bu işin en popüler ve güvenilir kütüphanesi ise RedLock.NET. Redis üzerinden merkezi bir kilit oluşturarak, 10 farklı sunucudan gelen binlerce isteği bile tek bir "kilit" ile durdurabilir ve veritabanımızı koruma altına alabiliriz. Konunu devamında tek api servisine gelen ve cacheden verileri okuyan bir senaryomuz olacak gönderdiğimiz binlerce istekte önce ilk gelen istek cache'e bakacak veri yoksa o thread veriyi veritabanından okuyacak ve cache'e atacak diğer istekler cache'in dolmasını bekleyecek ve cache dolduğu zaman verileri alıp servisten response dönecek.

2. Early Refresh / Background Refresh (Erken Yenileme)

Cache süresi tamamen dolmadan, arka planda sessizce veriyi güncelleme yöntemidir. Örneğin 5 dakikalık bir cache ömrünün 4. dakikasında (soft expiration), gelen bir isteğe mevcut veri dönülürken arka planda asenkron bir task başlatılır ve veri güncellenir. Böylece kullanıcı hiçbir zaman "boş cache" ile karşılaşmaz.

3. Probabilistic Early Expiration (Olasılıksal Erken Yenileme)

Facebook gibi devlerin kullandığı bu teknikte, cache süresinin dolup dolmadığına bakarken işin içine biraz "rastgelelik" katılır. Her istek için "cache süresi dolmuş olabilir mi?" sorusu farklı bir olasılıkla hesaplanır. Böylece binlerce isteğin hepsi aynı milisaniyede "cache doldu" demez; biri biraz erken der, veriyi günceller ve diğerleri güncel veriden devam eder.

4. Çok Katmanlı Cache (Multi-Layer Cache)

Hız için In-Memory (L1), dayanıklılık için Distributed (L2) cache yapısını birlikte kullanmaktır. L1'deki veri uçsa bile L2'deki verinin hala orada olması, veritabanına giden yükü ciddi oranda absorbe eder.

using Microsoft.AspNetCore.Mvc;

[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{        
    private readonly ProductService _productService;
    private readonly ILogger<ProductsController> _logger;


    public ProductsController(ProductService productService, ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    // Test öncesi cache'i temizler.
    // Cache boşaltılmazsa tüm istekler direkt cache'ten döner
    // ve stampede senaryosu oluşmaz.
    [HttpDelete("stampede-clear")]
    public async Task<IActionResult> StampedeClear()
    {
        await _productService.ClearCacheAsync();
        return Ok("Stampede test cache'i temizlendi. Şimdi stampede-test endpoint'ine istek atabilirsiniz.");
    }

    // Cache stampede test endpoint'i.
    // Paralel istek aracıyla (örn: 100 eşzamanlı istek) çağrıldığında
    // sadece 1 isteğin veritabanına gittiği loglardan doğrulanabilir.
    [HttpGet("stampede-test")]
    public async Task<IActionResult> StampedeTest()
    {
        _logger.LogWarning(">> Stampede test isteği alındı - Thread: {ThreadId}", Environment.CurrentManagedThreadId);
        var products = await _productService.GetProductsAsync();
        if (products is null)
        {
            _logger.LogWarning("<< Lock alınamadı, 503 dönülüyor");
            return StatusCode(503, "Cache stampede — lock not acquired, try again later.");
        }


        _logger.LogInformation("<< {Count} ürün dönülüyor", products.Count);
        return Ok(products);
    }
}


using Microsoft.Extensions.Caching.Distributed;
using StackExchange.Redis;
using System.Text.Json;
using WebApiRedis;
using WebApiRedis.Models;


public class ProductService
{
    public const string CacheKey = "product-list";
    private const string LockKey = "product-list-lock";


    private readonly IDistributedCache _cache;
    private readonly IConnectionMultiplexer[] _redis;
    private readonly ILogger<ProductService> _logger;


    public ProductService(
        IDistributedCache cache,
        IConnectionMultiplexer redis,
        ILogger<ProductService> logger)
    {
        _cache = cache;
        _redis = [redis];
        _logger = logger;
    }


    public async Task ClearCacheAsync()
    {
        await _cache.RemoveAsync(CacheKey);
        _logger.LogWarning("Cache temizlendi: {CacheKey}", CacheKey);
    }


    public async Task<List<Product>> GetProductsAsync()
    {


        // 1. Önce cache'e bakılıyor.
        var cached = await _cache.GetStringAsync(CacheKey);
        if (!string.IsNullOrEmpty(cached))
        {
            _logger.LogInformation("Cache'te veri bulundu, doğrudan dönülüyor");
            return JsonSerializer.Deserialize<List<Product>>(cached);
        }


        _logger.LogWarning("Cache boş, dağıtık kilit alınmaya çalışılıyor...");


        // 2. RedLock ile kilit almayı dene (tek Redis instance, 5 saniye TTL)
        // retryCount × retryDelay = 15 × 200ms = 3sn bekleme süresi (DB sorgusu 2sn)
        await using var redLock = await RedisLockMechanism.CreateAsync(
            _redis,
            LockKey,
            ttl: TimeSpan.FromSeconds(5),
            retryCount: 15,
            retryDelay: TimeSpan.FromMilliseconds(200),
            autoExtend: true
        );


        if (!redLock.IsAcquired)
        {
            // Kilit alınamadı — ama belki bekleme süresinde başka bir istek
            // cache'i doldurmuştur. Son bir kontrol daha yapalım.
            // Bu sayede gereksiz 503 hatası önlenir.
            cached = await _cache.GetStringAsync(CacheKey);
            if (!string.IsNullOrEmpty(cached))
            {
                _logger.LogInformation("Kilit alınamadı ancak cache bu sürede dolmuş, cache'ten dönülüyor");
                return JsonSerializer.Deserialize<List<Product>>(cached);
            }


            _logger.LogWarning("Kilit alınamadı ve cache hâlâ boş, null dönülüyor");
            return null;
        }


        _logger.LogInformation("Kilit alındı, veritabanından veri yükleniyor...");


        // 3. Kilit alındı, veriyi yükle (double-check)
        cached = await _cache.GetStringAsync(CacheKey);
        if (!string.IsNullOrEmpty(cached))
        {
            _logger.LogInformation("Cache başka bir istek tarafından zaten doldurulmuş");
            return JsonSerializer.Deserialize<List<Product>>(cached);
        }


        // 4. Gerçek kaynaktan al (veritabanı)
        var products = await LoadFromDatabaseAsync();


        // 5. Cache'e yaz
        var serialized = JsonSerializer.Serialize(products);
        await _cache.SetStringAsync(CacheKey, serialized, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        });


        _logger.LogInformation("Veri başarıyla cache'e yazıldı");


        return products;
    }


    private async Task<List<Product>> LoadFromDatabaseAsync()
    {
        // Simüle edilmiş ağır veritabanı sorgusu
        _logger.LogWarning("Ağır veritabanı sorgusu çalıştırılıyor...");
        await Task.Delay(2000); // 2 saniye


        return new List<Product>
        {
            new Product { Id = 1, Name = "Laptop", Price = 15000 },
            new Product { Id = 2, Name = "Mouse", Price = 300 }
        };
    }
}

Bu sınıf, Redis üzerinde dağıtık kilit (distributed lock) mekanizması sağlar. Anlaşılması açısından kendi RedisLockMechanism'i kodladım fakat bunun için hali hazırda var olan bir paket var. RedLock.NET paketinide kullanabilirsiniz. Ben bu paketin temelinde yatan mimariyi anlatmak için basit bir örnekle anlatmak istedim.

RedisLockMechanism Nasıl Çalışır ?

  1. Her istek kendine özel bir kimlik (GUID) üretir — "Bu kapıyı ben kilitledim" demek için.
  2. Redis'e "Bu anahtar boşsa benim adıma yaz" komutu gönderilir. Redis bunu atomik yapar — aynı anda iki kişi yazamaz, ilk gelen kazanır.
  3. Birden fazla Redis sunucunuz varsa, yarıdan fazlasında kilidi almış olmanız gerekir. Tek sunucu kullanıyorsanız zaten 1/1 = çoğunluk sağlanır.
  4. Kapı zaten kilitliyse, belli aralıklarla tekrar dener. Deneme hakkı biterse vazgeçer.
  5. İşlem tamamlandığında kilit kaldırılır — ama sadece kendi kilididir. Başkasının kilidi varsa ona dokunmaz.
using StackExchange.Redis;

namespace WebApiRedis
{
    public class RedisLockMechanism : IAsyncDisposable
    {
        private readonly IConnectionMultiplexer[] _redis;
        private readonly string _resource;
        private readonly string _lockId;
        private readonly TimeSpan _ttl;
        private readonly TimeSpan _retryDelay;
        private readonly int _retryCount;
        private readonly Timer _extensionTimer;
        private readonly List<RedisKey> _lockedKeys;
        private bool _isDisposed;
        private bool _isAcquired;

        public bool IsAcquired => _isAcquired;

        private RedisLockMechanism(
            IConnectionMultiplexer[] redis,
            string resource,
            string lockId,
            TimeSpan ttl,
            int retryCount,
            TimeSpan retryDelay,
            bool autoExtend)
        {
            _redis = redis;
            _resource = resource;
            _lockId = lockId;
            _ttl = ttl;
            _retryCount = retryCount;
            _retryDelay = retryDelay;
            _lockedKeys = new List<RedisKey>();

            if (autoExtend)
            {
                // Kilit süresinin yarısında yenileme yap (3/4'ü de olabilir)
                var interval = TimeSpan.FromMilliseconds(ttl.TotalMilliseconds * 0.75);
                _extensionTimer = new Timer(AutoExtend, null, interval, interval);
            }
        }

        // Dışarıdan kilit oluşturmanın tek yolu bu metottur. Her çağrıda yeni bir kimlik (GUID) üretilir — tıpkı bir 
        // anahtar üzerindeki seri numarası gibi. Bu sayede herkes kendi kilidini tanır ve sadece kendi kilidini açabilir.
        public static async Task<RedisLockMechanism> CreateAsync(
            IConnectionMultiplexer[] redis,
            string resource,
            TimeSpan ttl,
            int retryCount = 3,
            TimeSpan? retryDelay = null,
            bool autoExtend = true)
        {
            var lockId = Guid.NewGuid().ToString();
            var delay = retryDelay ?? TimeSpan.FromMilliseconds(200);
            var redLock = new RedisLockMechanism(redis, resource, lockId, ttl, retryCount, delay, autoExtend);
            await redLock.AcquireAsync();
            return redLock;
        }

        // Bu metot kilidin asıl alındığı yer. Mantık basit: "Redis'e kilit yazılabildi mi?" 
        //Bunu birden fazla Redis sunucunuz varsa hepsine aynı anda sorar ve çoğunluğundan onay bekler 
        // (3 sunucuysa en az 2'si evet demeli). Tek sunucu kullanıyorsanız, 1'den 1 onay yeterlidir.
        // Neden süre kontrolü var? Kilit almak beklediğimiz süre TTL'den uzun sürerse
        //belki ilk sunucudaki kilit zaten süresi dolmuş ve başkası almıştır. 
        // Bu yüzden "toplam geçen süre TTL'den küçük mü?" kontrolü yapılır.
        private async Task AcquireAsync()
        {
            var quorum = (_redis.Length / 2) + 1;
            var startTime = DateTime.UtcNow;

            for (int attempt = 0; attempt <= _retryCount; attempt++)
            {
                var tasks = _redis.Select(conn => TryLockOnInstanceAsync(conn)).ToArray();
                var results = await Task.WhenAll(tasks);

                var successCount = results.Count(r => r);
                var elapsed = DateTime.UtcNow - startTime;
                if (successCount >= quorum && elapsed < _ttl)
                {
                    _isAcquired = true;
                    return;
                }
                // Quorum sağlanamadı, hemen kilitleri temizle
                await ReleaseLocksAsync();
                if (attempt < _retryCount)
                {
                    await Task.Delay(_retryDelay);
                }
            }
            _isAcquired = false;
        }

        // Tek Sunucuda Kilit
        // Redis'e gönderilen komut aslında çok basit bir şey söyler:
        // Bu anahtar boşsa, benim ismimi yaz ve 5 saniye sonra otomatik sil.
        // NX (Not Exists): "Anahtar yoksa yaz" — zaten başkası kilit almışsa yazma.
        // PX 5000: "5000ms sonra otomatik sil" — sunucu çökse bile kilit sonsuz kalmaz.
        private async Task<bool> TryLockOnInstanceAsync(IConnectionMultiplexer conn)
        {
            var db = conn.GetDatabase();
            var key = new RedisKey(_resource);
            var value = new RedisValue(_lockId);
            var expiry = _ttl;

            // SET key value NX PX milliseconds
            var result = await db.StringSetAsync(key, value, expiry, When.NotExists);

            if (result)
            {
                lock (_lockedKeys)
                {
                    _lockedKeys.Add(key);
                }
            }
            return result;
        }

        private async Task ReleaseLocksAsync()
        {
            List<RedisKey> keys;
            lock (_lockedKeys)
            {
                keys = _lockedKeys.ToList();
                _lockedKeys.Clear();
            }
            var tasks = new List<Task>();
            foreach (var conn in _redis)
            {
                tasks.Add(ReleaseOnInstanceAsync(conn, keys));
            }
            await Task.WhenAll(tasks);
        }

        // Kilidi silerken dikkat edilmesi gereken şey: sadece kendi kilidimizi silmeliyiz. 
        // Başka birinin kilidi varsa dokunmamalıyız. Bunun için Redis'e küçük bir betik (Lua script) gönderiyoruz. 
        // Bu betik "kontrol et ve sil" işlemini tek adımda yapar — araya başka bir komut giremez.
        private async Task ReleaseOnInstanceAsync(IConnectionMultiplexer conn, IEnumerable<RedisKey> keys)
        {
            var db = conn.GetDatabase();
            foreach (var key in keys)
            {
                // Sadece bizim lockId'imize aitse sil
                var script = @"
                    if redis.call('get', KEYS[1]) == ARGV[1] then
                        return redis.call('del', KEYS[1])
                    else
                        return 0
                    end";
                await db.ScriptEvaluateAsync(script, new RedisKey[] { key }, new RedisValue[] { _lockId });
            }
        }

        // Kilitlerin bir son kullanma tarihi (TTL) vardır. Peki ya işlemimiz beklenenden uzun sürerse?
        // Kilit süresi dolar ve başkası aynı kilidi alabilir — bu tehlikelidir. 
        // AutoExtend bunu önler: periyodik olarak Redis'e "Ben hala buradayım, süremi uzat" der. 
        // Tabii yine sadece kendi kilidimizin süresini uzatır.
        private async void AutoExtend(object state)
        {
            if (_isDisposed || !_isAcquired) return;
            var newTtl = _ttl; // Yenileme süresi aynı olabilir
            var tasks = _redis.Select(conn => ExtendOnInstanceAsync(conn, newTtl)).ToArray();
            await Task.WhenAll(tasks);
        }


        private async Task<bool> ExtendOnInstanceAsync(IConnectionMultiplexer conn, TimeSpan newTtl)
        {
            var db = conn.GetDatabase();
            var key = new RedisKey(_resource);
            var value = new RedisValue(_lockId);
            // PEXPIRE ile süreyi uzat (sadece bizim lockId'imiz varsa)
            var script = @"
                if redis.call('get', KEYS[1]) == ARGV[1] then
                    return redis.call('pexpire', KEYS[1], ARGV[2])
                else
                    return 0
                end";
            var result = await db.ScriptEvaluateAsync(script, new RedisKey[] { key }, new RedisValue[] { value, newTtl.TotalMilliseconds });
            return (bool)result;
        }

        public async ValueTask DisposeAsync()
        {
            if (_isDisposed) return;
            _isDisposed = true;
            _extensionTimer?.Dispose();
            await ReleaseLocksAsync();
        }
    }
}


using StackExchange.Redis;


var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();


// Tek Redis bağlantısı — hem cache hem RedisLockMechanism bu instance'ı kullanır
var redisConnection = builder.Configuration.GetConnectionString("RedisConnection") ?? "localhost:6379";
var redis = ConnectionMultiplexer.Connect(redisConnection);
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);


builder.Services.AddStackExchangeRedisCache(options =>
{
    options.ConnectionMultiplexerFactory = () => Task.FromResult<IConnectionMultiplexer>(redis);
    options.InstanceName = "SampleInstance_";
});


builder.Services.AddScoped<ProductService>();


var app = builder.Build();


if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();


100 Paralel İstek Gönder

Aşağıdaki C# konsol uygulaması ile test edebilirsiniz:

using System.Diagnostics;

Console.Write("Kaç paralel istek gönderilsin? ");
var count = int.Parse(Console.ReadLine()!);

using var http = new HttpClient { BaseAddress = new Uri("https://localhost:7044") };

var sw = Stopwatch.StartNew();
var tasks = Enumerable.Range(1, count).Select(async i =>
{
    var reqSw = Stopwatch.StartNew();
    var response = await http.GetAsync("/api/products/stampede-test");
    reqSw.Stop();
    Console.WriteLine($"İstek {i} - {response.StatusCode} - {reqSw.ElapsedMilliseconds} ms");
});

await Task.WhenAll(tasks);
sw.Stop();
Console.WriteLine($"\nToplam Süre: {sw.ElapsedMilliseconds} ms");



Bir sonraki konuda cache bellek dolduğunda ne olacağı sorusu var. Bellek sonsuz değil sonuçta , dolduğunda hangi verileri atacağına nasıl karar vereceksin? İşte bu durumda Eviction Stratejileri devreye giriyor. Bir sonraki konuda Cache Eviction Stratejilerine değineceğiz.




Eğer yüksek trafikli bir uygulama geliştiriyorsanız, "nasılsa cache var" diyerek arkaya yaslanmak risklidir. Uygulamanızın dayanıklılığı için bu senaryoları kodlama aşamasında göz önünde bulundurmanız elzemdir.

Yorumlar 0
Görüntülenme 91
Henüz yorum yapılmamış. İlk yorumu siz yapın!
Beklenmeyen bir hata oluştu. Yenile 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.