ASP.NET Core Web API'de Caching: IMemoryCache, Redis ve Response Caching
ASP.NET Core Web API'de caching: IMemoryCache ile absolute ve sliding expiration, yazınca silerek invalidation, Redis ile IDistributedCache ve response caching middleware'inin hangi isteği neden cache'lemediği.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Bir önceki yazıda API'mizi Swagger ile belgeledik; istemciler artık neyi nasıl çağıracaklarını biliyor. Bu da şu demek: çağıracaklar, hem de çok. Ürün listesi gibi herkesin istediği ve nadiren değişen bir veri için her istekte veritabanına gidip aynı cevabı yeniden hesaplamak, her sipariş için mutfağa yeniden un elemeye benzer.
Yazının sonunda response caching middleware'ini kurmuş olacağız. Şimdi tahmin et: [ResponseCache(Duration = 60)] ile işaretli bir endpoint'e bir dakika içinde art arda dört istek atıyoruz. İlk ikisi düz istek, üçüncüsü Cache-Control: no-cache header'ı taşıyor, dördüncüsü Authorization: Bearer abc. Controller'daki action kaç kez çalışır? Bir mi, iki mi, dört mü?
Cache Neyi Çözer, Neyi Bozar?
Cache, pahalı bir işin sonucunu bir süre saklayıp bir sonraki seferde hazır vermektir. Kazancı açık: daha hızlı response, veritabanında daha az yük. Bedeli de bir o kadar açık: verdiğin cevap artık "şu anki gerçek" değil, "biraz önceki gerçek". Fiyatı az önce değişmiş bir ürünü eski fiyatıyla göstermek, bazı ekranlarda sorun değildir, sepette ise ciddi sorundur.
Bu yüzden cache'i bir hız düğmesi gibi değil, bir tazelik pazarlığı gibi düşün. Her cache kararında üç soruya cevap ver: Bu veri ne sıklıkla okunuyor? Ne sıklıkla değişiyor? Eski hâlini görmek kime ne kadar zarar verir? Okuması çok, değişmesi az, birkaç saniyelik eskiliği tolere edilebilir veri, cache'in en iyi müşterisidir. ASP.NET Core tarafında bu yazıda üç araç göreceğiz: uygulamanın kendi belleğinde duran IMemoryCache, birden fazla sunucunun paylaşabildiği IDistributedCache ve HTTP seviyesinde çalışan response caching middleware.
IMemoryCache ile İlk Cache
Önce cache'e değecek kadar yavaş bir veri kaynağı lazım. Her sorgusu yarım saniye süren bir repository yazalım:
namespace ProductCatalog.Api.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
public record ProductDto(int Id, string Name, decimal Price);
using ProductCatalog.Api.Models;
namespace ProductCatalog.Api.Services;
public interface IProductRepository
{
Task<List<Product>> GetPageAsync(int page, int pageSize, CancellationToken cancellationToken);
Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken);
}
public class SlowProductRepository : IProductRepository
{
private readonly List<Product> _products = new()
{
new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
new Product { Id = 2, Name = "Wireless Mouse", Price = 650m }
};
public async Task<List<Product>> GetPageAsync(int page, int pageSize, CancellationToken cancellationToken)
{
await Task.Delay(500, cancellationToken);
return _products.Skip((page - 1) * pageSize).Take(pageSize).ToList();
}
public async Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
// Yavaş bir veritabanı sorgusunu taklit ediyoruz
await Task.Delay(500, cancellationToken);
return _products.FirstOrDefault(p => p.Id == id);
}
public async Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken)
{
await Task.Delay(100, cancellationToken);
var product = _products.FirstOrDefault(p => p.Id == id);
if (product is null)
{
return false;
}
product.Price = price;
return true;
}
}
Cache mantığını controller'a değil service katmanına koyuyoruz. Controller HTTP ile ilgilenir; verinin nereden ve ne kadar taze geldiği service'in işi.
using Microsoft.Extensions.Caching.Memory;
using ProductCatalog.Api.Models;
namespace ProductCatalog.Api.Services;
public class ProductService
{
private readonly IProductRepository _repository;
private readonly IMemoryCache _cache;
public ProductService(IProductRepository repository, IMemoryCache cache)
{
_repository = repository;
_cache = cache;
}
public async Task<List<ProductDto>> GetPageAsync(int page, CancellationToken cancellationToken)
{
var products = await _repository.GetPageAsync(page, 10, cancellationToken);
return products.Select(p => new ProductDto(p.Id, p.Name, p.Price)).ToList();
}
public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var key = $"product:{id}";
// Cache'te varsa repository'ye hiç gitme
if (_cache.TryGetValue(key, out ProductDto? cached))
{
return cached;
}
var product = await _repository.GetByIdAsync(id, cancellationToken);
if (product is null)
{
return null;
}
var dto = new ProductDto(product.Id, product.Name, product.Price);
_cache.Set(key, dto, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(2)
});
return dto;
}
public async Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken)
{
var updated = await _repository.UpdatePriceAsync(id, price, cancellationToken);
if (updated)
{
// Veri değişti, eski kopyayı at
_cache.Remove($"product:{id}");
}
return updated;
}
}
Kayıtlar ve controller:
using ProductCatalog.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IProductRepository, SlowProductRepository>();
builder.Services.AddScoped<ProductService>();
var app = builder.Build();
app.MapControllers();
app.Run();
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;
namespace ProductCatalog.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ProductService _productService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(ProductService productService, ILogger<ProductsController> logger)
{
_productService = productService;
_logger = logger;
}
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
{
var product = await _productService.GetByIdAsync(id, cancellationToken);
if (product is null)
{
return NotFound();
}
return product;
}
[HttpPut("{id:int}/price")]
public async Task<IActionResult> UpdatePrice(int id, UpdatePriceRequest request, CancellationToken cancellationToken)
{
var updated = await _productService.UpdatePriceAsync(id, request.Price, cancellationToken);
return updated ? NoContent() : NotFound();
}
}
public record UpdatePriceRequest(decimal Price);
AddMemoryCache bir IMemoryCache singleton'ı kaydeder; uygulamanın ömrü boyunca tek bir cache var ve bütün request'ler onu paylaşıyor. ProductService'in scoped olması sorun değil, her instance aynı cache'e bakıyor. İki kez istek atınca fark hemen görünüyor:
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:5201/api/products/1
200 0.537084s
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:5201/api/products/1
200 0.002307s
İlk istek repository'nin yarım saniyesini ödedi, ikincisi bellekten 2 milisaniyede döndü.
Süreler: Absolute ve Sliding Expiration
Bir kaydı cache'e koyarken ne zaman çıkacağını da söylemen gerekir; yoksa bellekte sonsuza kadar kalır. AbsoluteExpirationRelativeToNow kesin bir son kullanma tarihi: kayıt ne kadar sık okunursa okunsun 10 dakika sonra düşer. SlidingExpiration ise bir hareketsizlik süresi: kayıt 2 dakika boyunca hiç okunmazsa düşer, her okumada süre baştan başlar.
İkisini birlikte kullanmamızın sebebi şu: sadece sliding verirsen, sürekli okunan bir kayıt hiç düşmez ve bir gün çok eski bir veriyi servis edersin. Absolute tavan, en kötü durumda verinin ne kadar eski olabileceğini sınırlar. Sliding de az okunan kayıtların belleği boşuna işgal etmesini önler. Süt kutusu gibi düşün: üzerinde bir son kullanma tarihi var, ama kapağını açtıysan birkaç gün içinde bitirmen gerekiyor.
Cache Invalidation: Veri Değişince
Cache'in zor kısmı koymak değil, ne zaman atacağını bilmek. Fiyatı güncelleyip hemen tekrar okuyalım:
curl -s -X PUT http://localhost:5201/api/products/1/price -H "Content-Type: application/json" -d '{"price":1399}' -o /dev/null -w "%{http_code}\n"
204
curl -s -w " %{time_total}s\n" http://localhost:5201/api/products/1
{"id":1,"name":"Mechanical Keyboard","price":1399} 0.503322s
curl -s -w " %{time_total}s\n" http://localhost:5201/api/products/1
{"id":1,"name":"Mechanical Keyboard","price":1399} 0.000913s
Güncellemeden sonraki ilk okuma yine yarım saniye sürdü, çünkü UpdatePriceAsync içindeki _cache.Remove eski kopyayı attı. Yeni fiyat repository'den geldi ve bir sonraki okuma tekrar cache'ten döndü. O Remove satırını silersen, PUT 204 dönse bile GET on dakika boyunca 1450 göstermeye devam eder.
Bu yaklaşımın adı "yazınca sil". Basit ve güvenilir, ama bir şartı var: veriyi değiştiren her yol aynı key'i silmeli. Fiyatı bir admin paneli, bir import job'ı ya da doğrudan veritabanına yazılan bir SQL de değiştiriyorsa, onlar senin Remove'undan habersizdir. Bu yüzden key'leri tek bir yerde üret (burada $"product:{id}") ve absolute expiration'ı hiçbir zaman sonsuz bırakma; o süre, unuttuğun bir yolun cezasını sınırlayan sigortan.
Birden Fazla Sunucu: IDistributedCache ve Redis
IMemoryCache'in iki sınırı var. Birincisi, uygulama yeniden başlayınca cache boşalır. İkincisi daha sinsi: API'yi bir load balancer arkasında üç instance olarak çalıştırdığında her birinin kendi belleği, dolayısıyla kendi cache'i olur. Birinde fiyatı güncelleyip key'i silersin, diğer ikisi eski fiyatı vermeye devam eder.
Çözüm cache'i uygulamanın dışına, herkesin ortak baktığı bir yere taşımak. ASP.NET Core bunun için IDistributedCache soyutlamasını veriyor; en yaygın arkası Redis. Paketi hedef framework'üne uygun sürümle ekle:
# net6.0 projesi için
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 6.0.36
# net10.0 projesi için
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 10.0.14
Program.cs'te AddMemoryCache yerine:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "ProductCatalog:";
});
{
"ConnectionStrings": {
"Redis": "localhost:6390"
}
}
IDistributedCache, IMemoryCache gibi object saklamaz; byte dizisi ya da string saklar, çünkü veri ağ üzerinden başka bir sürece gidiyor. Serialization'ı biz yapıyoruz. Service'in değişen kısmı:
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using ProductCatalog.Api.Models;
namespace ProductCatalog.Api.Services;
public class ProductService
{
private readonly IProductRepository _repository;
private readonly IDistributedCache _cache;
public ProductService(IProductRepository repository, IDistributedCache cache)
{
_repository = repository;
_cache = cache;
}
// GetPageAsync öncekiyle aynı
public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
var key = $"product:{id}";
var json = await _cache.GetStringAsync(key, cancellationToken);
if (json is not null)
{
return JsonSerializer.Deserialize<ProductDto>(json);
}
var product = await _repository.GetByIdAsync(id, cancellationToken);
if (product is null)
{
return null;
}
var dto = new ProductDto(product.Id, product.Name, product.Price);
await _cache.SetStringAsync(key, JsonSerializer.Serialize(dto), new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
}, cancellationToken);
return dto;
}
public async Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken)
{
var updated = await _repository.UpdatePriceAsync(id, price, cancellationToken);
if (updated)
{
await _cache.RemoveAsync($"product:{id}", cancellationToken);
}
return updated;
}
}
Bir istek attıktan sonra Redis'e bakalım:
redis-cli KEYS '*'
ProductCatalog:product:1
redis-cli HGETALL ProductCatalog:product:1
absexp
639293384395654690
sldexp
-1
data
{"Id":1,"Name":"Mechanical Keyboard","Price":1450}
redis-cli TTL ProductCatalog:product:1
600
InstanceName her key'in önüne ön ek olarak eklenmiş; aynı Redis'i paylaşan başka bir uygulamayla çakışmamak için bu önemli. Kayıt bir hash olarak duruyor: veri, son kullanma bilgisi ve 600 saniyelik TTL. Uygulamayı durdurup yeniden başlattıktan sonraki ilk istek 0,05 saniyede döndü; repository'nin yarım saniyesi yok, cache yerinde duruyor. Development'ta Redis kurmak istemezsen AddDistributedMemoryCache() aynı interface'i bellekte uygular. Kodun değişmez, sadece kayıt satırı değişir; ama dağıtık olmadığını unutma.
Response Caching Middleware
Şu ana kadar veriyi cache'ledik; action her seferinde çalıştı, sadece repository'ye gitmedi. Response caching bir adım öteye geçiyor ve HTTP response'unun tamamını saklıyor. Program.cs'e iki satır:
builder.Services.AddResponseCaching();
// app.Build()'den sonra, MapControllers'tan önce
app.UseResponseCaching();
Controller'a liste endpoint'i:
[HttpGet]
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "page" })]
public async Task<List<ProductDto>> GetAll(int page = 1, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Loading products for page {Page}", page);
return await _productService.GetPageAsync(page, cancellationToken);
}
[ResponseCache] iki iş yapıyor. Response'a Cache-Control: public,max-age=60 header'ını ekliyor, yani tarayıcıya ve aradaki proxy'lere "bunu 60 saniye saklayabilirsin" diyor. Middleware de aynı header'a bakıp response'u sunucu belleğinde tutuyor. VaryByQueryKeys ise page=1 ile page=2'nin ayrı kayıtlar olmasını sağlıyor; bu özellik middleware olmadan çalışmaz. İki düz istek:
curl -i "http://localhost:5201/api/products?page=1"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 03 Nov 2026 21:22:50 GMT
Server: Kestrel
Cache-Control: public,max-age=60
Transfer-Encoding: chunked
[{"id":1,"name":"Mechanical Keyboard","price":1450},{"id":2,"name":"Wireless Mouse","price":650}]
curl -i "http://localhost:5201/api/products?page=1"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 03 Nov 2026 21:22:50 GMT
Server: Kestrel
Age: 2
Cache-Control: public,max-age=60
Transfer-Encoding: chunked
[{"id":1,"name":"Mechanical Keyboard","price":1450},{"id":2,"name":"Wireless Mouse","price":650}]
İkinci response'ta Age: 2 header'ı var ve Date değişmemiş: bu cevap iki saniye önce üretilmiş ve middleware tarafından aynen geri verilmiş. Log'da "Loading products" satırı sadece bir kez görünüyor.
Tahminin cevabı: action üç kez çalışır. Birinci istek cache'i doldurur, ikincisi cache'ten döner. Üçüncüdeki Cache-Control: no-cache, istemcinin "saklanmış kopya istemiyorum" demesi; middleware buna uyar ve isteği controller'a geçirir. Dördüncüdeki Authorization header'ı ise middleware'e "bu cevap kişiye özel olabilir" sinyali verir ve cache devre dışı kalır. Tarayıcıda F5'e bastığında cache'in çalışmıyor gibi görünmesinin sebebi de bu: tarayıcı yenilemede genellikle Cache-Control: max-age=0 gönderir. Biz denedik, max-age=0 header'lı istek de controller'a gitti.
Sık Düşülen Tuzaklar
net6.0 projesine Microsoft.Extensions.Caching.StackExchangeRedis 6.0.36'yı eklediğinde build şu uyarıyı verir:
warning NU1904: Package 'System.Drawing.Common' 5.0.0 has a known critical severity vulnerability, https://github.com/advisories/GHSA-rxg9-xrhp-64gj
dotnet nuget why System.Drawing.Common zinciri gösteriyor: paket StackExchange.Redis 2.2.4'e bağlı, o da birkaç ara paket üzerinden bu sürümü çekiyor. dotnet add package StackExchange.Redis --version 2.7.27 ile doğrudan daha yeni bir sürümü referans verince uyarı kayboluyor; 10.0.14 paketi zaten bu sürümü kullanıyor.
Redis ulaşılamaz olduğunda cache yok sayılmaz, istek patlar. Container'ı durdurup istek attığımızda 500 aldık ve log'da StackExchange.Redis.RedisConnectionException: It was not possible to connect to the redis server(s). yazıyordu. Cache bir hızlandırıcı; düştüğünde uygulamayı da düşürmemeli. Cache çağrılarını try/catch ile sarıp hata durumunda doğrudan repository'ye gitmek yaygın bir çözüm.
IMemoryCache object'in kopyasını değil referansını saklar. Cache'ten aldığın bir entity'nin property'sini değiştirirsen cache'teki kayıt da değişir ve bunu herkes görür. Bu yüzden örnekte immutable bir record olan ProductDto'yu sakladık.
Kısa yol olarak GetOrCreateAsync çok kullanışlı, ama factory null dönerse o null'ı da cache'e yazar; olmayan bir ürün için on dakika boyunca 404 dönersin, ürün o arada eklense bile. Bazen istenen de budur, ama bilerek karar ver.
Response caching'i kullanıcıya özel veride kullanma. Authorization header'ı bizi bir kez korudu, ama kimliği cookie ile taşıyan bir uygulamada aynı koruma yok. Middleware sırası da önemli: CORS kullanıyorsan UseCors, UseResponseCaching'den önce gelmeli.
İlerleyen Sürümlerde Neler Var
Response caching middleware HTTP cache kurallarına sıkı sıkıya bağlı; istemci no-cache derse sunucu tarafı cache'i de atlıyor. Sunucunun kendi kuralıyla çalışan output caching .NET 7 ile geldi. Aynı key'i aynı anda isteyen yüzlerce request'in hepsinin veritabanına koşmasını önleyen ve bellek ile dağıtık cache'i tek API'de birleştiren HybridCache ise .NET 9 döneminin işi. İkisini de sürüm yazılarında ayrıntılı göreceğiz; buradaki kavramlar, yani key, süre ve invalidation, orada da aynen geçerli.
Küçük Bir Deneme
★☆☆ SlidingExpiration'ı 10 saniyeye, absolute süreyi 30 saniyeye indir. Her 5 saniyede bir istek atıp kaydın 30. saniyede düştüğünü, istekleri 15 saniye kestiğinde ise erkenden düştüğünü time_total değerlerinden izle.
★★☆ Redis versiyonundaki GetByIdAsync'i, Redis'e ulaşılamadığında hatayı loglayıp doğrudan repository'den okuyacak şekilde değiştir. Container'ı durdurup isteğin artık 500 değil, yarım saniyelik bir 200 döndüğünü gör.
★★★ API'yi iki farklı portta iki instance olarak çalıştır. Önce AddMemoryCache ile birinde fiyatı güncelle, diğerinden oku ve eski fiyatı gör. Sonra Redis'e geç ve aynı denemeyi tekrarla.
Cache katmanımız hazır, ama artık bir de Redis'e bağımlıyız. Peki load balancer, uygulamanın ayakta olduğunu ama Redis'e ya da veritabanına ulaşamadığını nereden bilecek? Bir sonraki yazıda health check'lere geçiyoruz: liveness, readiness ve veritabanı kontrolü.