Cache Stampede: وقتی کش به جای محافظت، به دشمن سیستم تبدیل میشود
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۲۵ ۰۸:۴۰
آدرس: www.dntips.ir
IMemoryCache، بهخودیخود هیچ مکانیزم هماهنگسازی (Synchronization) بین درخواستهای همزمان ندارند. وقتی چند درخواست دقیقاً در همان لحظهای که کش منقضی شده به سیستم برسند، همه آنها با یک Cache Miss مواجه میشوند و همه بهطور همزمان همان عملیات پرهزینه را دوباره اجرا میکنند. به این پدیده، Cache Stampede (یا در ادبیات فنی گاهی Thundering Herd یا Dog-Piling نیز گفته میشود) گفته میشود.SemaphoreSlim تا استفاده از قابلیت داخلی HybridCache در داتنت — به همراه نمونهکد بررسی میکنیم.public async Task<Report> GetReportAsync(string key)
{
if (_cache.TryGetValue(key, out Report cached))
{
return cached;
}
var report = await _reportService.BuildExpensiveReportAsync(key);
_cache.Set(key, report, TimeSpan.FromSeconds(60));
return report;
}false را از TryGetValue دریافت میکنند و در نتیجه هر دهی آنها متد BuildExpensiveReportAsync را فراخوانی میکنند. هیچ مکانیزمی در این کد وجود ندارد که این درخواستها را هماهنگ کند؛ این دقیقاً همان تعریف Cache Stampede است.IMemoryCache نیست. کشهای توزیعشده (Distributed Cache) مانند Redis نیز دقیقاً با همین مشکل روبهرو هستند، چون خودِ کش نمیداند و اهمیتی هم نمیدهد که چند فراخوانی دیگر دقیقاً همین لحظه به دنبال همان کلید هستند. مسئله در لایهی کش حل نمیشود؛ باید در لایهی کد برنامه مدیریت شود.private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public async Task<Report> GetReportAsync(string key)
{
if (_cache.TryGetValue(key, out Report cached))
{
return cached;
}
var keyLock = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await keyLock.WaitAsync();
try
{
// بررسی دوباره: ممکن است در حالی که منتظر آزاد شدن
// سمافور بودیم، درخواست دیگری کش را بازسازی کرده باشد.
if (_cache.TryGetValue(key, out cached))
{
return cached;
}
var report = await _reportService.BuildExpensiveReportAsync(key);
_cache.Set(key, report, TimeSpan.FromSeconds(60));
return report;
}
finally
{
keyLock.Release();
}
}ConcurrentDictionary هیچگاه بهطور خودکار پاکسازی نمیشود؛ اگر فضای کلیدها کوچک و محدود باشد مشکلی ایجاد نمیکند، اما برای فضای کلیدهای بزرگ یا نامحدود (مثلاً کلیدهایی که شامل شناسه کاربر یا پارامترهای پویا هستند)، باید مکانیزمی برای حذف سمافورهای استفادهنشده در نظر بگیرید، یا بهجای آن از راهکارهای مبتنی بر کتابخانه که در ادامه توضیح داده میشوند استفاده کنید.Microsoft.Extensions.Caching.Hybrid.HybridCache پاسخ رسمی مایکروسافت به این مسئله است و محافظت در برابر Stampede یکی از دلایل اصلی طراحی آن بوده است: این کتابخانه بهطور صریح فراخوانیهای همزمان برای یک کلید یکسان را هماهنگ میکند تا در هر لحظه فقط یک فراخوانی از تابع سازنده (Factory) اجرا شود.services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromSeconds(60),
LocalCacheExpiration = TimeSpan.FromSeconds(60)
};
});public async Task<Report> GetReportAsync(string key, HybridCache cache)
{
return await cache.GetOrCreateAsync(
key,
async cancel => await _reportService.BuildExpensiveReportAsync(key, cancel),
cancellationToken: default);
}GetOrCreateAsync تضمین میکند که برای هر کلید، تنها یک فراخوانی از تابع سازنده در هر لحظه اجرا میشود؛ سایر فراخوانیهای همزمان برای همان کلید، بهجای شروع یک عملیات جدید، منتظر همان Task در حال اجرا میمانند. این دقیقاً همان رفتاری است که در راهکار اول با SemaphoreSlim بهصورت دستی پیادهسازی کردیم، با این تفاوت که اینجا نیازی به مدیریت دستی دیکشنری قفلها نیست و این قابلیت بهصورت پیشفرض در داتنت وجود دارد، بدون نیاز به افزودن و ارزیابی یک بسته NuGet جداگانه.HybridCache علاوه بر محافظت در برابر Stampede، یک معماری کش دولایه (Two-Level Cache) نیز رایگان در اختیار شما میگذارد: یک لایهی درونپردازهای (Local) و یک لایهی توزیعشدهی اختیاری (مثل Redis یا SQL Server، یا هر بکاند دیگری که از IDistributedCache پشتیبانی میکند). اما باید به این محدودیت توجه داشت: محافظت در برابر Stampede فقط در سطح لایهی محلی روی هر نود (Node) اعمال میشود؛ این یعنی اگر برنامه روی چند سرور مختلف در یک کلاستر اجرا شود، HybridCache مانع از این نمیشود که دو نود مختلف همزمان همان کلید را بازسازی کنند، چون قفلی در سطح کل کلاستر وجود ندارد. برای رفع کامل این مسئله، یا باید به سراغ یک قفل توزیعشده واقعی (مثلاً با استفاده از الگوریتمهایی مانند Redlock روی Redis) بروید، یا این ریسک کوچکترِ بازسازی تکراری در سطح چند نود را بهعنوان نسخهی کوچکتری از همان مشکل اصلی بپذیرید — که معمولاً هم فشار قابلتوجهی به سیستم وارد نمیکند.var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 10)); _cache.Set(key, report, TimeSpan.FromSeconds(60) + jitter);
HybridCache هم پیاده کرد؛ کافی است هنگام تنظیم Expiration برای هر ورودی، بهجای یک مقدار ثابت، از یک بازهی تصادفی کوچک استفاده کنید.IMemoryCache هیچ هماهنگی داخلی بین درخواستهای همزمان ندارند و این وظیفهی توسعهدهنده است که آن را مدیریت کند.SemaphoreSlim شما را تا حد زیادی به هدف میرساند، بهشرط رعایت الگوی Double-Checked Locking و توجه به مدیریت طول عمر قفلها.HybridCache انتخاب منطقیتری است، چون این قابلیت را بهصورت آماده و بدون نیاز به کد اضافه در اختیار میگذارد و بهعنوان یک راهکار رسمی و پشتیبانیشده از سوی مایکروسافت، هزینه نگهداری کمتری دارد.HybridCache) تنها در محدودهی یک نمونه از برنامه معتبرند؛ برای هماهنگی کامل بین چند سرور، در نهایت باید به سراغ یک قفل توزیعشده واقعی رفت. با این حال، برای اکثر سناریوهای عملی، ترکیب قفل بهازای هر کلید (یا HybridCache) بههمراه Jitter روی زمان انقضا، کافی است تا کش دوباره به همان ابزاری تبدیل شود که قرار بود از ابتدا باشد: محافظی برای بکاند، نه تهدیدی برای آن.