Uygulamanızın daha hızlı ve performanslı çalışmasını mı istiyorsunuz?
O çözüm: Caching.

Bu yazımda In-Memory Caching ana başlığı altında ResponseCache, MemoryCache kavramlarından ve bu kavramların uygulanmasında iki önemli husus olan Expiration ve Validation terimlerinden bahsedeceğim.

Yazı boyunca konuları şu sıralama ile ele alacağım:

  • Giriş: Neden Cache Kullanıyoruz?
  • MemoryCache (Server-side caching)
  • ResponseCache (Client-side caching)
  • Expiration Model
  • Validation Model (ETag / If-None-Match)
  • ResponseCache + ETag Kombinasyonu
  • Özet / Sonuç

Giriş: Neden Cache Kullanıyoruz?

Cache kullanımının amacı, bakış açınıza göre farklılık gösterebilir.

  • Kullanıcı için: Bir alışveriş sitesinde dolaşırken sayfaların anında yüklenmesini ister.
  • Geliştirici için: Aynı veriyi tekrar tekrar veritabanından sorgulamak yerine sistem kaynaklarını verimli kullanmak önemlidir.

İşte caching, hem kullanıcıya hız kazandıran hem de geliştiriciye sunucu yükünü azaltan bir çözüm sunar.

Bu yazıda farklı caching yöntemlerinden bahsedeceğim. İlk yöntem olan MemoryCache (Server-side caching) ile başlayalım.

MemoryCache (Server-side caching)

MemoryCache, verilerin sunucu tarafında geçici olarak bellekte tutulduğu bir mekanizmadır. Verilerin sunucuda ne kadar saklanacağı önceden tanımlanan duration’a bağlıdır. Belirtilen süre boyunca sunucunun memory’sinde kalır, süresi geçtiğinde memory’den silinir.

Bunun amacı:

  • Tekrar tekrar kullanılan verileri bellekte saklamak,
  • Veritabanı sorgularını azaltmak,
  • Uygulamanın yanıt süresini ciddi şekilde düşürmek.

Örnek Senaryo

Bir kitap satıcısı olduğunuzu düşünün.
Son dönemde oldukça popüler olacağı tahmin edilen bir kitabın lansmanından sonra binlerce müşteri aynı kitabı sorgulamak için sitenize geliyor.

Eğer endpointiniz şu şekildeyse, her gelen istek için veritabanına sorgu atılacak ve sayfa yüklenmesi yavaşlayacaktır:

[HttpGet("books/{id}")]
public async Task<IActionResult> GetBook(int id)
{
var book = await _context.Books.FindAsync(id);
return Ok(book);
}

Böyle bir durumda response time (yanıt süresi) yüksek olacaktır.

Cache’siz yanıt süresi: 683ms

MemoryCache Kullanımı

Peki bu endpointimize MemoryCache ekleseydik ne olurdu?

private readonly IMemoryCache _cache;

public BooksController(IMemoryCache cache)
{
_cache = cache;
}

[HttpGet("books/{id}")]
public async Task<IActionResult> GetBook(int id)
{
if (!_cache.TryGetValue($"book_{id}", out Book book))
{
book = await _context.Books.FindAsync(id);

var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));

_cache.Set($"book_{id}", book, cacheOptions);
}

Response.Headers.Add("X-Cache", "HIT");
return Ok(book);
}

Burada şunları yaptık:

  • TryGetValue ile cache’de veri var mı diye kontrol ettik.
  • Eğer yoksa veritabanına gittik ve sonucu belleğe kaydettik.
  • Cache’e eklerken 5 dakika boyunca bellekte tutulacak şekilde ayarladık.
  • Son olarak, X-Cache header’ı ile verinin nereden geldiğini gözlemleyebilir hale geldik.

İlk İstek:
Cache henüz boş olduğu için ilk istekte veritabanına gidildi ve sorgu çalıştırıldı.

  • Response time: 652 ms
  • Kitap verisi cache’e eklendi (5 dakika boyunca tutulacak).
Caching eklendikten sonra ilk response time 652ms
Caching eklendikten sonra atılan ilk requestteki db sorgusu.

