عنوان:

‫Cache Stampede: وقتی کش به جای محافظت، به دشمن سیستم تبدیل می‌شود


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۲۵ ۰۸:۴۰
آدرس: www.dntips.ir
کش (Cache) یکی از رایج‌ترین ابزارهایی است که توسعه‌دهندگان دات‌نت برای بهبود کارایی سرویس‌ها به کار می‌گیرند. ایده پشت آن ساده‌است: نتیجه یک عملیات پرهزینه — مثل یک کوئری سنگین تجمیعی (Aggregation Query) یا فراخوانی یک سرویس بیرونی — را برای مدتی نگه می‌داریم تا درخواست‌های بعدی به‌جای اجرای دوباره آن عملیات، نتیجه را مستقیماً از حافظه بخوانند. در شرایط عادی، این الگو دقیقاً همان چیزی است که انتظار داریم: یک درخواست کش را می‌سازد و بقیه از همان نتیجه استفاده می‌کنند.
اما یک نکته ظریف در این الگو وجود دارد که اغلب تا زمان مواجهه با ترافیک بالا نمایان نمی‌شود: کلاس‌های متداول کشینگ در دات‌نت، مثل IMemoryCache، به‌خودی‌خود هیچ مکانیزم هماهنگ‌سازی (Synchronization) بین درخواست‌های همزمان ندارند. وقتی چند درخواست دقیقاً در همان لحظه‌ای که کش منقضی شده به سیستم برسند، همه آن‌ها با یک Cache Miss مواجه می‌شوند و همه به‌طور همزمان همان عملیات پرهزینه را دوباره اجرا می‌کنند. به این پدیده، Cache Stampede (یا در ادبیات فنی گاهی Thundering Herd یا Dog-Piling نیز گفته می‌شود) گفته می‌شود.
در این مقاله، ابتدا علت وقوع این مشکل را در یک سناریوی واقعی روی ASP.NET Core بررسی می‌کنیم، سپس سه راهکار عملی برای رفع آن را — از پیاده‌سازی دستی با SemaphoreSlim تا استفاده از قابلیت داخلی HybridCache در دات‌نت — به همراه نمونه‌کد بررسی می‌کنیم.

چرا Cache Stampede اتفاق می‌افتد؟
برای درک بهتر مشکل، یک سناریوی واقعی را در نظر بگیرید: یک API روی ASP.NET Core که نتیجه یک کوئری تجمیعی نسبتاً سنگین را به مدت ۶۰ ثانیه کش می‌کند. در بار عادی (Normal Load)، همه‌چیز روبه‌راه است: یک درخواست کش را بازسازی می‌کند و بقیه درخواست‌ها از همان نتیجه استفاده می‌کنند. اما در اوج بار (Peak Load)، ممکن است ده‌ها درخواست دقیقاً در همان بازه‌ی انقضای کش به سرور برسند. همه‌ی این درخواست‌ها با Cache Miss مواجه می‌شوند و همه به‌طور موازی همان کوئری سنگین را روی پایگاه‌داده اجرا می‌کنند. نتیجه؟ فشاری ناگهانی و غیرمنتظره روی دیتابیس، دقیقاً همان چیزی که کش قرار بود از آن جلوگیری کند.
برای دیدن این مشکل به شکل ملموس، به پیاده‌سازی ساده و رایج زیر نگاه کنید:
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 نیز دقیقاً با همین مشکل روبه‌رو هستند، چون خودِ کش نمی‌داند و اهمیتی هم نمی‌دهد که چند فراخوانی دیگر دقیقاً همین لحظه به دنبال همان کلید هستند. مسئله در لایه‌ی کش حل نمی‌شود؛ باید در لایه‌ی کد برنامه مدیریت شود.

