Levelwise
فارسی
داده و ذخیره‌سازی

الگوهای Caching

کش یک کپی نزدیک و سریع از داده است تا هر درخواست به دیتابیس نرود. سخت‌ترین بخش آن ساختن کش نیست، بلکه کهنه نشدن داده، انقضای همزمان کلیدهای پرطرفدار و از کار افتادن خود کش است.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۴ دقیقهمثال فروشگاه اینترنتیکد C# و HybridCache در .NET 10

نویسنده: bezzad

مشکل: دیتابیس زیر بار خواندن

در فروشگاه ما، صفحه هر محصول در هر درخواست از دیتابیس خوانده می‌شود. قیمت و توضیح محصول روزی چند بار عوض می‌شود، ولی هزاران بار خوانده می‌شود. نتیجه:

  1. پردازنده دیتابیس بالا است.
  2. صفحه‌ها کند می‌شوند.
  3. با هر کمپین تبلیغاتی، دیتابیس به مرز تحمل می‌رسد.

ایده کش ساده است: داده‌ای که زیاد خوانده و کم عوض می‌شود را یک بار بخوان و یک کپی از آن را جای سریع‌تری نگه دار.

الگوی Cache-Aside

رایج‌ترین الگو این است که برنامه خودش کش را مدیریت کند:

  1. اول در کش دنبال کلید بگرد. مثلاً کلید محصول ۴۲.
  2. اگر بود، همان را برگردان.
  3. اگر نبود، از دیتابیس بخوان. بعد آن را با زمان انقضا در کش بگذار و برگردان.
برنامهصفحه محصول ۴۲کشproduct:42دیتابیس۱. اول اینجا۲. اگر نبود۳. بگذار در کشبا زمان انقضابود: سریعcache hitنبود: کندcache miss

چرا زمان انقضا (TTL) همیشه لازم است؟ چون دیر یا زود جایی یک باگ پیش می‌آید و کش پاک نمی‌شود. زمان انقضا تضمین می‌کند داده کهنه فقط چند دقیقه بماند، نه برای همیشه.

کش کجا باشد؟

کش محلی (In-Memory)

  • در حافظه همان سرور است.
  • خیلی سریع است، چون شبکه ندارد.
  • هر سرور نسخه جدای خودش را دارد.
  • با ری‌استارت سرور خالی می‌شود.

کش توزیع‌شده (مثلاً Redis)

  • یک نسخه مشترک برای همه سرورها.
  • هر خواندن یک رفت و برگشت شبکه دارد.
  • با ری‌استارت برنامه از بین نمی‌رود.
  • یک سرویس دیگر است که ممکن است از کار بیفتد.

مشکل کش محلی وقتی چند سرور داری دیده می‌شود. مدیر قیمت را عوض می‌کند. فقط سروری که درخواست ویرایش را گرفته، کش خودش را پاک می‌کند:

سه سرور، هر کدام کش محلی خودشسرور ۱کش محلی: قیمت جدیدویرایش اینجا انجام شدسرور ۲کش محلی: قیمت قدیمیتا انقضا، کهنه می‌ماندسرور ۳کش محلی: قیمت قدیمیتا انقضا، کهنه می‌ماندRedisکش مشترک: یک نسخه برای همهکش محلی بسیار سریع است، چون شبکه ندارد. ولی هر سرور نسخه جدای خودش را دارد.پس برای کش محلی، زمان انقضای کوتاه بگذار.
کاربر با هر Refresh ممکن است به سرور دیگری برسد و قیمت قدیم یا جدید ببیند.

راه رایج، ترکیب هر دو است: یک کش محلی با انقضای کوتاه، جلوی یک کش 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 را انجام می‌دهد. یک مزیت دیگر هم دارد که پایین‌تر می‌بینیم.

باطل کردن کش: پاک کن، آپدیت نکن

وقتی قیمت عوض می‌شود، دو راه داریم: مقدار تازه را در کش بنویسیم، یا کلید را پاک کنیم. پاک کردن امن‌تر است. فرض کن دو مدیر همزمان قیمت را عوض می‌کنند:

دیتابیسکشنتیجه۱. الف: قیمت ۱۰۰۲. ب: قیمت ۱۲۰۳. ب: قیمت ۱۲۰۴. الف: قیمت ۱۰۰دیتابیس: ۱۲۰کش: ۱۰۰تا انقضا غلط می‌ماندراه ساده‌تر: بعد از ذخیره، کلید را پاک کن. خواندن بعدی مقدار تازه را از دیتابیس می‌آورد.
ترتیب نوشتن در دیتابیس و ترتیب نوشتن در کش لزوماً یکی نیست.

ترتیب درست این است:

  1. اول دیتابیس را تغییر بده.
  2. بعد کلید کش را پاک کن.
  3. اولین خواندن بعدی، مقدار تازه را از دیتابیس می‌آورد و دوباره کش می‌کند.
کش محلی سرورهای دیگر: طبق مستندات مایکروسافت، متد RemoveAsync در HybridCache کلید را از کش همین سرور و از Redis پاک می‌کند، ولی کش محلی سرورهای دیگر را نه. پس زمان انقضای محلی را کوتاه نگه دار، یا خودت پیام «این کلید را پاک کن» را به همه سرورها بفرست (مثلاً با Redis Pub/Sub).