Bu ilk istek sonucunda response oluşturulduktan sonra ‘116’ id numaralı kitap verisi sunucu tarafında geçici olarak saklanıyor (5 dakika olarak ayarladık).

İkinci İstek:
Artık veri cache’te bulunduğu için veritabanına sorgu atılmadı.

  • Response time: 3 ms
  • Response header’da X-Cache: HIT bilgisi geldi.
Caching eklendikten sonra ikinci response time 3ms
Veri MemoryCache’ten geldiği için eklenen ‘X-Cache’ header ‘HIT’ değerini aldı.

Caching eklendikten sonra atılan 2.istekte db sorgusu gönderilmiyor(sadece ilk db sorgusu var)

Gördüğünüz gibi caching sayesinde sadece hız kazanmadık, aynı zamanda veritabanına gereksiz sorgular göndermemiş olduk. Bu da hem sunucunun CPU yükünü hem de veritabanı trafiğini ciddi şekilde azaltır. Eğer MemoryCache kullanmasaydık, her istek başına tekrar tekrar veritabanına sorgu atılacak ve performans kaybı yaşanacaktı.

Endpointlerin yük altındaki performansları

Press enter or click to view image in full size

One Book V1 caching olmayan versiyon, One Book V2 ise memory caching uygulanan versiyon.

Aynı endpointi caching olmayan ve caching olan versiyon olarak çoğalttım ve 1 dakika boyunca istek attım. Memory Cache uygulanan endpointteki değerlerin, caching uygulanmayan versiyona göre ne kadar performanslı çalıştığını görebiliyoruz.

Not: Caching uygulanmayan endpointin response time’ı da istek sayısı çoğaldıkça düştü, bunun sebebi veritabanında veya orm aracının uyguladığı caching mekanizması.

MemoryCache’in Avantajları ve Sınırlamaları

Avantajlar:

  • Yanıt sürelerini ciddi şekilde düşürür ve kullanıcı deneyimini iyileştirir.
  • Veritabanına yapılan gereksiz sorguları azaltır, CPU ve kaynak kullanımını düşürür.
  • Kurulumu ve kullanımı basittir; ek bir sunucu gerektirmez.

Sınırlamalar:

  • Sunucu yeniden başlatılırsa cache içindeki tüm veriler kaybolur.
  • Büyük veri setleri için bellek kullanımı artabilir.
  • Yalnızca tek sunucu ortamında etkilidir; dağıtık (clustered) sistemlerde Redis gibi bir distributed cache tercih edilmelidir.
  • Sürekli değişen veriler için uygun değildir; cache güncelliğini kaybedebilir.

MemoryCache’i, sık kullanılan ve nadiren değişen veriler için kullanmak en iyisidir. Böylece hem performans kazanır hem de sistem kaynaklarını verimli kullanılmış olur.

MemoryCache kısmı için anlatacaklarım bu kadardı, dilerseniz ikinci kısım olan ResponseCache’e geçelim.

ResponseCache (Client-side caching)

Press enter or click to view image in full size

Client-side caching’in uygulanma şekli

ResponseCache, ASP.NET Core’da kullanılan client-side caching yöntemlerinden biridir. Bu yöntemde, endpoint’lerden dönen yanıtlar kullanıcının tarayıcısında veya ara proxy sunucularında saklanır.

Client-side caching’in özelliği, her kullanıcının kendi tarayıcı cache’ini kullanmasıdır. Bu nedenle, kullanıcıların cache’den faydalanabilmesi için aynı endpoint’e birden fazla istek göndermesi gerekir. Böylece, sonraki isteklerde sunucuya gitmeden cached response alınarak performans ve yanıt hızı artırılır.

Client-side caching’te, sunucudan dönen yanıtın HTTP header’ları incelenir. Eğer gerekli cache header’ları bulunuyorsa, response tarayıcının cache’ine kaydedilir.