راهکار اول: قفل به‌ازای هر کلید با SemaphoreSlim
ساده‌ترین راه‌حل این است که درخواست‌های همزمانی که به دنبال یک کلید یکسان هستند را مجبور کنیم تا پایان کار اولین درخواست منتظر بمانند، به‌جای اینکه همه با هم برای بازسازی کش وارد رقابت شوند. این کار را می‌توان با یک قفل مجزا برای هر کلید (Per-Key Lock) پیاده‌سازی کرد:
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();
    }
}
نکته کلیدی در این پیاده‌سازی، بررسی دوگانه یا همان الگوی Double-Checked Locking است: یک‌بار قبل از گرفتن قفل و یک‌بار بعد از آن. اگر این بررسی دوم حذف شود، هر درخواستی که پشت قفل منتظر مانده، بعد از آزاد شدن قفل، دوباره گزارش را از نو می‌سازد؛ در حالی که هدف اصلی این است که فقط اولین درخواست گزارش را بسازد و بقیه از همان نتیجه‌ی تازه در کش استفاده کنند.
این راهکار برای یک یا چند نقطه‌ی داغ (Hot Spot) مشخص در برنامه، بسیار سبک و کارآمد است، اما دو محدودیت دارد که باید به آن‌ها توجه کرد:
  • رشد نامحدود دیکشنری قفل‌ها:ConcurrentDictionary هیچ‌گاه به‌طور خودکار پاک‌سازی نمی‌شود؛ اگر فضای کلیدها کوچک و محدود باشد مشکلی ایجاد نمی‌کند، اما برای فضای کلیدهای بزرگ یا نامحدود (مثلاً کلیدهایی که شامل شناسه کاربر یا پارامترهای پویا هستند)، باید مکانیزمی برای حذف سمافورهای استفاده‌نشده در نظر بگیرید، یا به‌جای آن از راهکارهای مبتنی بر کتابخانه که در ادامه توضیح داده می‌شوند استفاده کنید.
  • محدود بودن به یک نمونه (Instance) از برنامه: این قفل فقط در محدوده‌ی یک پردازه (Process) عمل می‌کند. اگر برنامه روی چند سرور یا چند نمونه (مثلاً پشت یک Load Balancer) اجرا شود، هر نمونه قفل مستقل خودش را دارد و ممکن است همزمان چند نمونه، همان کوئری را روی دیتابیس اجرا کنند. برای رفع این محدودیت به قفل توزیع‌شده (Distributed Lock) نیاز است که در ادامه به آن اشاره می‌شود.

راهکار دوم: واگذاری این کار به HybridCache
نوشتن قفل دستی برای یک یا دو نقطه‌ی داغ در برنامه منطقی است، اما وقتی همین الگو در پنج سرویس مختلف تکرار شود، بهتر است از ابزاری استفاده کنید که این منطق را از پیش در خود دارد. 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) بروید، یا این ریسک کوچک‌ترِ بازسازی تکراری در سطح چند نود را به‌عنوان نسخه‌ی کوچک‌تری از همان مشکل اصلی بپذیرید — که معمولاً هم فشار قابل‌توجهی به سیستم وارد نمی‌کند.

