الگوهای Caching
کش یک کپی نزدیک و سریع از داده است تا هر درخواست به دیتابیس نرود. سختترین بخش آن ساختن کش نیست، بلکه کهنه نشدن داده، انقضای همزمان کلیدهای پرطرفدار و از کار افتادن خود کش است.
نویسنده: bezzad
مشکل: دیتابیس زیر بار خواندن
در فروشگاه ما، صفحه هر محصول در هر درخواست از دیتابیس خوانده میشود. قیمت و توضیح محصول روزی چند بار عوض میشود، ولی هزاران بار خوانده میشود. نتیجه:
- پردازنده دیتابیس بالا است.
- صفحهها کند میشوند.
- با هر کمپین تبلیغاتی، دیتابیس به مرز تحمل میرسد.
ایده کش ساده است: دادهای که زیاد خوانده و کم عوض میشود را یک بار بخوان و یک کپی از آن را جای سریعتری نگه دار.
الگوی Cache-Aside
رایجترین الگو این است که برنامه خودش کش را مدیریت کند:
- اول در کش دنبال کلید بگرد. مثلاً کلید محصول ۴۲.
- اگر بود، همان را برگردان.
- اگر نبود، از دیتابیس بخوان. بعد آن را با زمان انقضا در کش بگذار و برگردان.
چرا زمان انقضا (TTL) همیشه لازم است؟ چون دیر یا زود جایی یک باگ پیش میآید و کش پاک نمیشود. زمان انقضا تضمین میکند داده کهنه فقط چند دقیقه بماند، نه برای همیشه.
کش کجا باشد؟
کش محلی (In-Memory)
- در حافظه همان سرور است.
- خیلی سریع است، چون شبکه ندارد.
- هر سرور نسخه جدای خودش را دارد.
- با ریاستارت سرور خالی میشود.
کش توزیعشده (مثلاً Redis)
- یک نسخه مشترک برای همه سرورها.
- هر خواندن یک رفت و برگشت شبکه دارد.
- با ریاستارت برنامه از بین نمیرود.
- یک سرویس دیگر است که ممکن است از کار بیفتد.
مشکل کش محلی وقتی چند سرور داری دیده میشود. مدیر قیمت را عوض میکند. فقط سروری که درخواست ویرایش را گرفته، کش خودش را پاک میکند:
راه رایج، ترکیب هر دو است: یک کش محلی با انقضای کوتاه، جلوی یک کش Redis با انقضای بلندتر. در .NET کلاس HybridCache دقیقاً همین کار را میکند.
کد با HybridCache
کلاس HybridCache از بسته Microsoft.Extensions.Caching.Hybrid میآید. اگر یک کش توزیعشده مثل Redis هم ثبت کنی، از آن به عنوان سطح دوم استفاده میکند:
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddHybridCache(o =>
{
o.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // Redis
LocalCacheExpiration = TimeSpan.FromSeconds(30) // memory of each server
};
});
خواندن و پاک کردن محصول:
public sealed class ProductService(ShopDb db, HybridCache cache)
{
// Note: a missing product (null) is cached too, until it expires.
public async Task<ProductDto?> GetAsync(int id, CancellationToken ct) =>
await cache.GetOrCreateAsync(
$"product:{id}",
async token => await db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(token),
cancellationToken: ct);
public async Task ChangePriceAsync(int id, decimal price, CancellationToken ct)
{
await db.Products
.Where(p => p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, price), ct);
await cache.RemoveAsync($"product:{id}", ct); // first the database, then the cache
}
}
متد GetOrCreateAsync همان سه قدم Cache-Aside را انجام میدهد. یک مزیت دیگر هم دارد که پایینتر میبینیم.
باطل کردن کش: پاک کن، آپدیت نکن
وقتی قیمت عوض میشود، دو راه داریم: مقدار تازه را در کش بنویسیم، یا کلید را پاک کنیم. پاک کردن امنتر است. فرض کن دو مدیر همزمان قیمت را عوض میکنند:
ترتیب درست این است:
- اول دیتابیس را تغییر بده.
- بعد کلید کش را پاک کن.
- اولین خواندن بعدی، مقدار تازه را از دیتابیس میآورد و دوباره کش میکند.
هجوم همزمان (Cache Stampede)
کلید «تنظیمات صفحه اول» در هر درخواست خوانده میشود، حدود ۲۰۰۰ بار در ثانیه. زمان انقضای آن ۱۰ دقیقه است. هر ۱۰ دقیقه، پردازنده دیتابیس چند ثانیه به ۱۰۰ درصد میرسد. چرا؟
- تا وقتی کلید هست، همه درخواستها از کش جواب میگیرند.
- لحظه انقضا، همه درخواستها کش را خالی میبینند.
- همه با هم همان کوئری را به دیتابیس میفرستند.
- این ادامه دارد تا وقتی یکی از آنها کش را دوباره پر کند. اگر کوئری کند باشد، هزاران کوئری همزمان میرسد.
دکمهها را بزن و بار دیتابیس را مقایسه کن:
کلید «تنظیمات صفحه اول» روی ۵ سرور خوانده میشود. ساختن دوباره آن از دیتابیس ۳ واحد زمان طول میکشد. هر ستون، تعداد کوئری به دیتابیس در یک واحد زمان است.
راهها:
- یک سازنده برای هر کلید. فقط یک درخواست داده را بسازد و بقیه منتظر نتیجه همان بمانند. متد GetOrCreateAsync در HybridCache همین کار را داخل هر سرور انجام میکند. پس با ۵ سرور، حداکثر ۵ کوئری.
- تازهسازی قبل از انقضا. برای چند کلید خیلی مهم، یک کار پسزمینه کش را کمی قبل از انقضا دوباره پر میکند.
- کمی عدد تصادفی در زمان انقضا (Jitter). اگر هزاران کلید با هم ساخته شدهاند، با هم هم منقضی میشوند. کمی تصادفی بودن آنها را پخش میکند.
اگر Redis از کار بیفتد
کش باید اختیاری باشد. یعنی سایت بدون کش کندتر شود، ولی نخوابد:
- خطای کش را بگیر و مستقیم از دیتابیس بخوان.
- برای Redis زمان انتظار کوتاه بگذار. وگرنه هر درخواست چند ثانیه منتظر یک Redis مرده میماند و کل سایت کند میشود.
- ظرفیت دیتابیس را بشناس. اگر دیتابیس بدون کش اصلاً بار را تحمل نمیکند، کش دیگر «اختیاری» نیست. برای این حالت برنامه داشته باش.
چه چیزی را کش نکنیم؟
- دادهای که باید همیشه دقیق باشد. مثل موجودی کیف پول یا موجودی انبار در لحظه خرید.
- دادهای که زودتر از زمان انقضا عوض میشود و کهنه بودنش مهم است.
- دادهای که کم خوانده میشود. کش فقط حافظه میگیرد و فایدهای ندارد.
- جواب «پیدا نشد» بدون فکر. اگر محصولی نیست و این را کش کنی، وقتی ساخته شود تا انقضا دیده نمیشود. اگر لازم است، زمان انقضای کوتاه بگذار.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| کش بدون زمان انقضا | داده کهنه برای همیشه | همیشه زمان انقضا |
| آپدیت کش به جای پاک کردن | کش با مقدار غلط تا انقضا | اول دیتابیس، بعد پاک کردن کلید |
| کش محلی با انقضای طولانی روی چند سرور | هر سرور قیمت متفاوت نشان میدهد | انقضای کوتاه یا پیام پاک کردن |
| بدون محافظت برای کلید پرطرفدار | دیتابیس در لحظه انقضا میخوابد | یک سازنده برای هر کلید یا تازهسازی |
| خطای Redis باعث خطای صفحه | قطعی کش، کل سایت را میخواباند | کش اختیاری و زمان انتظار کوتاه |
| کپی کردن منطق کش در همه متدها | تغییر کلید یا انقضا در ده جا | یک سرویس یا Decorator برای کش |
خلاصه در شش خط
- کش برای دادهای است که زیاد خوانده و کم عوض میشود.
- الگوی Cache-Aside: اول کش، اگر نبود دیتابیس، بعد ذخیره در کش با زمان انقضا.
- بعد از تغییر داده، اول دیتابیس را عوض کن، بعد کلید کش را پاک کن.
- کش محلی هر سرور جدا است. انقضای کوتاه بگذار، یا آن را با Redis ترکیب کن.
- انقضای یک کلید پرطرفدار، هجوم به دیتابیس میآورد. فقط یک درخواست داده را بسازد.
- کش باید اختیاری باشد. اگر Redis نبود، سایت کند شود، نه خاموش.