Kullanıcı aynı endpoint’e tekrar istek gönderdiğinde, tarayıcı öncelikle cache’inde saklanan response’un header’larındaki max-age değerini kontrol eder:

  • Eğer response hâlâ geçerli (max-age süresi dolmamış) ve validation gerekmiyorsa, tarayıcı sunucuya gitmeden cache’deki yanıtı döner.
  • Eğer max-age süresi dolmuş veya validation gerekiyorsa, tarayıcı isteği sunucuya iletir ve sunucu veri değişmemişse 304 Not Modified, değişmişse yeni response döner.

Bu mekanizma, sık erişilen veriler için sunucu yükünü azaltır ve yanıt sürelerini hızlandırır.

Tarayıcının yaptığı otomatik işlemler postman’de olmadığı için tarayıcıdan bir örnek vermek istiyorum.

[ResponseCache(Duration = 60)]
public async Task<IActionResult> GetAllBooks()
{
var books = await _manager.BookService.GetAllBooksAsync();
return Ok(books);
}

Şöyle bir endpointimiz var, bu endpointe istek attığımızda response olarak ne döneceğini inceleyelim:

Press enter or click to view image in full size

İlgili endpoint’e atılan isteğin response headers’ındaki ‘cache-control’ headerı.

İlk isteği attığımızda gördüğünüz gibi response headers’ta ek olarak ‘cache-control’ header’ı eklendi. Bu header, response’un tarayıcının cache’ine kaydedilmesini sağlar.

İkinci istek:

Press enter or click to view image in full size

Tarayıcı cache’inden dönen response headers’ta bulunan ‘age’ alanı.

İkinci isteği attığımızda gelen response’un headerlarını inceleyelim. Göreceğiniz üzere ilk response header’ından farklı olarak ‘age’ header’ı var. Bu header, üretilen response’un cache’de ne zamandır var olduğunu, aynı zamanda da verinin cache’den geldiğini belirtiyor.

Bu noktada duraksayalım ve iki farklı cache mekanizmasını karşılaştıralım. Bu mekanizmaların temel farkı verilerin saklandığı yerdir:

  • Server-side caching: Veriler sunucu tarafında tutulur.
  • Client-side caching: Veriler kullanıcı tarayıcısında veya proxy’lerde saklanır.

Peki, bu bize ne kazandırır?

  • Client-side caching sayesinde, sunucuya istek gönderilmeden cache’deki veri kullanılabilir. Eğer cache’deki veri artık geçerli değilse, tarayıcı isteği sunucuya iletir.
  • Bu sırada ilgili endpoint, server-side caching ile verilerin hızlı bir şekilde sunulmasını sağlayabilir.

Sonuç olarak, client-side ve server-side caching birbirini tamamlayıcı şekilde çalışarak hem performansı artırır hem de sunucu yükünü azaltır.

Expiration Model ve Validation Model (ETag / If-None-Match)

Yazımızın burasına kadar hem sunucu tarafında hem de istemci tarafında barındırılan verilerden, bu verilerin performansa nasıl katkı sağladığından ve geçici olarak saklanmasının neden önemli olduğundan bahsettik. Şimdi ise verilerin bu geçici saklama sürecinin hangi yöntemlerle yönetilebileceğini inceleyelim.

Temel olarak iki farklı yaklaşım vardır: Expiration Model ve Validation Model.

  • Expiration Model, adından da anlaşılacağı üzere, verinin belirli bir süre boyunca cache’te tutulmasını esas alır. Yani sistem, cache’e alınan bir veriyi “önceden belirlenmiş süre dolana kadar” geçerli kabul eder. Süre dolduğunda ise veri cache’ten otomatik olarak silinir ve ihtiyaç halinde tekrar kaynaktan alınır. Bu modelde asıl amaç, gereksiz veri tekrarlarını azaltmak ve belirlenen süre boyunca hızlı erişim sağlamaktır.
  • Validation Model ise farklı bir yaklaşım sunar. Burada önceden belirlenen sabit bir süre yoktur. Bunun yerine cache’teki verinin güncel olup olmadığı, istemciden sunucuya yapılan doğrulama istekleri (örneğin ETag veya If-None-Match header’ları aracılığıyla) üzerinden kontrol edilir. Eğer veri değişmemişse, istemciye tekrar veri gönderilmesine gerek kalmaz ve mevcut cache kullanılmaya devam edilir. Ancak veri güncellenmişse, yeni sürüm istemciye gönderilir ve cache güncellenir. Bu modelin en büyük avantajı, her zaman güncel veriyi garanti altına almasıdır.

