ASP.NET Core چند نوع کش دارد و هرکدام مسئلهٔ جداگانهای را حل میکند. IMemoryCache شیء را در حافظهٔ همان پروسه نگه میدارد و IDistributedCache با Redis داده را بین چند سرور مشترک میکند. HybridCache این دو را در یک کش دولایه (L1 در پروسه و L2 در Redis) کنار هم میگذارد و جلوی هجوم همزمان به منبع داده را میگیرد. Output Caching هم کل پاسخ HTTP را روی سرور نگه میدارد. در این راهنما توضیح میدهم هر لایه به چه کاری میآید، کلید و TTL را چطور انتخاب کنیم، باطلسازی با تگ دقیقاً چه میکند و چه نمیکند، و چه چیزهایی را نباید کش کرد.
لایههای کش و کار هرکدام
پیش از انتخاب ابزار، به دو سؤال جواب دهید: برنامه روی یک سرور اجرا میشود یا چند نمونه پشت یک load balancer دارد؟ و چه چیزی را کش میکنید؛ دادهای که کد با آن کار میکند یا پاسخ کاملی که به کلاینت برمیگردد؟ جدول زیر تفاوتها را خلاصه میکند:
| لایه | داده کجا میماند | مشترک بین سرورها | جلوگیری از هجوم | باطلسازی گروهی |
|---|---|---|---|---|
| IMemoryCache | حافظهٔ پروسه، خود شیء | خیر | ندارد | با change token |
| IDistributedCache با Redis | Redis، به شکل byte[] | بله | ندارد | ندارد |
| HybridCache | L1 در پروسه، L2 در IDistributedCache | فقط L2 | دارد، در هر پروسه | تگ، بهصورت منطقی |
| Response Caching | حافظهٔ پروسه، کل پاسخ | خیر | ندارد | ندارد |
| Output Caching | پیشفرض حافظه، قابل تعویض با Redis | با انبار Redis | قفل منابع | تگ، با EvictByTagAsync |
روی یک سرور، IMemoryCache معمولاً کافی است. با چند نمونه، هر نمونه کش خودش را دارد و بدون sticky session ممکن است کاربر از دو سرور دو جواب متفاوت بگیرد؛ اینجا کش مشترک لازم است. اگر هم میخواهید کل پاسخ یک endpoint عمومی را نگه دارید، ابزار مناسب Output Caching است.
IMemoryCache و سقف حافظه
IMemoryCache خود شیء را نگه میدارد، نه نسخهٔ سریالشدهاش را، پس خواندن از آن تقریباً هزینهای ندارد. همین مزیت یک دام هم دارد: همهٔ فراخوانندهها همان نمونه را میگیرند و اگر یکی از آنها فهرست کششده را تغییر دهد، مقدار کش برای همه عوض شده است. نوعهای تغییرناپذیر کش کنید و با نتیجه مثل دادهٔ فقطخواندنی رفتار کنید.
مسئلهٔ دوم حافظه است. طبق مستندات مایکروسافت، ASP.NET Core این کش را بر اساس فشار حافظه کوچک نمیکند و بدون SizeLimit بیسقف رشد میکند. SizeLimit واحد ندارد و واحدش را خودتان تعیین میکنید، مثلاً یک واحد برای هر ورودی. وقتی سقف تعیین شده باشد، هر ورودی باید Size داشته باشد و اگر مجموع از سقف بگذرد، ورودی تازه ذخیره نمیشود. نمونهای که AddMemoryCache ثبت میکند ممکن است بین چند کتابخانه مشترک باشد و نمیتوانید مطمئن باشید همهٔ آنها برای ورودیهایشان Size میگذارند؛ برای همین مستندات هشدار میدهد که سقف گذاشتن روی کش مشترک میتواند برنامه را از کار بیندازد. یک نمونهٔ جداگانه بسازید:
using Microsoft.Extensions.Caching.Memory;
public sealed class LookupCache
{
// SizeLimit has no unit: every entry below has Size = 1, so this caps the entry count.
public MemoryCache Cache { get; } = new(new MemoryCacheOptions { SizeLimit = 5_000 });
}
// Program.cs
builder.Services.AddSingleton<LookupCache>();
// Inside a service that receives LookupCache lookup, AppDbContext db and a CancellationToken ct
var grades = await lookup.Cache.GetOrCreateAsync("lookup:grades", async entry =>
{
entry.SetSize(1);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return await db.Grades.Select(g => new GradeDto(g.Id, g.Title)).ToArrayAsync(ct);
});
این کد کش جداگانهای با سقف ۵ هزار ورودی میسازد و برای هر ورودی Size و انقضای مطلق تعیین میکند. انقضای لغزنده (sliding) بهتنهایی کافی نیست، چون کلیدی که مدام خوانده شود هیچوقت منقضی نمیشود. GetOrCreateAsync در IMemoryCache جلوی اجرای همزمان factory را هم نمیگیرد و چند درخواست همزمان ممکن است هرکدام جداگانه کوئری را اجرا کنند.
Redis با IDistributedCache، و HybridCache بهعنوان کش دولایه
IDistributedCache رابط مشترک کشهای بیرون از پروسه است و بستهٔ Microsoft.Extensions.Caching.StackExchangeRedis پیادهسازی Redis آن را با AddStackExchangeRedisCache ثبت میکند. این رابط عمداً ساده است: کلید رشتهای و مقدار byte[]؛ سریالسازی، مدیریت miss و هجوم همزمان همه بر عهدهٔ شماست. در «گزینه دو» Redis با Sentinel بخشی از زیرساخت اوج آزمونها بود و بهینهسازی مسیرهای کش هم جزو کارم بود. پشتهٔ آنجا .NET 6 بود و HybridCache هنوز وجود نداشت، پس محافظت در برابر هجوم را باید خود کد cache-aside فراهم میکرد. آن الگو و معماری Sentinel را در یادداشت بکاند ۱ نوشتهام و اینجا تکرارشان نمیکنم.
HybridCache در بستهٔ Microsoft.Extensions.Caching.Hybrid است که از مارس ۲۰۲۵ نسخهٔ پایدار دارد و روی .NET 8 هم کار میکند. یک لایهٔ L1 در حافظهٔ پروسه دارد و اگر IDistributedCache ثبت شده باشد، از آن بهعنوان L2 استفاده میکند. GetOrCreateAsync اول L1 را نگاه میکند، بعد L2 را، و فقط اگر هر دو خالی بودند factory را صدا میزند و نتیجه را در هر دو مینویسد. برای هر کلید، فقط یکی از فراخوانندههای همزمان factory را اجرا میکند و بقیه منتظر نتیجهٔ همان میمانند. این هماهنگی محدود به یک نمونهٔ HybridCache است و به سرورهای دیگر نمیرسد، حتی اگر همه از یک Redis استفاده کنند.
// dotnet add package Microsoft.Extensions.Caching.Hybrid
// dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "exams:"; // key prefix for this app
});
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // overall lifetime, also sent to Redis (L2)
LocalCacheExpiration = TimeSpan.FromMinutes(1) // in-process copy (L1)
};
});
builder.Services.AddDbContextFactory<ExamDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Exams")));
// ExamCatalog.cs
public sealed class ExamCatalog(HybridCache cache, IDbContextFactory<ExamDbContext> dbFactory)
{
public ValueTask<ExamSummary?> GetSummaryAsync(int examId, CancellationToken ct) =>
cache.GetOrCreateAsync(
$"exam:{examId}:summary",
async cancel =>
{
await using var db = await dbFactory.CreateDbContextAsync(cancel);
return await db.Exams
.Where(e => e.Id == examId)
.Select(e => new ExamSummary(e.Id, e.Title, e.StartsAtUtc, e.DurationMinutes))
.FirstOrDefaultAsync(cancel);
},
tags: [$"exam:{examId}"],
cancellationToken: ct);
public ValueTask InvalidateAsync(int examId, CancellationToken ct) =>
cache.RemoveByTagAsync($"exam:{examId}", ct);
}
رشتهٔ اتصال از پیکربندی خوانده میشود، نه از کد. Expiration عمر کلی ورودی است و به Redis هم فرستاده میشود؛ LocalCacheExpiration عمر نسخهٔ درون پروسه است. factory عمداً از DbContext درخواست جاری استفاده نمیکند: نتیجهاش بین فراخوانندههای همزمان مشترک است و توکن لغوش فقط وقتی لغو میشود که همهٔ منتظرها لغو کرده باشند، پس ممکن است بعد از پایان درخواستی که آن را شروع کرده هنوز در حال اجرا باشد. یک DbContext کوتاهعمر از IDbContextFactory این وابستگی را از بین میبرد.
در سمت Redis، مقدار پیشفرض maxmemory روی سیستمهای ۶۴ بیتی صفر است، یعنی سقفی برای حافظه وجود ندارد. برای Redisی که فقط نقش کش دارد، سقف و یک سیاست حذف مثل allkeys-lru تعیین کنید. سیاست پیشفرض noeviction است و با آن، وقتی حافظه به سقف برسد، فرمانهایی که دادهٔ تازه مینویسند خطا میگیرند.
کش پاسخ HTTP: Response Caching یا Output Caching
این دو با وجود اسمهای شبیه، منطق متفاوتی دارند. میانافزار Response Caching، که با AddResponseCaching و UseResponseCaching فعال میشود، معنای استاندارد کش HTTP را پیاده میکند: فقط پاسخهایی را نگه میدارد که هدر Cache-Control آنها public باشد و از هدرهای درخواست هم پیروی میکند. مرورگرها معمولاً هنگام رفرش Cache-Control: max-age=0 یا no-cache میفرستند و میانافزار طبق RFC 9111 پاسخ کششده را کنار میگذارد و درخواست به برنامه میرسد؛ یعنی تصمیم نهایی با کلاینت است. انبارش فقط حافظه است و نه باطلسازی انتخابی دارد و نه قفل. ویژگی [ResponseCache] در MVC در اصل فقط هدرها را تنظیم میکند و برای کش در مرورگر یا CDN هم به کار میآید؛ تنها گزینهاش که بدون میانافزار کار نمیکند VaryByQueryKeys است.
Output Caching، که از .NET 7 در دسترس است، سیاست کش را روی سرور تعریف میکند و هدرهای درخواست کلاینت آن را لغو نمیکنند. انبارش قابل تعویض است: پیشفرض حافظه است و از .NET 8 با بستهٔ Microsoft.AspNetCore.OutputCaching.StackExchangeRedis و متد AddStackExchangeRedisOutputCache میشود Redis را جایش گذاشت. ورودیهایش با تگ باطل میشوند و با قفل منابع جلوی هجوم را میگیرد. اگر پاسخ ETag داشته باشد و کلاینت آن را در If-None-Match برگرداند، یا If-Modified-Since بفرستد، و ورودی هنوز تازه باشد، پاسخ ۳۰۴ برمیگرداند. پیشفرضهایش محافظهکارانهاند: فقط پاسخ ۲۰۰ برای GET و HEAD کش میشود، پاسخی که کوکی تنظیم کند یا به درخواست احرازهویتشده داده شود کش نمیشود، و انقضای پیشفرض ۶۰ ثانیه است.
using Microsoft.AspNetCore.OutputCaching;
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("ExamList", policy => policy
.Expire(TimeSpan.FromSeconds(30))
.SetVaryByQuery("grade")
.Tag("exams"));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache(); // after the auth middleware
app.MapGet("/api/exams", (int grade, ExamCatalog catalog, CancellationToken ct) =>
catalog.ListAsync(grade, ct))
.CacheOutput("ExamList");
app.MapPost("/api/admin/exams/{id:int}/publish",
async (int id, ExamCatalog catalog, IOutputCacheStore store, CancellationToken ct) =>
{
await catalog.PublishAsync(id, ct); // write to SQL Server first
await catalog.InvalidateAsync(id, ct); // cached data in HybridCache
await store.EvictByTagAsync("exams", ct); // cached HTTP responses
return Results.NoContent();
}).RequireAuthorization("ExamAdmin");
در این کد، ListAsync و PublishAsync متدهای فرضی همان سرویساند. بهطور پیشفرض کل URL، از جمله همهٔ پارامترهای query، بخشی از کلید است؛ SetVaryByQuery("grade") بخش query کلید را به همین یک پارامتر محدود میکند تا پارامترهای بیربط، مثل پارامترهای utm، کش را تکهتکه نکنند. ترتیب میانافزارها هم مهم است: طبق مستندات، اگر UseOutputCache پیش از احراز هویت و مجوز بیاید، ممکن است بهجای محتوای کاربر مجاز، محتوایی برگردد که برای کاربر بیمجوز کش شده است. سقفهای این لایه هم جدا از IMemoryCache است: SizeLimit در OutputCacheOptions بهطور پیشفرض ۱۰۰ مگابایت است و پاسخی که از MaximumBodySize (پیشفرض ۶۴ مگابایت) بزرگتر باشد کش نمیشود.
کلید و TTL
کلید باید هرچه را که نتیجه را تغییر میدهد در بر داشته باشد و چیز دیگری را نه. چند قاعدهٔ کاربردی:
- شناسهٔ داخلی، نه ورودی خام. کلید را از شناسههای داخلی و مقادیر یک فهرست مشخص بسازید. مستندات مایکروسافت هشدار میدهد که کلید ساختهشده از ورودی دلخواه کاربر هم خطر امنیتی دارد و هم راه را برای پر کردن حافظه با سیل کلیدهای بیمعنی باز میکند.
- جداکننده بین بخشها. بدون جداکننده، مشتری ۴۲ با سفارش ۱۲۳ و مشتری ۴۲۱ با سفارش ۲۳ هر دو به یک کلید میرسند.
- نسخه در کلید. اگر شکل DTO عوض میشود، پیشوندی مثل
v2:اضافه کنید. بعد از استقرار، مقدارهای قدیمی هنوز در Redis هستند و ممکن است با کلاس جدید درست deserialize نشوند. - پیشوند هر برنامه.
InstanceNameدرAddStackExchangeRedisCacheکلیدهای هر برنامه را جدا میکند تا دو سرویس روی یک Redis کلیدهای هم را بازنویسی نکنند. کلید را هم کوتاه نگه دارید: HybridCache کلید بلندتر ازMaximumKeyLength(پیشفرض ۱۰۲۴ کاراکتر) را کش نمیکند.
TTL را بر اساس کهنگی قابلقبول هر داده انتخاب کنید. در HybridCache، LocalCacheExpiration طبق مستندات، وقتی مقدار از L2 خوانده میشود، از عمر باقیماندهٔ کل (Expiration) بیشتر نمیشود. معمولاً L1 را کوتاهتر بگیرید؛ در بخش بعد میبینیم که باطلسازی به L1 سرورهای دیگر نمیرسد و عمر L1 در عمل سقف کهنگی در آن سرورهاست. اگر کلیدهای زیادی با هم نوشته میشوند، کمی نوسان تصادفی به TTLها اضافه کنید تا همزمان منقضی نشوند. جلوگیری از هجوم در HybridCache هم برای هر پروسه جداست؛ وقتی کلیدی در L2 هم منقضی شده باشد، با ده سرور ممکن است تا ده کوئری همزمان برای همان کلید به پایگاهداده برسد.
باطلسازی: تگها چه میکنند و چه نمیکنند
RemoveAsync یک کلید را از L1 همان سرور و از L2 حذف میکند. برای گروهی از کلیدها HybridCache تگ دارد: هنگام GetOrCreateAsync تگ میدهید و بعد RemoveByTagAsync همهٔ ورودیهای آن تگ را باطل میکند. اما این باطلسازی منطقی است: چون نه IMemoryCache و نه IDistributedCache مفهوم تگ ندارند، RemoveByTagAsync چیزی را پاک نمیکند و فقط قاعدهای ثبت میکند که «هر ورودی با این تگ که پیش از این لحظه ساخته شده، نادیده گرفته شود». خواندن بعدی آن ورودی را miss حساب میکند و factory را صدا میزند، و مقدارهای قدیمی تا انقضای عادیشان در حافظه و Redis باقی میمانند. RemoveByTagAsync("*") همهٔ دادههای HybridCache را، حتی ورودیهای بدون تگ، باطل میکند، اما الگوهایی مثل "exam*" پشتیبانی نمیشوند.
محدودیت مهمتر در محیط چندسروری است. طبق مستندات، باطلسازی با کلید یا تگ روی سرور فعلی و روی L2 اعمال میشود، اما کش درونحافظهٔ سرورهای دیگر تغییری نمیکند. پس اگر سرور الف آزمونی را منتشر کند و کش آن را باطل کند، سرور ب ممکن است تا پایان LocalCacheExpiration همچنان نسخهٔ قبلی را برگرداند. سه راه دارید: LocalCacheExpiration را به اندازهٔ کهنگی قابلقبول کوتاه کنید؛ برای دادهای که باید بین سرورها یکسان باشد، L1 را با HybridCacheEntryFlags.DisableLocalCache غیرفعال کنید؛ یا پیادهسازی دیگری از همین API را بررسی کنید. مستندات مایکروسافت FusionCache را نمونهای از این پیادهسازیها نام میبرد و طبق مستندات خود FusionCache، با تنظیم یک backplane باطلسازی به سرورهای دیگر هم میرسد.
در Output Caching تگ را با Tag در سیاست یا روی یک گروه endpoint میگذارید و با IOutputCacheStore.EvictByTagAsync ورودیها را از انبار بیرون میکنید. اما انبار پیشفرض در حافظهٔ همان پروسه است و طبق مستندات هر سرور کش جداگانهٔ خودش را دارد؛ پس با چند سرور، این فراخوانی فقط ورودیهای سروری را پاک میکند که درخواست باطلسازی به آن رسیده است. برای باطلسازی مشترک از انبار Redis استفاده کنید. مستندات استفاده از IDistributedCache را بهعنوان انبار Output Caching توصیه نمیکند، چون عملیات اتمی لازم برای تگها را ندارد.
هزینهٔ سریالسازی و آنچه نباید کش کرد
هر کش بیرون از پروسه یعنی سریالسازی در هر نوشتن و deserialize در هر خواندن، بهعلاوهٔ رفتوبرگشت شبکه. HybridCache رشته و byte[] را خودش مدیریت میکند و برای بقیهٔ نوعها از System.Text.Json استفاده میکند؛ با AddSerializer یا AddSerializerFactory میشود سریالساز دیگری مثل protobuf گذاشت. طبق مستندات، HybridCache بهطور پیشفرض همان رفتار کدهای مبتنی بر IDistributedCache را حفظ میکند: هر بازیابی با deserialize همراه است و هر فراخواننده نمونهٔ جداگانهٔ خودش را میگیرد، تا تغییر همزمان یک شیء مشترک پیش نیاید. اگر نوع شما sealed باشد و ویژگی [ImmutableObject(true)] داشته باشد، HybridCache میتواند نمونهها را دوباره استفاده کند و هزینهٔ deserialize در هر فراخوانی را کم کند. مقداری که از MaximumPayloadBytes (پیشفرض ۱ مگابایت) بزرگتر باشد ذخیره نمیشود و فقط در لاگ ثبت میشود. DTO کوچک کش کنید، نه موجودیت EF Core را با همهٔ navigationهایش.
و چند چیز که در کش جایی ندارد:
- دادهٔ حساس هر کاربر در کش مشترک. توکن، رمز، اطلاعات هویتی و هر چیزی که نمیخواهید در خروجی یک دستور Redis ببینید. در کش مشترک، یک اشتباه در ساختن کلید کافی است تا کاربری دادهٔ کاربر دیگر را ببیند. Output Caching هم پاسخ درخواستهای احرازهویتشده را بهطور پیشفرض کش نمیکند؛ اگر با سیاست سفارشی این پیشفرض را برمیدارید، مطمئن شوید پاسخ واقعاً برای همه یکسان است یا کلید بر اساس کاربر تغییر میکند.
- دادهٔ پرتغییر. وقتی داده تقریباً به همان سرعتی عوض میشود که خوانده میشود، نرخ اصابت پایین است و کش فقط یک منبع کهنگی اضافه میکند؛ مثل شمارندهها، موجودی لحظهای یا زمان باقیماندهٔ یک جلسه.
- تنها نسخهٔ داده. کش رونوشت است و هر چیزی که از دست رفتنش گران تمام میشود، اول باید در پایگاهداده نوشته شود. فهرست کاملتر این موارد برای سامانههای آزمون در یادداشت بکاند ۱ آمده است.
خلاصهٔ تصمیم این است: دادهٔ کوچک و کمتغییر روی یک سرور در IMemoryCache با سقف مشخص؛ دادهای که بین چند سرور مشترک است در HybridCache با Redis بهعنوان L2 و یک L1 کوتاهعمر؛ پاسخهای عمومی HTTP با Output Caching؛ و Response Caching فقط وقتی که واقعاً میخواهید از معنای استاندارد کش HTTP پیروی کنید.
در NovaTech هم Redis کنار SQL Server و EF Core بخشی از پشتهٔ میکروسرویسهای .NET است و همین تصمیمها برای هر سرویسی که از Redis میخواند مطرحاند. Output Caching در minimal API موضوع یادداشت بکاند ۳ هم هست که ۲۷ مهر در بخش یادداشتها منتشر میشودOutput Caching در minimal API موضوع یادداشت بکاند ۳ هم هست و برای سنجیدن اثر کش پیش از اوج، راهنمای تست بار با Locust را ببینید. اگر برای کش یا کارایی بکاند سامانهٔ خودتان نگاه دومی لازم دارید، در صفحهٔ همکاری توضیح دادهام چه کمکی از من برمیآید.
پرسشهای رایج
فرق Response Caching و Output Caching در ASP.NET Core چیست؟
Response Caching از معنای استاندارد کش HTTP پیروی میکند: اگر کلاینت Cache-Control: no-cache یا max-age=0 بفرستد، پاسخ از نو ساخته میشود و انبارش هم فقط حافظه است. Output Caching که از .NET 7 در دسترس است، سیاست کش را روی سرور تعریف میکند، انبارش قابل تعویض است، ورودیهایش با تگ باطل میشوند و با قفل منابع جلوی هجوم همزمان را میگیرد.
آیا RemoveByTagAsync در HybridCache داده را از کش پاک میکند؟
نه. باطلسازی با تگ منطقی است: قاعدهای ثبت میشود که ورودیهای آن تگ را اگر پیش از این لحظه ساخته شده باشند نادیده بگیرد، و مقدارهای قدیمی تا انقضای عادی در حافظه و Redis میمانند. طبق مستندات، این باطلسازی به کش درونحافظهٔ سرورهای دیگر نمیرسد.
آیا HybridCache روی .NET 8 کار میکند؟
بله. بستهٔ Microsoft.Extensions.Caching.Hybrid از مارس ۲۰۲۵ نسخهٔ پایدار دارد و روی .NET 8 کار میکند؛ طبق مستندات، حتی .NET Framework 4.7.2 را هم پشتیبانی میکند.
SizeLimit در IMemoryCache به چه واحدی است؟
واحد ندارد؛ واحد را خودتان تعیین میکنید، مثلاً یک واحد برای هر ورودی. وقتی SizeLimit تعیین شده باشد، هر ورودی باید Size داشته باشد؛ پس سقف را روی یک نمونهٔ جداگانهٔ MemoryCache بگذارید، نه روی کش مشترکی که AddMemoryCache ثبت میکند.
آیا جلوگیری از هجوم همزمان در HybridCache بین چند سرور هم کار میکند؟
نه. هماهنگی فقط بین فراخوانندههای یک نمونهٔ HybridCache برقرار است، حتی اگر همهٔ سرورها از یک Redis استفاده کنند. پس در هر سرور برای هر کلید حداکثر یک factory همزمان اجرا میشود، اما چند سرور ممکن است همزمان همان factory را اجرا کنند.
مهدی مولوی