کش‌کردن در Laravel یک خط کد است: Cache::remember(). سخت‌ترین بخش، تصمیم درباره پاک‌کردن آن است. کی، با چه روشی و با چه هزینه‌ای؟ در مقاله کش با Redis در Laravel سه الگوی کلی invalidation را معرفی کردم. این مقاله قدم بعدی است. هر روش را روی Laravel 8.83 و Redis واقعی اندازه گرفتم تا تصمیم‌گیری برای invalidation کش Redis در لاراول بر اساس عدد باشد، نه سلیقه. چهار آزمایش انجام دادم: هزینه خواندن هر روش، هزینه پاک‌کردن، رفتار cache tag در Redis وقتی کلیدها منقضی می‌شوند، و هجوم هم‌زمان درخواست‌ها به کلیدی که تازه پاک شده است.

محیط و روش اندازه‌گیری

همه آزمایش‌ها روی یک Apple M2 Pro با ۱۶ گیگابایت حافظه اجرا شد. Redis 7.4.11 و MySQL 8.4.11 هر دو در Docker Desktop بودند. کد با PHP 8.2.28 در حالت CLI، با XDEBUG_MODE=off، و با خود Laravel 8.83.29 همین سایت اجرا شد. کلاینت Redis، predis 1.1.10 بود. هر اندازه‌گیری خواندن و نوشتن بعد از ۲۰۰ اجرای گرم‌کردن، ۲۰۰۰ بار تکرار شد و میانه و p95 گزارش شده است. برای flush هر اندازه پنج بار تکرار شد. آزمایش هجوم سه بار، هر بار با ۴۰ پردازه هم‌زمان، اجرا شد.

یک نکته مهم درباره عددها: در Docker Desktop روی macOS هر رفت‌وبرگشت به Redis حدود ۲۵۰ میکروثانیه طول می‌کشد. این عدد خیلی بیشتر از سرور Linux با phpredis و Redis روی همان شبکه است. پس عدد مطلق را مبنا قرار ندهید. آنچه به سرور شما منتقل می‌شود تعداد رفت‌وبرگشت هر روش است، چون هزینه تقریباً با همان بالا می‌رود. روش کلی تکرارپذیرکردن اندازه‌گیری را در پروتکل benchmark کوئری در Laravel نوشته‌ام.

اشتباه اول: forget داخل transaction

رایج‌ترین الگو این است که بعد از update مدل، در observer یا همان سرویس، کلید کش را پاک کنیم. مشکل وقتی پیش می‌آید که update داخل DB::transaction() باشد. کلید قبل از commit پاک می‌شود. اگر درخواست دیگری در همان فاصله برسد، cache miss می‌بیند و از دیتابیس می‌خواند. چون تغییر هنوز commit نشده، مقدار قدیمی را می‌خواند و دوباره در کش می‌گذارد. نتیجه این است که دیتابیس مقدار جدید دارد و کش تا پایان TTL مقدار قدیمی را نشان می‌دهد.

این ترتیب را با دو اتصال جدا به MySQL بازسازی کردم: یکی نویسنده داخل transaction، و دیگری درخواستی که از مسیر Cache::remember می‌خواند و قبل از commit می‌رسد.

DB::transaction(function () {
    DB::table('products')->where('id', 1)->update(['price' => 120]);

    // نسخه اشتباه:
    Cache::forget('product:1');

    // نسخه درست:
    DB::afterCommit(fn () => Cache::forget('product:1'));
});

خروجی واقعی دو حالت:

forget inside transaction      db=120 cache='100' next read=100
forget with DB::afterCommit    db=120 cache=NULL  next read=120

در Laravel 8، متد afterCommit روی اتصال دیتابیس وجود دارد. اگر transaction فعالی نباشد، callback بلافاصله اجرا می‌شود (این را در DatabaseTransactionsManager::addCallback بررسی کردم). پس همیشه می‌توانید از آن استفاده کنید. برای listenerها و observerها هم property عمومی $afterCommit = true همین کار را می‌کند. این باگ در تست‌های معمولی دیده نمی‌شود، چون تست‌ها هم‌زمانی ندارند. در production هم معمولاً با جمله «گاهی قیمت قدیمی نشان داده می‌شود» گزارش می‌شود و بازتولیدش سخت است.

هزینه خواندن: هر روش چند رفت‌وبرگشت دارد

کلید ساده با یک GET خوانده می‌شود. روش‌های دیگر به‌خاطر امکان پاک‌کردن گروهی هزینه اضافه دارند:

مسیر خواندن (cache hit)رفت‌وبرگشتمیانهp95
کلید ساده۱۳۱۶ µs۴۷۱ µs
کلید با شماره نسخه۲۷۰۱ µs۱۰۳۱ µs
Cache::tags با یک tag۲۷۰۲ µs۹۴۳ µs
Cache::tags با دو tag۳۱۰۴۰ µs۱۴۸۸ µs

علت را در کد Laravel 8 می‌شود دید. TagSet برای هر tag یک کلید جدا نگه می‌دارد که شناسه فعلی آن tag است. قبل از خواندن مقدار، برای هر tag یک GET جدا انجام می‌شود. کلید نسخه‌دار هم دقیقاً همین ساختار را دارد: یک GET برای نسخه و یک GET برای مقدار. نوشتن با tag هزینه بیشتری دارد. میانه put ساده ۲۷۷ میکروثانیه و put با یک tag ۱۰۷۹ میکروثانیه بود، چون RedisTaggedCache علاوه بر خواندن شناسه tag، نام کامل کلید را با SADD در یک set مرجع هم ثبت می‌کند.

اگر یک صفحه ۳۰ مقدار کش‌شده با دو tag بخواند و هر رفت‌وبرگشت در سرور شما ۲۰۰ میکروثانیه باشد، فقط سربار tagها حدود ۱۲ میلی‌ثانیه است. برای کلیدهایی که در هر درخواست خوانده می‌شوند، این هزینه را جدی بگیرید یا مقدارها را با Cache::many() دسته‌ای بخوانید.

هزینه پاک‌کردن

عمل invalidationمیانه زمان
Cache::forget یک کلید۰٫۲۴ ms
Cache::increment کلید نسخه۰٫۲۶ ms
tags()->flush() با ۱۰۰ کلید۴٫۳ ms
tags()->flush() با ۱٬۰۰۰ کلید۷٫۲ ms
tags()->flush() با ۱۰٬۰۰۰ کلید۴۱٫۱ ms
tags()->flush() با ۵۰٬۰۰۰ کلید۲۱۰٫۴ ms

افزایش شماره نسخه، صرف‌نظر از تعداد کلیدهای گروه، یک فرمان است. کلیدهای قدیمی پاک نمی‌شوند، فقط دیگر خوانده نمی‌شوند و با TTL خودشان منقضی می‌شوند. در مقابل، flush روی tag در Laravel 8 set مرجع را با SSCAN پیمایش می‌کند و کلیدها را دسته‌ای حذف می‌کند. پس هزینه‌اش با تعداد کلیدها خطی بالا می‌رود. ۲۱۰ میلی‌ثانیه برای یک درخواست ادمین که قیمت‌ها را به‌روز می‌کند شاید قابل‌قبول باشد. اما اگر همان flush در هر ذخیره محصول اجرا شود، زمان پاسخ ذخیره عملاً به اندازه کش وابسته می‌شود.

دام tagها در Laravel 8: set مرجع که فقط بزرگ می‌شود

مهم‌ترین یافته این آزمایش چیزی است که در مستندات Laravel 8 نیامده. RedisTaggedCache نام هر کلید را در یک set مرجع ثبت می‌کند، اما وقتی کلید با TTL منقضی می‌شود، Redis عضو متناظر را از set حذف نمی‌کند. set فقط هنگام flush پاک می‌شود. برای آزمایش، پنج دور ۱۰٬۰۰۰ کلید جست‌وجو با tag search و TTL یک‌ثانیه‌ای ساختم و بعد از انقضای هر دور اندازه set را خواندم:

دورکلید داده زندهعضو set مرجعحافظه set
۱۰۱۰٬۰۰۰۱٫۰ MB
۳۰۳۰٬۰۰۰۲٫۷ MB
۵۰۵۰٬۰۰۰۴٫۸ MB

هیچ داده‌ای در کش نمانده بود، اما set مرجع حدود ۴٫۸ مگابایت حافظه می‌گرفت. چون set خودش TTL ندارد، این حافظه را eviction بر اساس TTL هم آزاد نمی‌کند. در سیستمی که نتایج جست‌وجو یا صفحه‌های فیلترشده را با tag و TTL کوتاه کش می‌کند، این set در چند هفته به میلیون‌ها عضو می‌رسد. هم حافظه Redis را مصرف می‌کند و هم اولین flush بعدی را بسیار کند می‌کند، چون باید همه آن نام‌های مرده را پیمایش کند.