Kısacası:

  • Expiration Model → “Veriyi şu süre boyunca geçerli say.”
  • Validation Model → “Veri gerçekten güncel mi, önce kontrol et.”

Her iki modelin de kullanım alanları vardır. Expiration modeli, belirli aralıklarla güncellenen ama gerçek zamanlı güncellik gerektirmeyen veriler için uygundur. Validation modeli ise özellikle dinamik ve sık değişen verilerde tercih edilir.

Expiration Model’e örnek verecek olursak daha önce bahsettiğimiz ResponseCache buna örnektir. Belirli bir süre verilerek kullanılır. İsterseniz ResponseCache’i nasıl kullandığımızı hatırlayalım.

[ResponseCache(Duration = 60)]
public async Task<IActionResult> GetAllBooks()
{
var books = await _manager.BookService.GetAllBooksAsync();
return Ok(books);
}

Burada ResponceCache attribute’unda belirtilen ‘Duration=60’ ifadesi, üretilecek olan response’un cache’de ne kadar kalacağını belirler. Bu sebeple ResponseCache örneği Expiration Model’e örnektir.

Press enter or click to view image in full size

‘Cache-Control’ kısmındaki max-age=60 bilgisi, endpointte tanımlanan ResponseCache’in Duration özelliğinden gelmektedir.

Validation Model

Validation Model’de verinin cache’te ne kadar kalacağı sabit bir süreye bağlı değildir. Bunun yerine tamamen verinin güncelliği belirleyici olur. Eğer veri hâlâ güncelse cache’te tutulmaya devam eder, fakat güncelliğini kaybettiği anda silinir ve kaynaktan gelen en son haliyle yenilenir. Bu sayede kullanıcılar her zaman en doğru ve en güncel veriye ulaşır.

Bu model genellikle ETag ve If-None-Match header’ları üzerinden çalışır. İstemci, sunucuya “bende şu versiyon var, sende değişiklik oldu mu?” diye sorar. Eğer veri değişmemişse sunucu “elindeki hâlâ güncel” diyerek 304 Not Modified yanıtı döner. Bu durumda veri yeniden gönderilmez, böylece gereksiz veri transferi ve ağ trafiği engellenmiş olur. Eğer veri değişmişse, sunucu güncel halini istemciye gönderir ve cache bu yeni içerikle güncellenir.

İsterseniz ilk olarak bunun örneğini, devamında da nasıl işlediğini adım adım anlatalım:

[HttpGet("{id:int}")]
public async Task<IActionResult> GetBookById([FromRoute(Name = "id")] int id)
{
var book = await _manager.BookService.GetOneBookByIdAsync(id, false);
if(book == null)
return NotFound($"Book with id: {id} doesn't exist in the database.");

var eTag = Convert.ToBase64String(Encoding.UTF8.GetBytes(book.Title + book.Price));
if (Request.Headers.ContainsKey("If-None-Match") && Request.Headers["If-None-Match"].ToString() == eTag)
return StatusCode(StatusCodes.Status304NotModified);

Response.Headers.TryAdd("ETag", eTag);
return Ok(book);
}

Bu kodda neler oluyor?

  • Adım 1 — Kitap var mı kontrolü:
    Kullanıcı, belirli bir id ile kitap istiyor. Eğer kitap bulunamazsa (örneğin silinmişse), 404 NotFound dönüyoruz. Bu durumda cache’teki veri artık geçersiz demektir.
  • Adım 2 — Benzersizlik anahtarının (ETag) üretilmesi:
    Kitap verisinin bazı özelliklerini (Title + Price) kullanarak bir hash oluşturuyoruz. Bu hash, verinin o anki haline ait benzersiz bir imza görevi görüyor.
  • Adım 3 — Kullanıcının cache’ini doğrulama:
    İstekle birlikte gelen If-None-Match header’ını kontrol ediyoruz. Bu header, kullanıcının elindeki cache’in hangi versiyona ait olduğunu söylüyor. Eğer bu değer bizim oluşturduğumuz ETag ile aynıysa, verinin değişmediğini anlıyoruz. Bu durumda tekrar veritabanından veri çekmeye gerek yok ve 304 Not Modified dönüyoruz. Yani kullanıcıya, “Senin cache’inde bulunan veri hâlâ güncel, onu kullanmaya devam edebilirsin” demiş oluyoruz.
  • Adım 4 — Veri değişmişse:
    Eğer ETag değerleri eşleşmiyorsa, bu verinin güncellendiği anlamına geliyor. Böyle bir durumda yeni veriyi 200 OK ile birlikte dönüyor ve aynı zamanda yeni ETag değerini response header’a ekliyoruz. Böylece istemci, bundan sonraki isteklerinde güncel veriyi referans alabiliyor.

