جلوگیری از Cache Stampede در APIهای پرترافیک با استفاده از Double-Checked Locking

زمانی که تعداد کاربران یک وب‌سایت افزایش پیدا می‌کند و درخواست‌های بیشتری به سرور ارسال می‌شود، اولین بخشی که معمولاً تحت فشار قرار می‌گیرد، دیتابیس است. برای کاهش این فشار، معمولاً یک لایه کش (Cache) در مقابل دیتابیس قرار می‌گیرد.

در این معماری، ابتدا برنامه بررسی می‌کند که آیا داده‌ی موردنیاز در کش وجود دارد یا خیر. اگر داده در کش موجود باشد، مستقیماً از همان‌جا خوانده می‌شود. در غیر این صورت، داده از دیتابیس خوانده شده، در کش ذخیره می‌شود و سپس به کاربر بازگردانده می‌شود. به این ترتیب، درخواست‌های بعدی می‌توانند داده را مستقیماً از کش دریافت کنند و تعداد درخواست‌های ارسالی به دیتابیس به میزان قابل توجهی کاهش پیدا می‌کند.

اما اگر تعداد زیادی درخواست به‌صورت همزمان به سرور ارسال شوند، چه اتفاقی می‌افتد؟

فرض کنید 10,000 درخواست همزمان به یک API ارسال شوند. اگر همه این درخواست‌ها دقیقاً در یک لحظه به بخش بررسی کش برسند، همگی متوجه می‌شوند که داده هنوز در کش وجود ندارد. در نتیجه، هر 10,000 درخواست به دیتابیس ارسال می‌شوند و عملاً کش در این سناریو هیچ کمکی به کاهش بار دیتابیس نخواهد کرد.

برای مثال، کد زیر را در نظر بگیرید:

public async ValueTask<IReadOnlyList<LabelDto>> GetAllLabelsAsync(CancellationToken cancellationToken = default)
{
    if (_memoryCache.TryGetValue<List<LabelDto>>(AllLablesCacheKey, out var cachedItem) && cachedItem != null)
        return cachedItem;

    var labels = await _labelReadStore.GetAllLabelsAsync(cancellationToken);

    _memoryCache.Set(AllLablesCacheKey, labels, TimeSpan.FromMinutes(AbsoluteExpirationInMinutes));

    return labels;
}

اگر چندین درخواست به‌صورت همزمان به قسمت خواندن از کش برسند، همگی مقدار null دریافت می‌کنند. سپس هر درخواست به‌طور مستقل داده را از دیتابیس می‌خواند و مجدداً همان داده را در کش ذخیره می‌کند. در نتیجه، هزاران درخواست غیرضروری به دیتابیس ارسال خواهد شد.

پس راه‌حل چیست؟ 

راه‌حل این مشکل، استفاده از الگوی Double-Checked Locking است.

در این روش، اگر 10,000 درخواست همزمان به بخش بررسی کش برسند و همگی مقدار null دریافت کنند، تنها یک درخواست اجازه خواهد داشت که به دیتابیس مراجعه کند. پس از دریافت داده، آن را در کش ذخیره می‌کند. در همین زمان، سایر درخواست‌ها منتظر می‌مانند و پس از آزاد شدن لاک، مجدداً کش را بررسی می‌کنند. از آنجایی که داده در این مرحله در کش قرار گرفته است، مستقیماً از کش خوانده می‌شود و دیگر هیچ درخواست تکراری به دیتابیس ارسال نخواهد شد.

نمونه پیاده‌سازی:

public async ValueTask<IReadOnlyList<LabelDto>> GetAllLabelsAsync(CancellationToken cancellationToken = default)
{
    if (_memoryCache.TryGetValue<List<LabelDto>>(AllLablesCacheKey, out var cachedItem) && cachedItem != null)
        return cachedItem;

    using (await AsyncLocker.LockAsync(AllLablesCacheKey))
    {
        if (_memoryCache.TryGetValue<List<LabelDto>>(AllLablesCacheKey, out cachedItem) && cachedItem != null)
            return cachedItem;

        var labels = await _labelReadStore.GetAllLabelsAsync(cancellationToken);

        _memoryCache.Set(AllLablesCacheKey, labels, TimeSpan.FromMinutes(AbsoluteExpirationInMinutes));

        return labels;
    }
}

استفاده از این الگو در APIهایی که حجم بالایی از درخواست‌های همزمان را دریافت می‌کنند، می‌تواند تأثیر قابل توجهی بر عملکرد سیستم داشته باشد. این روش علاوه بر جلوگیری از ارسال درخواست‌های تکراری به دیتابیس، از ایجاد بار ناگهانی روی دیتابیس (Cache Stampede) نیز جلوگیری می‌کند.

پیاده سازی کلاس AsnycLocker:

public static class AsyncLocker
{
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

    public static async ValueTask<IDisposable> LockAsync(string key)
    {
        var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));

        await semaphore.WaitAsync();

        return new Releaser(key, semaphore);
    }

    private sealed class Releaser : IDisposable
    {
        private readonly string _key;
        private readonly SemaphoreSlim _semaphore;

        public Releaser(string key, SemaphoreSlim semaphore)
        {
            _key = key;
            _semaphore = semaphore;
        }

        public void Dispose()
        {
            _semaphore.Release();
        }
    }
}

نکته مهم

دقت داشته باشید که لاک باید به ازای هر Cache Key ایجاد شود، نه به‌صورت سراسری. به عبارت دیگر، هر کلید کش باید لاک مخصوص به خود را داشته باشد.

در غیر این صورت، اگر تنها یک لاک مشترک برای تمام داده‌ها استفاده شود، درخواست‌هایی که هیچ ارتباطی با یکدیگر ندارند نیز مجبور می‌شوند منتظر آزاد شدن همان لاک بمانند و این موضوع باعث کاهش شدید همزمانی (Concurrency) و افت کارایی سیستم خواهد شد.

با ایجاد یک لاک مجزا برای هر Cache Key، تنها درخواست‌هایی که به یک داده مشترک نیاز دارند منتظر یکدیگر می‌مانند، در حالی که درخواست‌های مربوط به سایر کلیدهای کش می‌توانند به‌صورت همزمان اجرا شوند. این رویکرد علاوه بر جلوگیری از Cache Stampede، حداکثر کارایی سیستم را نیز حفظ می‌کند.

Powered by Froala Editor

نظرات