راهکار سوم: جلوگیری از انقضای همزمان همه‌چیز با هم
حتی با وجود قفل به‌ازای هر کلید، ممکن است نسخه‌ی ملایم‌تری از همین مشکل رخ دهد: زمانی که چندین کلید متفاوت، همگی دقیقاً در یک لحظه منقضی شوند. این حالت معمولاً وقتی پیش می‌آید که در زمان راه‌اندازی برنامه (Startup)، یک دسته از آیتم‌ها را با یک زمان انقضای ثابت و یکسان در کش قرار می‌دهیم (Cache Warm-Up). در این حالت، حتی اگر هر کلید به‌تنهایی قفل مخصوص خودش را داشته باشد، همه‌ی این کلیدها همزمان منقضی می‌شوند و ناگهان موج بزرگی از درخواست‌های بازسازی به سمت بک‌اند سرازیر می‌شود.
راهکار ساده و کم‌هزینه برای این مشکل، افزودن مقداری نویز تصادفی (Jitter) به زمان انقضا است:
var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 10));
_cache.Set(key, report, TimeSpan.FromSeconds(60) + jitter);
با این کار، به‌جای اینکه همه‌ی کلیدها دقیقاً در ثانیه‌ی ۶۰ منقضی شوند، هر کدام در بازه‌ای بین ۶۰ تا ۷۰ ثانیه منقضی می‌شوند. این تغییر ساده، یک موج بزرگ و متمرکز از بازسازی کش را به چند موج کوچک و پراکنده تبدیل می‌کند و فشار لحظه‌ای روی سیستم را به‌طور قابل‌توجهی کاهش می‌دهد. همین اصل را می‌توان روی HybridCache هم پیاده کرد؛ کافی است هنگام تنظیم Expiration برای هر ورودی، به‌جای یک مقدار ثابت، از یک بازه‌ی تصادفی کوچک استفاده کنید.

جمع‌بندی و نتیجه‌گیری
Cache Stampede یکی از آن مشکلاتی است که در توسعه و تست معمولی برنامه کاملاً نامرئی است و فقط زیر بار واقعی و همزمانی بالا خودش را نشان می‌دهد. دلیل اصلی وقوع آن هم ساده است: کش‌های رایج در دات‌نت مثل IMemoryCache هیچ هماهنگی داخلی بین درخواست‌های همزمان ندارند و این وظیفه‌ی توسعه‌دهنده است که آن را مدیریت کند.
برای انتخاب راهکار مناسب، می‌توان این جمع‌بندی را در نظر گرفت:
  • برای یک یا چند نقطه‌ی داغ مشخص در یک سرویس کوچک، یک قفل ساده به‌ازای هر کلید با SemaphoreSlim شما را تا حد زیادی به هدف می‌رساند، به‌شرط رعایت الگوی Double-Checked Locking و توجه به مدیریت طول عمر قفل‌ها.
  • برای پروژه‌های بزرگ‌تر یا زمانی که این الگو در چندین سرویس تکرار می‌شود، استفاده از HybridCache انتخاب منطقی‌تری است، چون این قابلیت را به‌صورت آماده و بدون نیاز به کد اضافه در اختیار می‌گذارد و به‌عنوان یک راهکار رسمی و پشتیبانی‌شده از سوی مایکروسافت، هزینه نگهداری کمتری دارد.
  • اگر علاوه بر محافظت در برابر Stampede، به رفتار Fail-Safe نیز نیاز دارید — یعنی می‌خواهید در صورت از دسترس خارج شدن موقت بک‌اند، برنامه همچنان آخرین مقدار معتبر کش را برگرداند به‌جای شکست کامل — کتابخانه‌هایی مانند FusionCache یا مشابه آن ارزش بررسی و افزودن به پروژه را دارند.
  • در نهایت، صرف‌نظر از راهکاری که انتخاب می‌کنید، افزودن Jitter به زمان انقضا یک بیمه‌ی کم‌هزینه و ساده در برابر انقضاهای همزمان است و بهتر است همیشه به‌عنوان یک عادت خوب در طراحی کش، در نظر گرفته شود.
در محیط‌های توزیع‌شده و چند‌نودی نیز باید به یاد داشت که قفل‌های محلی (چه دستی و چه از طریق HybridCache) تنها در محدوده‌ی یک نمونه از برنامه معتبرند؛ برای هماهنگی کامل بین چند سرور، در نهایت باید به سراغ یک قفل توزیع‌شده واقعی رفت. با این حال، برای اکثر سناریوهای عملی، ترکیب قفل به‌ازای هر کلید (یا HybridCache) به‌همراه Jitter روی زمان انقضا، کافی است تا کش دوباره به همان ابزاری تبدیل شود که قرار بود از ابتدا باشد: محافظی برای بک‌اند، نه تهدیدی برای آن.