این رفتار را در کد همین نسخه، Laravel 8.83.29، خواندم و اندازه گرفتم. پیاده‌سازی tag در نسخه‌های بعدی Laravel تغییر کرده است، پس اگر نسخه دیگری دارید، کد RedisTaggedCache نسخه خودتان را بخوانید و همین آزمایش را تکرار کنید. نتیجه عملی برای Laravel 8: tag را فقط برای گروه‌هایی با تعداد کلید محدود و قابل‌پیش‌بینی استفاده کنید، مثل صفحه‌های یک دسته یا تنظیمات یک tenant. برای کلیدهای پرتعداد با TTL کوتاه از آن استفاده نکنید. اگر مجبورید، یک job زمان‌بندی‌شده بنویسید که آن tag را به‌صورت دوره‌ای flush کند و اندازه set را در مانیتورینگ زیر نظر بگیرید.

هجوم هم‌زمان بعد از invalidation

هر invalidation یک cache miss می‌سازد. اگر کلید محبوب باشد، ده‌ها درخواست هم‌زمان miss می‌بینند و همه query سنگین را اجرا می‌کنند. برای اندازه‌گیری، روی جدول ۵۰۰ هزار ردیفی سفارش‌ها یک گزارش تجمیعی روزانه ساختم. اجرای تنهای این query میانه ۹۵ میلی‌ثانیه داشت. بعد ۴۰ پردازه PHP را دقیقاً هم‌زمان روی کلید خالی رها کردم. سه روش را مقایسه کردم:

  • بدون محافظت: Cache::remember ساده.
  • lock با انتظار: هر پردازه با Cache::lock()->block() منتظر lock می‌ماند و داخل آن دوباره کش را چک می‌کند.
  • lock و polling روی کش: فقط برنده lock مقدار را می‌سازد. بقیه منتظر lock نمی‌مانند و هر ۲۰ میلی‌ثانیه خود کش را می‌خوانند.
روش (۴۰ درخواست هم‌زمان)تعداد اجرای queryمیانه پاسخبدترین پاسخ
بدون محافظت۴۰۵۹۵ تا ۶۳۶ ms۶۷۳ تا ۷۰۵ ms
lock با block()۱۲۷۶ تا ۴۹۳ ms۸۴۲ تا ۱۱۱۳ ms
lock و polling روی کش۱۱۲۹ تا ۱۵۹ ms۱۳۶ تا ۲۰۷ ms

بدون محافظت، query ۹۵ میلی‌ثانیه‌ای ۴۰ بار هم‌زمان اجرا شد و هر کدام حدود ۶۰۰ میلی‌ثانیه طول کشید، چون همه برای CPU و دیسک دیتابیس رقابت می‌کردند. lock تعداد query را به یک رساند. اما با block() بدترین پاسخ حتی از حالت بدون محافظت بدتر شد. دلیلش این است که lock انحصاری است. بعد از ساخته‌شدن مقدار، ۳۹ پردازه دیگر هنوز باید یکی‌یکی lock را بگیرند تا داخل آن کش را دوباره بخوانند. در Laravel 8 فاصله پیش‌فرض بین تلاش‌ها هم ۲۵۰ میلی‌ثانیه است (من آن را با betweenBlockedAttemptsSleepFor(50) کم کرده بودم). در روش سوم، منتظرها به جای صف‌کشیدن پشت lock، مستقیم کش را می‌خوانند. مقدار به محض آماده‌شدن به همه می‌رسد:

$report = Cache::get('daily-report');

if ($report === null) {
    $lock = Cache::lock('daily-report:lock', 30);

    if ($lock->get()) {
        try {
            $report = Cache::get('daily-report')
                ?? tap($build(), fn ($r) => Cache::put('daily-report', $r, 300));
        } finally {
            $lock->release();
        }
    } else {
        $deadline = microtime(true) + 5;
        while (($report = Cache::get('daily-report')) === null && microtime(true) < $deadline) {
            usleep(20000);
        }
        $report = $report ?? $build(); // درخواست را شکست نده
    }
}

سه جزئیات این کد مهم است. مدت lock (۳۰ ثانیه) باید بیشتر از بدترین زمان ساخت مقدار باشد. برای منتظرها سقف زمانی گذاشته‌ام. و اگر سازنده شکست بخورد، منتظرها در نهایت خودشان مقدار را می‌سازند به جای اینکه خطا برگردانند. در Laravel 11 به بعد، Cache::flexible الگوی stale-while-revalidate را آماده دارد. طبق مستندات رسمی، مقدار کهنه را برمی‌گرداند و بعد از ارسال پاسخ آن را تازه می‌کند. در Laravel 8 چنین متدی وجود ندارد و باید الگوی بالا یا یک job زمان‌بندی‌شده برای گرم‌کردن کش را خودتان بنویسید.

