مقالات › ASP.NET Core

کش در ASP.NET Core؛ IMemoryCache، Redis، HybridCache و Output Caching

برنامه‌های ASP.NET Core را با کش مناسب سریع کنید: کش داخل حافظه، کش توزیع‌شده با Redis، HybridCache، کش خروجی، کش HTTP و راهبردهای امن برای ابطال کش.

کش مؤثرترین بهینه‌سازی کارایی است: پاسخی که از حافظه سرو شود به‌جای یک رفت‌وبرگشت دیتابیس، چند میکروثانیه طول می‌کشد. اما کش دو مسئله از سخت‌ترین مسائل محاسبات را هم با خودش می‌آورد: ابطال و سازگاری داده. این راهنما همه‌ی لایه‌های کشی که ASP.NET Core ارائه می‌دهد و نحوه‌ی استفاده‌ی امن از هرکدام را پوشش می‌دهد.

چه چیزی را کش کنیم؟

گزینه‌های خوب:

  • داده‌ای که زیاد خوانده و کم تغییر می‌کند: کاتالوگ محصول، مقالات منتشرشده، تنظیمات، منوها.
  • محاسبات پرهزینه: گزارش‌ها، تجمیع‌ها، Markdown رندرشده.
  • نتایج APIهای خارجی کند: نرخ ارز، استعلام موقعیت جغرافیایی.

گزینه‌های بد: داده‌ی حساس هر کاربر در کش مشترک (مگر با کلید مخصوص کاربر)، مقادیر خیلی پرتغییر و هر چیزی که کهنه بودنش آسیب واقعی بزند (مثل موجودی حساب).

۱. کش داخل حافظه با IMemoryCache

builder.Services.AddMemoryCache();
public class ArticleService(AppDbContext db, IMemoryCache cache)
{
    public async Task<IReadOnlyList<ArticleSummary>> GetLatestAsync(CancellationToken ct)
    {
        return await cache.GetOrCreateAsync("articles:latest", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SlidingExpiration = TimeSpan.FromMinutes(2);
            entry.Size = 1;

            return await db.Articles.AsNoTracking()
                .Where(a => a.IsPublished)
                .OrderByDescending(a => a.PublishedAt)
                .Take(6)
                .Select(a => new ArticleSummary(a.Id, a.Slug, a.Title, a.PublishedAt))
                .ToListAsync(ct);
        }) ?? [];
    }
}
  • انقضای مطلق (Absolute) — بعد از زمان مشخص، در هر صورت حذف می‌شود.
  • انقضای لغزان (Sliding) — اگر مدتی استفاده نشود حذف می‌شود؛ همیشه آن را با یک سقف مطلق ترکیب کنید تا ورودی‌های پراستفاده هم بالاخره تازه شوند.
  • محدودیت اندازه — SizeLimit را در تنظیمات کش تعیین کنید تا حافظه بی‌نهایت رشد نکند.

مزایا: بسیار سریع، بدون راه‌اندازی. معایب: هر سرور کش مخصوص خودش را دارد. با چند نمونه پشت Load Balancer ممکن است داده‌های متفاوتی سرو شود و با هر Restart کش از بین می‌رود.

۲. کش توزیع‌شده با Redis

وقتی چند نمونه اجرا می‌کنید، از یک کش مشترک استفاده کنید:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "portfolio:";
});

IDistributedCache با بایت/رشته کار می‌کند، پس Serialize را خودتان انجام می‌دهید:

public class ExchangeRateService(IDistributedCache cache, HttpClient http)
{
    public async Task<Rates> GetAsync(CancellationToken ct)
    {
        var cached = await cache.GetStringAsync("rates", ct);
        if (cached is not null) return JsonSerializer.Deserialize<Rates>(cached)!;

        var rates = await http.GetFromJsonAsync<Rates>("/latest", ct);
        await cache.SetStringAsync("rates", JsonSerializer.Serialize(rates),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }, ct);
        return rates!;
    }
}

Redis با Restart برنامه از بین نمی‌رود، بین همه‌ی نمونه‌ها مشترک است و ساختارهای داده‌ی پیشرفته دارد؛ اما هر دسترسی یک فراخوانی شبکه است (معمولاً در شبکه‌ی محلی زیر یک میلی‌ثانیه).

۳. HybridCache (از ‎.NET 9)؛ بهترینِ هر دو

HybridCache یک لایه‌ی سریع داخل حافظه (L1) را با یک لایه‌ی توزیع‌شده‌ی اختیاری (L2) ترکیب می‌کند، Serialize را خودش انجام می‌دهد و — مهم‌تر از همه — جلوی هجوم به کش (Cache Stampede) را می‌گیرد:

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };
});
public class ArticleReader(HybridCache cache, AppDbContext db)
{
    public ValueTask<ArticleDto?> GetAsync(string slug, CancellationToken ct) =>
        cache.GetOrCreateAsync(
            $"article:{slug}",
            async token => await db.Articles.AsNoTracking()
                .Where(a => a.Slug == slug)
                .Select(a => new ArticleDto(a.Title, a.Content))
                .FirstOrDefaultAsync(token),
            tags: ["articles"],
            cancellationToken: ct);

    public ValueTask InvalidateAllAsync(CancellationToken ct) =>
        cache.RemoveByTagAsync("articles", ct);
}

