Cache-Aside Pattern ile Veritabanı Yükünü Azaltma
Veritabanı, çoğu uygulamanın en pahalı ve en zor ölçeklenen bileşenidir. Aynı veri defalarca okunduğunda, her seferinde veritabanına gitmek gereksiz yük ve gecikme yaratır. Cache-Aside Pattern (Lazy Loading), bu sorunu çözmenin en yaygın ve en basit yoludur: veri talep edildiğinde önce cache'e bakılır, bulunamazsa veritabanından okunur ve cache'e yazılır.
1. Cache-Aside Nasıl Çalışır?
Desenin akışı üç adımdan oluşur:
-
Uygulama cache'e sorar. Anahtar (key) ile veri aranır.
-
Cache hit (bulundu): Veri doğrudan cache'ten döner; veritabanına hiç gidilmez.
-
Cache miss (bulunamadı): Veri veritabanından okunur, cache'e yazılır ve istemciye döner.
Kritik Özellik: Cache, veritabanının bir "aynası" değil, isteğe bağlı (on-demand) doldurulan bir kopyasıdır. Yalnızca talep edilen veri cache'lenir; tüm veritabanı önceden yüklenmez.
csharp
public async Task<Product?> GetProductAsync(int id)
{
string cacheKey = $"product:{id}";
// 1. Cache'e sor
if (_cache.TryGetValue(cacheKey, out Product? cached))
return cached;
// 2. Cache miss: veritabanından oku
var product = await _dbContext.Products.FindAsync(id);
if (product is null) return null;
// 3. Cache'e yaz (TTL ile)
_cache.Set(cacheKey, product, TimeSpan.FromMinutes(10));
return product;
}
2. En Kritik Kural: Invalidation Sırası
Cache-Aside'ın en sık yanlış uygulanan kısmı, yazma (write) işlemlerinde cache'in nasıl güncelleneceğidir. İki yaygın yaklaşım vardır:
| Yaklaşım | Açıklama | Risk |
|---|---|---|
| Cache'i güncelle | Yazma sonrası cache'teki değer de güncellenir | Eşzamanlı yazmalarda tutarsızlık |
| Cache'i sil (invalidate) | Yazma sonrası cache'teki anahtar silinir; bir sonraki okuma yeniden doldurur | ✅ Önerilen yaklaşım |
Neden Güncelleme Yerine Silme? İki eşzamanlı yazma işlemi düşünün: A yazar, cache'i günceller; B yazar, cache'i günceller. Ağ gecikmesi nedeniyle güncellemeler ters sırayla uygulanırsa, cache'te eski veri kalabilir. Silme yaklaşımında ise bir sonraki okuma her zaman veritabanından taze veri çeker .
Kritik Sıra: Yazma işleminde önce veritabanı, sonra cache silinmelidir. Ters sıra (önce cache sil, sonra veritabanına yaz) bir yarış durumu yaratır: cache silindiğinde başka bir okuma veritabanından eski veriyi çekip cache'e yazabilir .
csharp
public async Task UpdateProductAsync(Product product)
{
// 1. Önce veritabanını güncelle
_dbContext.Products.Update(product);
await _dbContext.SaveChangesAsync();
// 2. Sonra cache'i sil (güncelleme değil!)
_cache.Remove($"product:{product.Id}");
}
3. Cache Stampede: Sessiz Katil
Cache-Aside'ın en tehlikeli sorunu, cache stampede (thundering herd) olarak bilinir. Popüler bir anahtarın TTL'si dolduğunda, o anda gelen binlerce istek aynı anda cache miss yaşar ve hepsi veritabanına hücum eder. Bu, veritabanını anlık olarak çökertebilir.
Çözüm 1: Kilit (Lock) ile Tek Yükleme
Aynı anahtar için yalnızca bir isteğin veritabanına gitmesine izin verin; diğerleri bekler.
csharp
private static readonly SemaphoreSlim _lock = new(1, 1);
public async Task<Product?> GetProductSafeAsync(int id)
{
string cacheKey = $"product:{id}";
if (_cache.TryGetValue(cacheKey, out Product? cached))
return cached;
await _lock.WaitAsync();
try
{
// Çift kontrol: kilit beklerken başka biri doldurmuş olabilir
if (_cache.TryGetValue(cacheKey, out cached))
return cached;
var product = await _dbContext.Products.FindAsync(id);
if (product is not null)
_cache.Set(cacheKey, product, TimeSpan.FromMinutes(10));
return product;
}
finally
{
_lock.Release();
}
}
Çözüm 2: Kademeli TTL (Jitter)
Aynı anda dolan anahtarların TTL'sine rastgele bir sapma ekleyin: 10 dakika + random(0-60 saniye).
Çözüm 3: Erken Yenileme (Refresh-Ahead)
TTL dolmadan önce, arka planda cache'i yenileyin; kullanıcı hiç cache miss yaşamasın.
4. Tutarlılık ve TTL: Ne Kadar Eski Veri Kabul Edilebilir?
Cache-Aside, eventual consistency getirir: veritabanı güncellendiğinde cache'teki eski veri, TTL dolana veya invalidation yapılana kadar kalabilir.
TTL Seçimi:
| Veri Türü | Önerilen TTL | Gerekçe |
|---|---|---|
| Ürün kataloğu, kategori listesi | 10-60 dakika | Sık değişmez, eski veri tolere edilebilir |
| Kullanıcı profili | 5-15 dakika | Orta sıklıkta değişir |
| Stok, fiyat, bakiye | 30 saniye - 2 dakika | Tutarlılık kritik; kısa TTL veya invalidation |
| Referans veriler (ülke, para birimi) | Saatler - günler | Neredeyse hiç değişmez |
Kritik Kural: TTL, iş gereksinimlerine göre belirlenmelidir. "Ne kadar eski veri kabul edilebilir?" sorusunun cevabı, TTL'yi belirler. Finansal verilerde saniyeler, ürün kataloğunda dakikalar kabul edilebilir.
5. .NET'te Uygulama: IMemoryCache vs. IDistributedCache
| Özellik | IMemoryCache | IDistributedCache |
|---|---|---|
| Kapsam | Tek instance (in-process) | Tüm instance'lar (Redis, SQL) |
| Hız | En hızlı (bellek içi) | Ağ gecikmesi (~1-5ms) |
| Kullanım | Tek sunuculu uygulamalar | Yatay ölçeklenmiş sistemler |
| Tutarlılık | Instance başına farklı olabilir | Merkezi, tutarlı |
Yatay ölçeklenmiş sistemlerde IMemoryCache yetersizdir: her instance kendi cache'ini tutar, bir instance'ta güncellenen veri diğerlerinde eski kalır. Bu durumda Redis tabanlı IDistributedCache kullanılmalıdır.
csharp
// Redis ile distributed cache
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
options.InstanceName = "MyApp:";
});
// Kullanım
public async Task<Product?> GetProductAsync(int id)
{
string cacheKey = $"product:{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await _dbContext.Products.FindAsync(id);
if (product is not null)
{
await _cache.SetStringAsync(cacheKey,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
}
return product;
}
6. Ne Zaman Cache-Aside Kullanılmalı?
| Uygun | Uygun Değil |
|---|---|
| Okuma yoğun, yazma seyrek iş yükleri | Yazma yoğun iş yükleri |
| Aynı verinin sık okunması | Her okumanın farklı veri getirdiği senaryolar |
| Eski verinin kısa süre tolere edilebildiği durumlar | Güçlü tutarlılık gerektiren finansal işlemler |
| Veritabanı yükünü azaltmak isteyen sistemler | Cache'in kendisinin darboğaz olacağı sistemler |
7. En İyi Pratikler
| Pratik | Açıklama |
|---|---|
| Yazmada önce DB, sonra cache sil | Ters sıra yarış durumu yaratır |
| TTL'i iş gereksinimine göre belirleyin | "Ne kadar eski veri kabul edilebilir?" sorusuna yanıt verin |
| Stampede koruması ekleyin | Kilit, jitter veya refresh-ahead kullanın |
| Yatay ölçeklemede distributed cache kullanın | IMemoryCache instance başına farklılaşır |
| Cache key'lerini tutarlı adlandırın | product:{id} gibi; versiyonlama için product:v2:{id} |
| Cache'i bir "hızlandırıcı" olarak görün | Cache çökerse uygulama veritabanından çalışmaya devam etmelidir (graceful degradation) |
Sonuç
Cache-Aside Pattern, veritabanı yükünü azaltmanın en yaygın ve en etkili yoludur. Okuma yoğun sistemlerde, doğru TTL ve invalidation stratejisiyle veritabanı yükünü önemli ölçüde düşürür ve yanıt sürelerini milisaniyelere indirir. Ancak bu desen "bedava" değildir: tutarlılık, stampede ve invalidation sırası gibi konular yanlış yönetilirse, faydadan çok zarar getirir. Unutmayın: Cache, veritabanının yerini almaz; onu rahatlatır. Cache çöktüğünde sistem çalışmaya devam etmelidir; cache'e bağımlı bir mimari, tek nokta arızası yaratır.