Tüm yazılara dön
·5 dk okuma

.NET'te önbellekleme: cache-aside, stampede koruması ve HybridCache

Önbellek, yavaş bir sorguyu hızlandırmanın en kolay yolu gibi görünür: sonucu bir yere koy, tekrar sorulursa oradan dön. Sorun şu ki önbellek kodu yazması kolay, doğru davranmasını sağlamak zor bir şeydir. Eski veri gösteren ekranlar, aynı anda binlerce istekle ezilen veri tabanı ve neden çalıştığı anlaşılmayan "temizleyince düzeliyor" hataları çoğunlukla bu ayrıntılardan çıkar.

Bu yazıda .NET tarafında önbelleği üç soruyla ele alıyorum: Neyi, ne kadar süreyle saklamalıyım? Veri değişince ne olacak? Aynı anda gelen çok sayıda istek aynı eksik anahtarı hesaplamaya kalkarsa ne olacak? Sonra bu soruların büyük kısmını hazır çözen HybridCache'e bakıyorum.

Önce önbelleğe gerçekten ihtiyaç var mı

Önbellek bir optimizasyondur ve her optimizasyon gibi ölçümle başlamalıdır. Sorgunun gerçekten yavaş olduğunu, sık çağrıldığını ve sonucunun kısa süre eskimiş olmasının kabul edilebilir olduğunu doğrulayın. Bazen eksik bir indeks ya da gereksiz bir N+1 sorgusu, önbelleğin saklayacağı sorunun asıl kaynağıdır; onu düzeltmek önbelleğe göre çok daha az karmaşıklık getirir.

İyi adaylar şunlardır: seyrek değişen referans verileri, pahalı hesaplanan ama herkes için aynı olan özetler ve dış servislerden gelen, hız sınırı olan yanıtlar. Kötü adaylar ise kullanıcıya özel, sık değişen ya da yanlış olması maliyetli verilerdir (bakiye, stok adedi gibi).

Cache-aside deseni

En yaygın desen cache-aside'dır: önce önbelleğe bak, yoksa kaynaktan oku, sonucu önbelleğe yaz. IMemoryCache ile şöyle görünür:

public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
    var key = $"product:{id}";

    if (cache.TryGetValue(key, out ProductDto? cached))
        return cached;

    var product = await db.Products
        .Where(p => p.Id == id)
        .Select(p => new ProductDto(p.Id, p.Name, p.Price))
        .FirstOrDefaultAsync(ct);

    if (product is not null)
        cache.Set(key, product, TimeSpan.FromMinutes(5));

    return product;
}

Bu kod çalışır ama iki sessiz sorunu vardır. Birincisi, bulunamayan kayıtlar önbelleğe yazılmadığı için var olmayan bir id'ye gelen her istek veri tabanına gider. İkincisi, anahtar önbellekte yokken gelen eşzamanlı istekler hepsi birden veri tabanına gider. İkincisine stampede diyoruz.

Anahtar ve süre tasarımı

Anahtarı sonucu belirleyen her girdiden üretin. Dil, kiracı, sayfa numarası ya da filtre sonucu değiştiriyorsa anahtarın parçası olmalıdır. Aksi halde bir kullanıcının verisi bir başkasına gösterilebilir; çok kiracılı sistemlerde bu doğrudan bir güvenlik hatasıdır. Anahtar üretimini dağınık string birleştirmeleri yerine tek bir yardımcıda toplamak hataları azaltır.

Süre için tek bir doğru değer yoktur, ama iki ilke işe yarar. Süreyi verinin "ne kadar eski kalabileceği" üzerinden seçin, "ne kadar hızlı olmasını istediğim" üzerinden değil. Ayrıca tüm anahtarlar aynı anda dolmasın diye süreye küçük bir rastgelelik eklemek, aynı anda yenilenme dalgalarını yumuşatır.

Geçersiz kılma: asıl zor kısım

Veri değiştiğinde önbelleği nasıl güncelleyeceğiniz, ne kadar yaşayacağından daha önemlidir. Üç yaklaşım var:

  • Yalnızca süreye güvenmek: En basitidir. Veri en fazla süre kadar eski kalabilir; bu ürün açısından kabul edilebilirse çoğu zaman yeterlidir.
  • Yazarken silmek: Güncelleme yapan kod, ilgili anahtarı önbellekten kaldırır. Bir sonraki okuma taze veriyi yükler. Anahtarı yazan ve silen kodun aynı yerde yaşamasına dikkat edin.
  • Etiketle toplu silmek: Bir kategoriye bağlı tüm ürün listelerini tek hamlede geçersiz kılmak istediğinizde anahtarları tek tek bilmek yerine ortak bir etiket kullanırsınız.

