الگوی Cache Aside  در .NET WebAPI  ترکیب Redis با EF Core برای عملکرد بهینه

در پروژه‌های واقعی که حجم تراکنش و تعداد درخواست‌ها بالاست، پایگاه‌داده (Database) خیلی زود به گلوگاه عملکرد تبدیل می‌شود. افزودن یک لایهٔ کش (Cache Layer) مانند Redis راهی مؤثر برای نگهداری داده‌های پرتکرار و کم‌تغییر در حافظهٔ سریع است. اما داشتن کش به‌تنهایی کافی نیست؛ باید مشخص کنیم اپلیکیشن در چه زمانی از کش می‌خواند و چه زمانی باید آن را به‌روزرسانی یا بی‌اعتبار کند.

الگوی Cache Aside Pattern پاسخی ساده و کارآمد برای همین هدف است. در این الگو، برنامه مسئول هماهنگی بین دیتابیس و کش است: برنامه همیشه ابتدا تلاش می‌کند داده را از کش بخواند و اگر در کش موجود نبود، به دیتابیس مراجعه می‌کند، داده را دریافت کرده و سپس آن را در کش ذخیره می‌کند تا در درخواست‌های بعدی با سرعت بالا در دسترس باشد.

سناریوی عملی

فرض کنید در یک سیستم آموزشی یا فروشگاهی، اطلاعات کاربران یا محصولات به‌طور مداوم خوانده می‌شود اما به‌ندرت تغییر می‌کند. اگر هر بار مستقیماً به دیتابیس مراجعه کنیم، فشار زیادی به سرور وارد می‌شود. در مقابل، ذخیره این داده‌ها در Redis می‌تواند زمان پاسخ را از چند صد میلی‌ثانیه به کمتر از ۵ میلی‌ثانیه کاهش دهد و بار دیتابیس را به‌شدت کم کند.

جریان‌های اصلی در Cache Aside

  1. Read: ابتدا کلید در Redis جست‌وجو می‌شود. اگر پیدا شد (Cache Hit) همان مقدار بازگردانده می‌شود؛ در غیر این صورت (Cache Miss) داده از دیتابیس خوانده شده، در Redis ذخیره و سپس بازگردانده می‌شود.
  2. Write / Update / Delete: تغییر روی دیتابیس اعمال می‌شود و سپس کلید مرتبط در Redis حذف (invalidation) می‌گردد تا در اولین خواندن بعدی، مقدار تازه از منبع اصلی بارگذاری شود.

معماری و ساختار پروژه

/src
  ├── Infrastructure
  │   ├── Data
  │   │   ├── AppDbContext.cs
  │   │   ├── Entities
  │   │   │   └── Product.cs
  │   │   ├── Repositories
  │   │   │   ├── IProductRepository.cs
  │   │   │   └── ProductRepository.cs
  │   └── Cache
  │       ├── ICacheService.cs
  │       └── RedisCacheService.cs
  └── WebAPI
      ├── Program.cs
      └── Controllers
          └── ProductsController.cs

راه‌اندازی سریع Redis با Docker

# docker-compose.yml
version: '3.9'
services:
  redis:
    image: redis:7-alpine
    container_name: redis
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "Str0ng_Redis_Pass!"]
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
volumes:
  redis-data: {}

تنظیمات اتصال در .NET

// appsettings.json
{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=AppDb;User Id=sa;Password=Your@StrongPwd;TrustServerCertificate=True;",
    "Redis": "localhost:6379,password=Str0ng_Redis_Pass!,abortConnect=false"
  }
}
// Program.cs
using Infrastructure.Cache;
using Infrastructure.Data;
using Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddSingleton<IConnectionMultiplexer>(
    _ => ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!)
);
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();

builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

Repository با الگوی Cache Aside

public class ProductRepository : IProductRepository
{
    private readonly AppDbContext _db;
    private readonly ICacheService _cache;
    private const string CacheKeyPrefix = "product:";

    public ProductRepository(AppDbContext db, ICacheService cache)
    {
        _db = db;
        _cache = cache;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        string key = $"{CacheKeyPrefix}{id}";
        var cached = await _cache.GetAsync<Product>(key);
        if (cached != null)
            return cached; // Cache Hit

        var product = await _db.Products.FindAsync(id);
        if (product != null)
            await _cache.SetAsync(key, product, TimeSpan.FromMinutes(10)); // Cache Miss → Set

        return product;
    }

    public async Task UpdateAsync(Product entity)
    {
        _db.Products.Update(entity);
        await _db.SaveChangesAsync();
        await _cache.RemoveAsync($"{CacheKeyPrefix}{entity.Id}");
    }
}

کنترلر WebAPI

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repo;

    public ProductsController(IProductRepository repo)
    {
        _repo = repo;
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var product = await _repo.GetByIdAsync(id);
        if (product == null) return NotFound();
        return Ok(product);
    }
}

جمع‌بندی

Cache Aside Pattern به شما اجازه می‌دهد بین سرعت و صحت داده تعادل برقرار کنید: برنامه همیشه از کش می‌خواند و فقط وقتی داده موجود نیست به دیتابیس مراجعه می‌کند، سپس نتیجه را در کش قرار می‌دهد. با تعریف TTL مناسب، حذف کلید پس از تغییر، و استفاده از تکنیک‌های جلوگیری از فشار همزمان (Stampede) می‌توانید WebAPI مبتنی بر .NET + EF Core را در مقیاس بالا پایدار و سریع نگه دارید.

در ادامه این مجموعه، به سراغ Write-Through Pattern می‌رویم تا ببینیم چگونه می‌توان نوشتن در کش و دیتابیس را به‌صورت همزمان و اتمیک انجام داد و چه مزایا یا محدودیت‌هایی نسبت به Cache Aside دارد.