Stampede وقتی رخ می‌دهد که یک ورودی پرطرفدار منقضی شود و صدها درخواست هم‌زمان برای بازسازی آن به دیتابیس هجوم بیاورند. HybridCache فقط به یک فراخواننده اجازه‌ی اجرای Factory را می‌دهد و بقیه منتظر نتیجه‌ی او می‌مانند.

۴. Output Caching؛ کش کل پاسخ

برای Endpointهای عمومی GET، خود پاسخ HTTP را کش کنید:

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(30)));
    options.AddPolicy("Articles", b => b
        .Expire(TimeSpan.FromMinutes(10))
        .SetVaryByQuery("page", "tag")
        .Tag("articles"));
});

app.UseOutputCache();

app.MapGet("/api/articles", GetArticles).CacheOutput("Articles");

// بعد از انتشار یک مقاله:
await outputCacheStore.EvictByTagAsync("articles", ct);

Output Caching در صورت وجود در کش، اصلاً Endpoint شما را اجرا نمی‌کند؛ سریع‌ترین گزینه‌ی ممکن. به‌طور پیش‌فرض درخواست‌های احراز هویت‌شده یا پاسخ‌هایی که کوکی تنظیم می‌کنند را کش نمی‌کند و این از نشت داده‌ی خصوصی جلوگیری می‌کند.

۵. کش HTTP؛ کمک گرفتن از مرورگر و CDN

app.MapGet("/api/settings", () => Results.Ok(settings))
   .WithMetadata(new ResponseCacheAttribute { Duration = 300, Location = ResponseCacheLocation.Any });
  • Cache-Control: public, max-age=300 به مرورگر و CDN اجازه‌ی استفاده‌ی مجدد از پاسخ را می‌دهد.
  • فایل‌های استاتیک با نام هش‌دار (app.4f9c2.js) می‌توانند max-age=31536000, immutable داشته باشند.
  • ETag وقتی محتوا تغییر نکرده، پاسخ 304 Not Modified را ممکن می‌کند.

راهبردهای ابطال کش

  1. انقضای زمانی — ساده‌ترین؛ قبول کنید داده تا N دقیقه کهنه باشد.
  2. ابطال صریح — هنگام تغییر داده (بعد از SaveChanges) کلیدها یا تگ‌ها را حذف کنید.
  3. کلیدهای نسخه‌دار — نسخه را در کلید بگذارید (articles:v42) و با هر تغییر بالا ببرید.
  4. مبتنی بر رویداد — یک پیام منتشر کنید تا همه‌ی نمونه‌ها ورودی‌های محلی‌شان را حذف کنند.

یک ترکیب عملی: ابطال صریح هنگام نوشتن، به‌علاوه‌ی یک انقضای مطلق به‌عنوان تور ایمنی.

نام‌گذاری کلید و بهترین روش‌ها

  • از کلیدهای ساخت‌یافته استفاده کنید: article:{slug}، user:{id}:permissions، articles:list:page:{n}.
  • همه‌ی پارامترهایی که نتیجه را تغییر می‌دهند در کلید بیاورید (زبان، صفحه، فیلترها، Tenant).
  • DTO را کش کنید، نه Entityهای EF Core (موجودیت‌های ردیابی‌شده و چرخه‌های Navigation جایشان در کش نیست).
  • هرگز داده‌ی مخصوص کاربر را زیر یک کلید مشترک کش نکنید.
  • نرخ موفقیت (Hit Ratio) را اندازه بگیرید؛ کشی با نرخ موفقیت ۵ درصد فقط سربار است.

انتخاب کش مناسب

سناریو انتخاب
یک سرور، داده‌ی کم IMemoryCache
چند سرور، داده‌ی مشترک Redis (IDistributedCache)
دات‌نت مدرن، L1+L2 و محافظت در برابر Stampede HybridCache
Endpointهای عمومی GET Output Caching
فایل‌های استاتیک و منابع عمومی کش HTTP / CDN

جمع‌بندی

چیزهایی را کش کنید که زیاد خوانده و کم تغییر می‌شوند، لایه‌ای را انتخاب کنید که با معماری انتشارتان می‌خواند (حافظه، Redis، HybridCache، کش خروجی، HTTP)، از کلیدهای روشنی استفاده کنید که همه‌ی ورودی‌ها را در بر بگیرند و از روز اول برای ابطال برنامه داشته باشید. یک کش خوب‌طراحی‌شده می‌تواند زمان پاسخ را چند مرتبه کاهش دهد و همزمان داده را درست نگه دارد.

پرسش‌های متداول

آیا Entityهای EF Core را کش کنم؟

نه؛ DTOهای Projectشده را کش کنید. Entityها ممکن است وضعیت ردیابی و گراف‌های Navigation بزرگ داشته باشند.

ورودی‌های کش چقدر عمر کنند؟

به اندازه‌ای که داده‌ی کهنه برای آن کاربرد قابل قبول است. محافظه‌کارانه (چند دقیقه) شروع کنید و با متریک‌های واقعی تنظیم کنید.

آیا برای کش حتماً به Redis نیاز است؟

نه. وقتی لازم است که چند نمونه کش مشترک داشته باشند یا کش بعد از Restart باقی بماند، به آن نیاز دارید.