Her durumda, geçersiz kılma hatasının sonucunun sadece "biraz eski veri" olması için süreyi her zaman bir güvenlik ağı olarak koruyun.

Stampede: aynı anda gelen eksik anahtarlar

Popüler bir anahtar süresi dolduğunda, aynı milisaniyede yüzlerce istek önbellekte bulamaz ve hepsi aynı pahalı sorguyu çalıştırır. Veri tabanı o anda en çok yükü taşıyan noktaya dönüşür; yavaşlayan sorgu daha fazla isteği bekletir ve sistem kendini zorlayan bir döngüye girebilir.

Çözümün özü, aynı anahtar için hesaplamayı tek bir isteğin yapması, diğerlerinin sonucu beklemesidir. Elle yazmak mümkündür (anahtar başına bir SemaphoreSlim), ama kilit sözlüğünün temizlenmesi, iptal ve hata durumları gibi ayrıntılar kolayca yanlış yapılır. Bu yüzden hazır bir çözüm kullanmak mantıklıdır.

HybridCache ile sade çözüm

.NET'in Microsoft.Extensions.Caching.Hybrid paketindeki HybridCache'i, bellek içi (L1) önbellek ile dağıtık (L2) önbelleği tek bir API altında birleştirir ve aynı anahtar için eşzamanlı çağrıları tek bir hesaplamaya indirger. Kayıt basittir:

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(5),
        LocalCacheExpiration = TimeSpan.FromMinutes(1)
    };
});

Kullanım ise cache-aside kodunun tamamını tek bir çağrıya toplar:

public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
    return await cache.GetOrCreateAsync(
        $"product:{id}",
        async token => await db.Products
            .Where(p => p.Id == id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .FirstOrDefaultAsync(token),
        tags: ["products"],
        cancellationToken: ct);
}

Aynı anahtar için aynı anda gelen çağrılarda fabrika fonksiyonu yalnızca bir kez çalışır. Güncelleme sonrasında RemoveAsync($"product:{id}") ile tek bir kaydı, RemoveByTagAsync("products") ile etiketli tüm kayıtları geçersiz kılabilirsiniz. Dağıtık katman için IDistributedCache uygulaması (örneğin Redis) kaydedilmişse HybridCache onu otomatik kullanır; kaydedilmemişse yalnızca yerel önbellek çalışır.

Dağıtık bir önbellekte değerler serileştirilir; bu yüzden önbelleğe koyduğunuz tipler basit, serileştirilebilir DTO'lar olmalı. EF Core varlıklarını doğrudan saklamak, ilişkili nesneler ve izleme bilgisi yüzünden sorun çıkarır.

Önbelleği gözlemlemek

Ölçmediğiniz önbelleğin işe yarayıp yaramadığını bilemezsiniz. İsabet (hit) ve ıskalama (miss) oranını, fabrika fonksiyonunun süresini ve önbellek kaynaklı hataları metrik olarak yayımlayın. Isabet oranı düşükse ya anahtar tasarımı yanlıştır ya da süre çok kısadır. Isabet oranı yüksek ama kullanıcılar eski veri şikâyet ediyorsa geçersiz kılma akışında bir boşluk vardır.

Testte de önbelleği sahte bir bağımlılıkla geçmek yerine, hem dolu hem boş durumda davranışı doğrulayın: ikinci çağrı fabrikayı çalıştırmamalı, güncellemeden sonraki çağrı yeni veriyi görmeli.

Kapanış

Önbellek, hızı karmaşıklıkla takas eder; bu takasın değmesi için ölçülmüş bir ihtiyaç ve açık kurallar gerekir. Önce sorgunun kendisini iyileştirin, önbelleğe aldığınız veri için kabul edilebilir eskilik süresini ürünle birlikte belirleyin, anahtarı sonucu etkileyen her girdiyle üretin ve geçersiz kılmayı süreyle destekleyin. Stampede'i elle çözmeye çalışmak yerine HybridCache gibi sınanmış bir yapı kullanın. Sonunda önbellek, uygulamanın görünmeyen ama davranışını belirleyen bir parçası olduğu için onu da diğer parçalar gibi ölçün ve test edin.