ماتریس تصمیم

نوع دادهروش پیشنهادیدلیل
یک رکورد مشخص (محصول، کاربر)forget با afterCommitیک فرمان؛ کلید دقیقاً معلوم است
گروه بزرگ مشتق از یک منبع (همه صفحه‌های یک کاتالوگ)کلید نسخه‌دارinvalidation در زمان ثابت؛ یک GET اضافه در خواندن
گروه کوچک و محدود (تنظیمات یک tenant)tagخوانا؛ هزینه flush با تعداد کم قابل‌قبول است
نتایج جست‌وجو و فیلتر با تعداد زیادفقط TTL کوتاهtag در Laravel 8 set مرجع را بی‌نهایت بزرگ می‌کند
گزارش یا شمارش سنگین و پربازدیدTTL به‌همراه lock و polling یا گرم‌کردن با jobهزینه اصلی، هجوم بعد از miss است
موجودی، موجودی حساب، هر چیزی که اشتباهش هزینه مالی داردکش نکنید یا از منبع اصلی دوباره چک کنیدهیچ روش invalidation پنجره ناسازگاری را صفر نمی‌کند

چک‌لیست قبل از اضافه‌کردن یک کلید جدید

  1. منبع حقیقت این مقدار کجاست و کدام نوشتن‌ها آن را تغییر می‌دهند؟ همه مسیرها را بنویسید، از جمله jobها، import و دستورهای artisan.
  2. invalidation بعد از commit اجرا می‌شود؟ DB::afterCommit یا $afterCommit = true در listener.
  3. حداکثر کهنگی قابل‌قبول چقدر است؟ TTL را از همین عدد بگیرید. TTL تور ایمنی است وقتی invalidation فراموش می‌شود.
  4. این کلید چند بار در هر درخواست خوانده می‌شود؟ اگر زیاد، از tag چندتایی پرهیز کنید یا دسته‌ای بخوانید.
  5. اگر کلید پاک شود و ۵۰ درخواست هم‌زمان برسند، دیتابیس تحمل می‌کند؟ اگر نه، lock و polling یا گرم‌کردن پیش از انقضا.
  6. اگر از tag استفاده می‌کنید، تعداد کلیدهای هر tag محدود است؟ اندازه set مرجع را در مانیتورینگ ببینید.
  7. tenant، زبان و نسخه شکل داده در نام کلید هستند؟

جمع‌بندی

در این آزمایش‌ها، گران‌ترین اشتباه کندی Redis نبود. سه اشتباه طراحی بیشترین هزینه را داشتند: پاک‌کردن کش قبل از commit که داده قدیمی را برای کل TTL برمی‌گرداند، tag روی کلیدهای پرتعداد که در Laravel 8 حافظه را بی‌صدا پر می‌کند، و lock بدون polling که query را یک بار اجرا می‌کند ولی بدترین زمان پاسخ را بالاتر می‌برد. کلید ساده با forget بعد از commit و کلید نسخه‌دار برای گروه‌های بزرگ، در بیشتر پروژه‌های Laravel بیشترین نیاز را با کمترین ریسک پوشش می‌دهند.

اگر کش را اضافه کرده‌اید اما هنوز داده قدیمی یا جهش زمان پاسخ بعد از هر به‌روزرسانی دارید، معمولاً مشکل در همین تصمیم‌های invalidation است. می‌توانید از طریق خدمت بهینه‌سازی سرعت و ممیزی فنی Laravel بررسی و اندازه‌گیری آن را به من بسپارید. اگر هنوز مطمئن نیستید مشکل از کش است یا از query، از انتخاب index ترکیبی MySQL شروع کنید.

پرسش‌های متداول

آیا Cache::flush() برای پاک‌کردن کش بعد از هر به‌روزرسانی راه خوبی است؟

نه. flush کل store را پاک می‌کند، حتی کلیدهای برنامه‌های دیگری که از همان Redis استفاده می‌کنند، و یک هجوم هم‌زمان به دیتابیس می‌سازد. فقط کلید یا گروه مرتبط را پاک کنید.

کش و session و queue را در یک دیتابیس Redis نگه دارم؟

بهتر است جدا باشند؛ حداقل با شماره دیتابیس یا اتصال جدا. در غیر این صورت پاک‌کردن کش یا سیاست eviction حافظه می‌تواند session کاربران یا jobهای صف را هم از بین ببرد.