NET Core'da Cache Yönetimi (Distributed) - 2
Önceki yazımda load balancer ile çoklu sunuculu ortamlardaki cache kullanımının oluşturduğu sorunlardan bahsetmiştim. Her sunucunun kendi önbelleğine sahip olması, kullanıcı istekleri farklı sunuculara yönlendirildiğinde tutarsız verilerle karşılaşılmasına neden oluyordu. Sticky Session ise bu soruna yarım bir çözüm sunuyor, sunucu çökmesi gibi durumlarda veri kaybını engelleyemiyordu.
Distributed Cache uygulama sunucularından bağımsız harici bir kaynakta tutulan önbellektir. Sunuculardaki bütün uygulamalar bu kaynaktan beslenirler. Bu sayede önbellek, tüm uygulama sunucuları arasında ortak ve tek bir kaynak haline gelir.
Distributed Cache ne zaman ihtiyaç duyarız sorusuna cevap verecek olursak
- Uygulamamız birden çok sunucuda çalışıyorsa
- Oturum (session) verilerini sunucular arasında paylaşmamız gerekiyorsa
- In-Memory Cache kullanımının yetersiz kaldığı durumlarda, tutarlılıkla gerektiren durumlarda
- Yüksek boyutlarda verilerin tutulduğu ve instanceların sık sık yeniden başlatıldığı durumlarda distributed cache ihtiyaç duyuyoruz.
In-Memory Cache'e Göre Avantajları
- Consistency (Tutarlılık) : Veri tutarlılığı en büyük avantajdır. Tüm uygulamalar tek bir kaynaktan verileri tükettiği için ilk yazımda bahsettiğim Cache Inconsistency sorunu ortadan kalkar.
- Scalability (Ölçeklenebilirlik) : Var olan uygulamalara yeni sunucu eklendiği zaman bu sunucular doğrudan mevcut distributed cache erişir. Dolayısıyla cache içerisine yeniden veri doldurulması gerekmediğinden (warm-up) beklenmez.
- Resilience (Dayanıklılık) : Uygulama sunucularından herhangi bir sunucuda çökme veya kapanma senaryolarında cachedeki veriler kaybolmayacağı için diğer sistemlerin çalışmasına engel olacak bir durumu engellemiş oluyoruz. Yapımızı daha dayanıklı bir hale getiriyoruz.
- Esneklik : In-Memory Cache kullanırken RAM kısıtı durumu söz konusu. Verileri RAM'de sakladığımızdan dolayı sunucunun RAM Kapasitesi ile sınırlıdır. Fakat Distributed Cache ihtiyaca göre ölçeklendirilebilir ve daha büyük verileri depolayabilme imkanına sahip oluyoruz.
Dezavantajları
- Network Latency(Ağ Gecikmesi) : Veriye erişmek için harici bir kaynağa gitmek durumunda kalıyoruz. Bu In-Memory Cache'e kıyasla her zaman daha yavaş. Bu sebepten dolayı distributed cache'de sık erişilen veya istenen verinin elde edilmesinin maliyetli olduğu senaryolarda kullanmak en doğru seçenek olacaktır. Her veriyi buraya koymak ve en ufak bir veri için bile harici kaynaktan tüketmek performanslı olmayacaktır. Bu noktada kritik olmayan veya çok sık değişen veriler için In-Memory Cache veya paylaşılması gereken tutarlılık arz eden veriler için distributed cache kullanarak Hybrid Cache yaklaşımı izleyebiliriz. (Hybrid Cache konusuna ilerleyen konularda örneklerle anlatılacaktır.)
- Complexity (Karmaşıklık) : Distributed cache kullanmak için bir katman eklenmesi gerekir. (Redis, Azure Cosmos DB,Sql Server vb.)
- Cost (Maliyet) : In-Memory Cache ücretsizdir sunucunun RAM'inde barındırılır. Fakat Distributed Cache ise genellikle ek bir sunucu veya bulut tabanlı bir hizmet gerektirir buda ek bir maliyet anlamına geliyor.
- Serialization (Serileştirme) : Distributed Cache'de bulunan verilerin ağ üzerinden taşınabilmesi için bir Serialization işlemine tabi tutmak gerekiyor. JSON yada Binary gibi formatlara çevrilmesi gerekiyor. Bu işlemde donanım yükü olarak karşımıza çıkıyor. Karışık verileri saklarken Serialization işlemi maliyetli olabiliyor bunuda göz önünde bulundurmak gerekiyor.
Çok daha fazla detaya boğmadan .NET Core'da distributed cache kullanımını anlatmaya geçelim. Çünkü ilerleyen yazı serisinde detaylara girmeye devam edeceğiz.
.NET Core'da Distributed Cache Kullanımı
Öncelikle Redis üzerinden kullanımı anlatacağım.
Redis kurulumu için 2 seçeneğimiz var. Birisi docker üzerinde kurulumu gerçekleştirmek docker kurulumu biraz zor olabilir. Docker üzerinde kurulumunu da anlatabilirdim ama basit seviyeden ileriye seviye doğru anlatım yaptığım bir seri olduğu için en basit yolunu anlatmayı tercih ettim. Bu yüzden Windows alternatifi bulunmakta.Bu adresten gerekl installerı indirip Redis'i kurabilirsiniz.Releases · microsoftarchive/redis
Kurulumu yaptıktan sonra DataGrip kullanarak Redis'e bağlanarak kurulumun ve bağlantımın düzgün bir şekilde çalıştığını doğruluyorum.
Redis kurulumunu gerçekleştirdikten sonra yeni bir ASP.NET Core Web Api projesi oluşturuyoruz ve ardından
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
paketini projemize ekliyoruz.
Daha sonra Redis bağlantı adresini "appsettings.json" içerisine ekliyoruz.
{
"ConnectionStrings": {
"RedisConnection": "localhost:6379"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Servislere Redis'i ekliyoruz. Burada dikkat edilecek nokta "InstanceName" kısmıdır. Redis birden fazla uygulama, servis veya müşteri tarafından ortak kullanılabilir. Mikroservis mimarisinde farklı servisler aynı Redis'i paylaşıyorsa ve aynı anahtarı kullanıyorsa bir uygulama "ProductList" keyine kendi ürün listesini yazabilir buda diğer servislerin verisini bozabilir. Bu soruna Key Collision (Anahtar Çakışması) denir. Dolayısıyla servis kendi verilerini tüketecekse yada müşteri kendi verilerini görecekse örneği multi-tenancy bir yapıda InstanceName değerine o müşteriye ait bir prefix ekliyoruz.
Genelde InstanceName prefixini
- Multi-tenant sistemlerde
- Ortamlara göre verilerin karışmasını engellemek (Dev,Test,Prod)
- Yada modül ayrımı durumlarında o modüllere özgü kullanıyoruz.
Multi-Tenancy bir uygulamada her müşteri için ayrı bir prefix kullanmak "Tenant1" , "Tenant2" gibi kullanımlar yaygın kullanımlardır. Fakat unutulmaması gereken birşey var bu sadece mantıksal bir ayrımdır. güvenlik amaçlı değildir. Yani başka bir uygulama kendi verilerini tüketiyorken bu keyi değiştirirse farklı tenant'a ait verilere erişebilecektir. Burada da Redis'te access control list (ACL) kullanılabilir. Yada fiziksel olarak ayrı Redis instanceları kullanılabilir. İlerleyen zamanlarda bu konuya da detaylıca değinebiliriz.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("RedisConnection");
// InstanceName ayarı, Redis'teki anahtarlarınızın başına bu metni ekler.
// Örneğin "ProductList" anahtarı Redis'te "SampleInstance_ProductList" olarak görünür.
// Bu, aynı Redis sunucusunu kullanan farklı uygulamaların anahtar çakışmasını önler.
options.InstanceName = "SampleInstance_";
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
// Projeye modelimizi ekleyelim.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
Controllers/ProductsController.cs – Şimdi IDistributedCache kullanarak Redis'e veri ekleyip okuyalım.
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;
using WebApiRedis.Models;
namespace RedisCacheDemo.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
private readonly IDistributedCache _cache;
private const string ProductsCacheKey = "ProductList"; // InstanceName otomatik eklenecek
public ProductsController(IDistributedCache cache)
{
_cache = cache;
}
[HttpGet]
public async Task<IActionResult> GetProducts()
{
// Rediste veri varsa veriyi cacheden alır
var cachedData = await _cache.GetStringAsync(ProductsCacheKey);
List<Product>? products;
if (!string.IsNullOrEmpty(cachedData))
{
// Cache'te varsa, JSON'dan deserialize eder
products = JsonSerializer.Deserialize<List<Product>>(cachedData);
return Ok(products);
}
// Cache ver yok kaynaktan veriyi çekilir.
products = GetSampleProducts();
// Cache'e eklemek için serialize edilir ve cache'e kaydedilir
var serializedProducts = JsonSerializer.Serialize(products);
var cacheOptions = new DistributedCacheEntryOptions
{
// Verinin cache'te ne kadar süre kalacağını belirler
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
// Veriye erişildikçe süresini uzatır
SlidingExpiration = TimeSpan.FromMinutes(1)
};
await _cache.SetStringAsync(ProductsCacheKey, serializedProducts, cacheOptions);
return Ok(products);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
var cachedData = await _cache.GetStringAsync(ProductsCacheKey);
if (string.IsNullOrEmpty(cachedData))
return NotFound("Cache bulunamadı.");
var products = JsonSerializer.Deserialize<List<Product>>(cachedData);
var product = products?.FirstOrDefault(p => p.Id == id);
if (product == null)
return NotFound();
return Ok(product);
}
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] Product newProduct)
{
// Cache'teki liste alınır
var cachedData = await _cache.GetStringAsync(ProductsCacheKey);
List<Product> products;
if (!string.IsNullOrEmpty(cachedData))
{
products = JsonSerializer.Deserialize<List<Product>>(cachedData) ?? new List<Product>();
newProduct.Id = products.Any() ? products.Max(p => p.Id) + 1 : 1;
products.Add(newProduct);
}
else
{
// Cache boşsa yeni liste oluştur
newProduct.Id = 1;
products = new List<Product> { newProduct };
}
// Güncellenmiş listeyi cache'e geri yazar
var serializedProducts = JsonSerializer.Serialize(products);
await _cache.SetStringAsync(ProductsCacheKey, serializedProducts);
return CreatedAtAction(nameof(GetProduct), new { id = newProduct.Id }, newProduct);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, [FromBody] Product updatedProduct)
{
var cachedData = await _cache.GetStringAsync(ProductsCacheKey);
if (string.IsNullOrEmpty(cachedData))
return NotFound("Cache bulunamadı.");
var products = JsonSerializer.Deserialize<List<Product>>(cachedData);
var existingProduct = products?.FirstOrDefault(p => p.Id == id);
if (existingProduct == null)
return NotFound();
existingProduct.Name = updatedProduct.Name;
existingProduct.Price = updatedProduct.Price;
var serializedProducts = JsonSerializer.Serialize(products);
await _cache.SetStringAsync(ProductsCacheKey, serializedProducts);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
var cachedData = await _cache.GetStringAsync(ProductsCacheKey);
if (string.IsNullOrEmpty(cachedData))
return NotFound("Cache bulunamadı.");
var products = JsonSerializer.Deserialize<List<Product>>(cachedData);
var productToRemove = products?.FirstOrDefault(p => p.Id == id);
if (productToRemove == null)
return NotFound();
products!.Remove(productToRemove);
var serializedProducts = JsonSerializer.Serialize(products);
await _cache.SetStringAsync(ProductsCacheKey, serializedProducts);
return NoContent();
}
[HttpDelete("clear")]
public async Task<IActionResult> ClearCache()
{
await _cache.RemoveAsync(ProductsCacheKey);
return Ok("Ürün cache'i temizlendi.");
}
private List<Product> GetSampleProducts()
{
return new List<Product>
{
new Product { Id = 1, Name = "Laptop", Price = 15000 },
new Product { Id = 2, Name = "Mouse", Price = 300 },
new Product { Id = 3, Name = "Klavye", Price = 500 }
};
}
}
Daha sonra apimize get isteği atarak cache kısmında verilerin eklendiğini ve api sunulmasını görebilirsiniz.
Bir sonraki yazı serisinde iki cache türünün de karşılaştığı en büyük sorunlardan birini ele alacağız. Aynı anda gelen binlerce isteğin cache'in süresi dolduğunda sistemi nasıl çökerttiğini ve bundan korunmak için hangi yöntemleri kullanabileceğimizi detaylıca inceleyeceğiz.
options.InstanceName = "SampleInstance_"; bu olayı bilmiyordum manuel yazıyordum. Teşekkürler çok güzel içerik