Yalnızca bellek içi cache kullanan bir sistemde banka listesi gibi nadiren değişen bir verinin sunucudan sunucuya farklı olduğunu gördüm: bir ekranda eksik, diğerinde fazla banka. Normalde veri değiştiğinde ilgili key'in cache'ten silinmesi lazım. Ama silme yalnızca değişikliği yapan sunucuda oluyor; TTL de yoksa diğerleri eski listeyi süresiz tutuyor. Bu durum pub/sub'a geçmeden çözülemedi.
Bellek içi cache'in (IMemoryCache) birden fazla sunucuda iki ayrı derdi var. Biri bu: her sunucu kendi kopyasını tutuyor ve kopyalar birbirinden habersiz. Diğeri daha sessiz: kaydın süresi dolduğu anda herkesin aynı anda veritabanına koşması (cache stampede). HybridCache, .NET 9 ile gelen, bellek içi ve dağıtık cache'i tek API'de birleştirip stampede'e karşı koruma sunan yeni soyutlama; ikinci derdi çözmek için geldi. Hangisini ne kadar çözdüğünü ölçtüm.
Cache boşaldığında herkes veritabanına koşar
Popüler bir kaydın süresi dolduğu anda o kaydı isteyen herkes cache'i boş bulur ve aynı sorgu için hep birlikte veritabanına gider. Buna cache stampede (izdiham) deniyor; İngilizce kaynaklarda "thundering herd" diye de geçer. IMemoryCache.GetOrCreateAsync buna karşı koruma sunmuyor: aynı anahtar için gelen her istek kendi sorgusunu çalıştırıyor.
Ölçüm düzeneği: her sorgusu 50 ms süren, aynı anda en fazla 100 bağlantıya izin veren sahte bir veritabanı (SqlClient ve Npgsql'de varsayılan havuz boyutu 100). Aynı anahtara, cache boşken eşzamanlı istek gönderdim. .NET 10, Microsoft.Extensions.Caching.Hybrid 10.10.
| İstek | IMemoryCache | HybridCache |
|---|---|---|
| 100 | 100 sorgu, 63 ms | 1 sorgu, 61 ms |
| 1.000 | 1.000 sorgu, p99 612 ms | 1 sorgu, 53 ms |
| 5.000 | 5.000 sorgu, p99 2,9 sn | 1 sorgu, 64 ms |
100 isteğe kadar süre farkı yok; zarar, bağlantı havuzu dolunca başlıyor. Ondan sonra gecikme istek sayısıyla birlikte doğrusal büyüyor.
Daha gerçekçi senaryo sürekli trafik: 200 kullanıcı 10 saniye boyunca istek atıyor, kayıt 1 saniyede bir doluyor. IMemoryCache her süre dolmasında veritabanına ortalama 180 kez gitti, HybridCache 1 kez.
HybridCache neyi çözüyor, neyi çözmüyor?
Kurulumu iki satır; GetOrCreateAsync aynı anahtar için gelen eşzamanlı istekleri tek sorguda birleştiriyor:
builder.Services.AddHybridCache();
public class ProductService(HybridCache cache, AppDbContext db)
{
public async Task<Product?> GetAsync(int id, CancellationToken ct) =>
await cache.GetOrCreateAsync(
$"product:{id}",
async token => await db.Products.FindAsync([id], token),
cancellationToken: ct);
}
Bir IDistributedCache (ör. Redis) kayıtlıysa onu ikinci katman olarak kendisi kullanıyor. Ama baştaki banka listesi problemini çözmüyor. Microsoft'un dokümanı bunu açıkça söylüyor: bir kaydı anahtarla ya da tag ile sildiğinde silme o sunucuda ve Redis'te gerçekleşiyor, diğer sunucuların belleğindeki kopya etkilenmiyor.
Bunu da ölçtüm. İki HybridCache örneği (A ve B sunucusu) ortak bir ikinci katmanı paylaşıyor. İkisi de banka listesini önbelleğe alıyor, veritabanında liste değişiyor, A kaydı siliyor:
| Bellek ömrü | Silme | B güncel listeyi görüyor |
|---|---|---|
| 5 sn | anahtarla | 5 sn sonra |
| 30 sn | anahtarla | 30 sn sonra |
| 30 sn | tag ile | 30 sn sonra |
TTL yok (IMemoryCache) |
anahtarla | hiç |
A silmeden hemen sonra yeni listeyi görüyor; B ise bellek katmanının ömrü dolana kadar eski listeyi göstermeye devam ediyor. HybridCache bizim yaşadığımız "süresiz eski" durumunu "en fazla N saniye eski"ye çeviriyor, ama sıfırlamıyor. İzdiham koruması da aynı şekilde sunucu başına; 5 sunucun varsa veritabanına 1 değil, en fazla 5 sorgu gider.
Yani bizim pub/sub ihtiyacımız HybridCache'le ortadan kalkmazdı. Bunu hazır çözen bir seçenek olarak FusionCache'in backplane'i silme mesajını Redis pub/sub üzerinden bütün sunuculara dağıtıyor. HybridCache'te ise bellek katmanının süresini (LocalCacheExpiration) kısa tutarak eski veriyle yaşanacak süreyi sınırlıyorsun.
Gizli maliyet: her okumada deserialize
Ölçerken beklemediğim bir şey çıktı; açıkçası ben de bilmiyordum. Kayıt zaten bellekteyken okumanın bedeli:
| Okuma | Süre | Bellek ayırma |
|---|---|---|
IMemoryCache.TryGetValue |
28 ns | 0 |
HybridCache, { get; set; } özellikli sınıf |
520 ns | 120 B |
HybridCache, sealed + [ImmutableObject(true)]
|
43 ns | 0 |
HybridCache değiştirilebilir (mutable) bir tipi bellekte tutsa bile her okumada yeniden deserialize ediyor. Sebebi güvenlik: iki istek aynı nesneyi alıp biri değiştirirse cache bozulur. Microsoft varsayılanı bu yüzden temkinli seçmiş. Tip sealed ve [ImmutableObject(true)] ile işaretliyse aynı nesneyi döndürüyor ve fark 18 kattan 1,5 kata iniyor:
[ImmutableObject(true)]
public sealed record Product(int Id, string Name, decimal Price);
Hangisini seçmeli?
- Tek sunucu, seyrek değişen veri:
IMemoryCacheyeter. Popüler anahtarların süresi aynı anda doluyorsa izdihama dikkat et. - Birden fazla sunucu: tutarlılık için ya ortak bir katman (Redis) ya da silme mesajını herkese ulaştıran bir mekanizma (pub/sub, backplane) gerekiyor. HybridCache tek başına bunu vermiyor.
- HybridCache'e geçiyorsan cache'lediğin tipleri
sealed+[ImmutableObject(true)]yap.
HybridCache kullanamadığın eski bir projede (ör. .NET Framework'te paket eklemek istemiyorsan) izdiham korumasını IMemoryCache üzerine kendin de kurabilirsin. Ölçümdeki elle yazılmış sürüm, HybridCache'le aynı sonucu verdi (1.000 istekte 1 sorgu):
public sealed class TekSorguCache(IMemoryCache cache)
{
private readonly ConcurrentDictionary<string, Lazy<Task<object>>> _bekleyenler = new();
public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan ttl)
{
if (cache.TryGetValue(key, out T? value))
return value!;
// Aynı anahtar için yalnızca ilk istek factory'yi çalıştırır, diğerleri aynı Task'ı bekler.
var lazy = _bekleyenler.GetOrAdd(key, k => new Lazy<Task<object>>(async () =>
{
try
{
var result = await factory();
cache.Set(k, result, ttl);
return result!;
}
finally
{
_bekleyenler.TryRemove(k, out _);
}
}));
return (T)await lazy.Value;
}
}
Bütün senaryoların kodu ve ham sonuçlar GitHub'da; kendi makinende dotnet run -c Release -- soguk 1000 ile tekrarlayabilirsin.
Bu yazı ilk olarak huseyinbozdemir.com'da yayımlandı.
Top comments (0)