İsteği ilk attığımızda elde edilen response header’ı:

İlk istek sonrası eklenen ETag değeri, bu değer ile güncellik kontrol edilecek.

İkinci isteği atarken request header’ına If-None-Match ekleyerek endpointte güncellik kontrolünün yapılmasını sağlayacağız.

Press enter or click to view image in full size

İkinci request header’ına If-None-Match eklendi

İsteği attıktan sonra gelen response:

Veri değişmediği için 304 Not Modified döndü.

Cache’te bulunan veri ile veritabanındaki veri aynı olduğundan dolayı response 304 Not Modified olarak döndü. Tarayıcılar bu yanıtı gördüğünde kendi cache’indeki veriyi kullanıcıya response olarak döner.

İsterseniz verimizde değişiklik yapalım, cache’deki verinin değişip değişmediğini gözlemleyelim:

Bu istek ile birlikte kitabın ismini ‘beyaz zambaklar ülkesinde’ olarak değiştirdim. Artık client cache’de bulunan veri ile veritabanındaki veri birbiriyle farklı.

Bu kitaba erişmek istediğimizde response olarak ne dönecek görelim:

İlk istekten gelen ETag değeri ile birlikte endpointe istek attık, bize güncel veri geldi.

İlk requestten gelen veride ETag değeri de yer alıyordu. Bu ETag değeri verinin benzersiz anahtarını temsil ediyordu. Bizde ikinci requestte bu ETag değerini header kısmına ‘If-None-Match’ ile ekleyerek endpointte validasyon kontrolünün yapılmasını sağladık.

Gelen response header’ındaki ETag değeri değişti.

İsteği atarken eklediğimiz benzersiz anahtar ile response’taki ETag değeri birbiri ile farklı. Bunun sebebi endpointte ETag değerleri karşılaştırıldı. Verimizi değiştirdiğimiz için veritabanındaki verinin ETag değeri değiştiğinden karşılaştırma sonucu eşitsizlik ile sonuçlandı. Bu sebeple yeni ETag değeri ile güncel veri response olarak gönderildi. Artık yeni bir istek atıldığında bu ETag değeri kullanılarak verinin aynı olması durumda cache kullanımı sağlandı, performans ve hız konusunda avantaj elde etmiş olduk.

İsterseniz bu yeni ETag değeri ile tekrardan istek atalım. Güncel ETag değeri ile sunucudaki verinin ETag değeri eşleşirse 304 Not Modified dönecek.

Yeni ETag değeri ile istek attığımızda 304 Not Modified döndü.

Bu yapıyla birlikte veri güncelliğini garantileyerek cache kullandık.

Özetle Validation Model kullanımı ,hem gereksiz veritabanı sorgularını önlüyor hem de istemcinin her zaman güncel veriye erişmesini garanti ediyor. Validation Model’in gücü de tam olarak burada: sabit bir süreye bağlı kalmadan, doğrudan verinin güncelliğine göre cache’in kullanılıp kullanılmayacağına karar veriyoruz.

ResponseCache + ETag Kombinasyonu

Bu noktaya kadar iki farklı yaklaşımı gördük: Expiration Model ve Validation Model. Her iki modelin kendine özgü avantajları ve dezavantajları bulunuyor:

Expiration Model:

  • Avantajı: Basit ve hızlıdır. Belirlenen süre boyunca veri cache’te tutulur, her istekte direkt olarak cache’den cevap alınır.
  • Dezavantajı: Süre dolmadan veri değişmiş olsa bile istemci eski veriyi kullanabilir, yani veri güncelliği garanti edilmez.

Validation Model (ETag):

  • Avantajı: Veri her zaman güncel tutulur. İstemci, verinin değişip değişmediğini kontrol ederek gereksiz veri transferini önler.
  • Dezavantajı: Her istekte sunucuya bir kontrol request’i gönderilir. Bu küçük bir overhead yaratır ve sistemdeki toplam trafiği biraz artırabilir.

İşte tam bu noktada ResponseCache ile ETag kombinasyonu devreye giriyor. İkisini birleştirerek hem yüksek performans hem de güncel veri garantisi elde edebiliriz:

  1. ResponseCache sayesinde cache süresi boyunca veri hızlıca istemciye iletilir, böylece sık yapılan isteklerde veritabanına gitme ihtiyacı azalır.
  2. ETag ile Validation Model entegre edildiğinde, cache’teki veri güncel mi kontrol edilir. Eğer veri değişmişse, ResponseCache geçersiz sayılır ve yeni veri ile güncellenir.

Böylece hem yüksek performans sağlanır hem de istemcinin her zaman güncel veriye ulaşması garanti edilir. Bu yaklaşım özellikle yoğun trafik alan uygulamalarda, hem CPU yükünü hem de ağ trafiğini optimize etmek için idealdir.

ResponseCache + ETag Kombinasyonuna örnek verelim.

[HttpGet("{id:int}")]
[ResponseCache(Duration = 60, VaryByQueryKeys = ["id"])]
public async Task<IActionResult> GetBookById([FromRoute(Name = "id")] int id)
{
var book = await _manager.BookService.GetOneBookByIdAsync(id, false);
if(book == null)
return NotFound($"Book with id: {id} doesn't exist in the database.");

var eTag = Convert.ToBase64String(Encoding.UTF8.GetBytes(book.Title + book.Price));
if (Request.Headers.ContainsKey("If-None-Match") && Request.Headers["If-None-Match"].ToString() == eTag)
return StatusCode(StatusCodes.Status304NotModified);

Response.Headers.TryAdd("ETag", eTag);
return Ok(book);
}

Bir önceki örnekte verdiğim endpoint’e ResponseCache attribute’u ekledim.

ResponseCache + ETag kombinasyonu uyguladığımız endpointimize ilk isteği atalım.

Kombinasyon sonrası atılan ilk istek.
Kombinasyon uygulandıktan sonraki ikinci istekte expiration model ile veriyi cache’ten aldık.

Buraya kadar yaptığımız işlemlerde, ilk istekte veriyi cache’e aldık ve bu sayede ikinci istekte ResponseCache ile belirlenen süre (max-age=60) içerisinde veriyi hızlıca cache’den döndük. Yani ikinci request, veritabanına gitmeden cache’deki yanıtla cevaplandı ve yanıt süresi ciddi şekilde azaldı.

Ancak ResponseCache attribute’unda tanımlanan süre dolduğunda, cache’deki veri artık expiration süresi dolmuş sayılır ve bir sonraki istekte controller yeniden çalıştırılır. Bu noktada Validation Model devreye girer. Controller, istemciden gelen If-None-Match header’ı ile cache’deki verinin güncel olup olmadığını kontrol eder. Eğer ETag değerleri hâlâ eşleşiyorsa, sunucu 304 Not Modified döner ve istemci kendi cache’indeki veriyi kullanır.

Böylece hem Expiration Model sayesinde kısa süreli yüksek performans sağlanmış olur, hem de Validation Model ile verinin güncelliği garanti edilir. Yani ResponseCache + ETag kombinasyonu, hem hız hem doğruluk açısından en iyi sonucu sunar.

Özet / Sonuç

Bu yazıda ASP.NET Core’da In-Memory Caching, ResponseCache ve ETag / Validation Model kavramları ile ilgili öğrendiklerimden bahsettim. Umarım bahsettiğim kavramlar ile ilgili bir şey katabilmişimdir.

İyi günler dilerim.