هجوم همزمان (Cache Stampede)

کلید «تنظیمات صفحه اول» در هر درخواست خوانده می‌شود، حدود ۲۰۰۰ بار در ثانیه. زمان انقضای آن ۱۰ دقیقه است. هر ۱۰ دقیقه، پردازنده دیتابیس چند ثانیه به ۱۰۰ درصد می‌رسد. چرا؟

  1. تا وقتی کلید هست، همه درخواست‌ها از کش جواب می‌گیرند.
  2. لحظه انقضا، همه درخواست‌ها کش را خالی می‌بینند.
  3. همه با هم همان کوئری را به دیتابیس می‌فرستند.
  4. این ادامه دارد تا وقتی یکی از آن‌ها کش را دوباره پر کند. اگر کوئری کند باشد، هزاران کوئری همزمان می‌رسد.

دکمه‌ها را بزن و بار دیتابیس را مقایسه کن:

مثال زنده: انقضای یک کلید پرطرفدار

کلید «تنظیمات صفحه اول» روی ۵ سرور خوانده می‌شود. ساختن دوباره آن از دیتابیس ۳ واحد زمان طول می‌کشد. هر ستون، تعداد کوئری به دیتابیس در یک واحد زمان است.

زمانکلید منقضی می‌شود: واحد ۸ و ۲۰
کل کوئری به دیتابیس۰
بیشترین کوئری در یک لحظه۰

    راه‌ها:

    1. یک سازنده برای هر کلید. فقط یک درخواست داده را بسازد و بقیه منتظر نتیجه همان بمانند. متد GetOrCreateAsync در HybridCache همین کار را داخل هر سرور انجام می‌کند. پس با ۵ سرور، حداکثر ۵ کوئری.
    2. تازه‌سازی قبل از انقضا. برای چند کلید خیلی مهم، یک کار پس‌زمینه کش را کمی قبل از انقضا دوباره پر می‌کند.
    3. کمی عدد تصادفی در زمان انقضا (Jitter). اگر هزاران کلید با هم ساخته شده‌اند، با هم هم منقضی می‌شوند. کمی تصادفی بودن آن‌ها را پخش می‌کند.

    اگر Redis از کار بیفتد

    کش باید اختیاری باشد. یعنی سایت بدون کش کندتر شود، ولی نخوابد:

    1. خطای کش را بگیر و مستقیم از دیتابیس بخوان.
    2. برای Redis زمان انتظار کوتاه بگذار. وگرنه هر درخواست چند ثانیه منتظر یک Redis مرده می‌ماند و کل سایت کند می‌شود.
    3. ظرفیت دیتابیس را بشناس. اگر دیتابیس بدون کش اصلاً بار را تحمل نمی‌کند، کش دیگر «اختیاری» نیست. برای این حالت برنامه داشته باش.

    چه چیزی را کش نکنیم؟

    1. داده‌ای که باید همیشه دقیق باشد. مثل موجودی کیف پول یا موجودی انبار در لحظه خرید.
    2. داده‌ای که زودتر از زمان انقضا عوض می‌شود و کهنه بودنش مهم است.
    3. داده‌ای که کم خوانده می‌شود. کش فقط حافظه می‌گیرد و فایده‌ای ندارد.
    4. جواب «پیدا نشد» بدون فکر. اگر محصولی نیست و این را کش کنی، وقتی ساخته شود تا انقضا دیده نمی‌شود. اگر لازم است، زمان انقضای کوتاه بگذار.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    کش بدون زمان انقضا داده کهنه برای همیشه همیشه زمان انقضا
    آپدیت کش به جای پاک کردن کش با مقدار غلط تا انقضا اول دیتابیس، بعد پاک کردن کلید
    کش محلی با انقضای طولانی روی چند سرور هر سرور قیمت متفاوت نشان می‌دهد انقضای کوتاه یا پیام پاک کردن
    بدون محافظت برای کلید پرطرفدار دیتابیس در لحظه انقضا می‌خوابد یک سازنده برای هر کلید یا تازه‌سازی
    خطای Redis باعث خطای صفحه قطعی کش، کل سایت را می‌خواباند کش اختیاری و زمان انتظار کوتاه
    کپی کردن منطق کش در همه متدها تغییر کلید یا انقضا در ده جا یک سرویس یا Decorator برای کش

    خلاصه در شش خط

    1. کش برای داده‌ای است که زیاد خوانده و کم عوض می‌شود.
    2. الگوی Cache-Aside: اول کش، اگر نبود دیتابیس، بعد ذخیره در کش با زمان انقضا.
    3. بعد از تغییر داده، اول دیتابیس را عوض کن، بعد کلید کش را پاک کن.
    4. کش محلی هر سرور جدا است. انقضای کوتاه بگذار، یا آن را با Redis ترکیب کن.
    5. انقضای یک کلید پرطرفدار، هجوم به دیتابیس می‌آورد. فقط یک درخواست داده را بسازد.
    6. کش باید اختیاری باشد. اگر Redis نبود، سایت کند شود، نه خاموش.