کش یکی از ساده‌ترین راه‌ها برای سریع‌کردن یک اپلیکیشن لاراول است و در عین حال یکی از راحت‌ترین راه‌ها برای ساختن باگ‌هایی که فقط گاهی دیده می‌شوند. کاربری قیمت قدیمی را می‌بیند، ادمین تغییری می‌دهد و «اعمال نمی‌شود»، یا بعد از خالی‌شدن کش، پایگاه‌داده زیر بار ده‌ها کوئری هم‌زمان می‌خوابد.

در این مقاله‌ی کوتاه بیایید با هم ببینیم در لاراول ۱۳ چه چیزی را کش کنیم، چه چیزی را نه، چقدر نگهش داریم و چطور مطمئن شویم داده‌ی کهنه به کاربر نمی‌رسد.

چه چیزی ارزش کش‌شدن دارد

قبل از نوشتن اولین Cache::remember باید جواب این سؤال را داشته باشیم: این داده چقدر گران ساخته می‌شود و چقدر زود عوض می‌شود؟ کش وقتی بیشترین سود را دارد که داده گران باشد و کم تغییر کند.

گزینه‌های خوب برای کش:

  • نتیجه‌ی کوئری‌های سنگین و تجمیعی؛ مثل آمار داشبورد، تعداد محصولات هر دسته یا پرفروش‌های هفته.
  • پاسخ سرویس‌های بیرونی؛ مثل نرخ ارز یا اطلاعاتی که از یک API کند می‌گیریم.
  • داده‌هایی که در همه‌ی صفحه‌ها تکرار می‌شوند؛ مثل منوی سایت، تنظیمات عمومی و فهرست دسته‌ها.
  • خروجی پردازش‌های سنگین؛ مثل تبدیل Markdown به HTML.

چیزهایی که بهتر است کش نشوند یا با احتیاط زیاد کش شوند:

  • موجودی انبار، مانده‌ی حساب و هر عددی که تصمیم مالی بر اساسش گرفته می‌شود. این‌ها را همیشه از منبع اصلی بخوانید.
  • داده‌های شخصی کاربر با کلیدی که شناسه‌ی کاربر را ندارد. یک کلید اشتباه یعنی نشان‌دادن اطلاعات یک نفر به نفر دیگر.
  • کوئری‌هایی که خودشان سریع‌اند. کش‌کردن یک find() روی کلید اصلی معمولاً چیزی جز پیچیدگی اضافه نمی‌کند.

قاعده‌ی سرانگشتی: اول اندازه بگیرید (با Telescope، Debugbar یا لاگ کوئری‌ها)، بعد کش کنید.

remember، rememberForever و flexible

remember

رایج‌ترین الگو این است: اگر مقدار در کش هست برگردان، وگرنه بساز، ذخیره کن و برگردان.

app/Http/Controllers/HomeController.php
<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Support\Facades\Cache;

class HomeController extends Controller
{
    public function __invoke()
    {
        $bestSellers = Cache::remember('home:best-sellers', now()->addMinutes(10), function () {
            return Product::query()
                ->orderByDesc('sold_count')
                ->limit(8)
                ->get(['id', 'name', 'slug', 'price'])
                ->toArray();
        });

        return view('home', ['bestSellers' => $bestSellers]);
    }
}

یک نکته‌ی ظریف: remember مقدار null را «نبودن در کش» حساب می‌کند. اگر closure شما null برگرداند، هر بار دوباره اجرا می‌شود. برای «چیزی پیدا نشد» به‌جای null یک آرایه‌ی خالی یا false برگردانید.

rememberForever

rememberForever مقدار را بدون زمان انقضا ذخیره می‌کند. فقط برای داده‌ای مناسب است که مسیر پاک‌شدنش را دقیق می‌دانید، مثلاً تنظیماتی که فقط از پنل ادمین تغییر می‌کنند و در همان لحظه کششان پاک می‌شود. اگر مطمئن نیستید، یک TTL طولانی (مثلاً یک روز) امن‌تر از «برای همیشه» است؛ دست‌کم اشتباه‌ها خودشان بعد از مدتی درست می‌شوند.

flexible: داده‌ی کمی کهنه، پاسخ همیشه سریع

لاراول متد Cache::flexible را دارد که الگوی stale-while-revalidate را پیاده می‌کند. به‌جای یک TTL، دو عدد می‌دهیم:

php
$stats = Cache::flexible('dashboard:stats', [300, 900], function () {
    return [
        'orders_today' => Order::whereDate('created_at', today())->count(),
        'revenue_today' => (int) Order::whereDate('created_at', today())->sum('total'),
    ];
});

معنی [300, 900]:

سن مقدار در کش رفتار
کمتر از ۳۰۰ ثانیه تازه است؛ مستقیم برگردانده می‌شود
بین ۳۰۰ و ۹۰۰ ثانیه مقدار کهنه برگردانده می‌شود و بازسازی بعد از ارسال پاسخ انجام می‌شود
بیشتر از ۹۰۰ ثانیه منقضی شده؛ همان لحظه دوباره ساخته می‌شود

بازسازی با defer بعد از فرستادن پاسخ به کاربر اجرا می‌شود و پشت یک lock است، پس فقط یک پردازش مقدار را تازه می‌کند. برای داشبوردها و صفحه‌های پربازدیدی که چند دقیقه تأخیر در داده برایشان مهم نیست، flexible معمولاً بهترین انتخاب است.

TTL را چطور انتخاب کنیم

TTL را از روی این سؤال تعیین کنید: «اگر کاربر این داده را X دقیقه کهنه ببیند، چه اتفاقی می‌افتد؟»

  • ثانیه‌ها تا یکی‌دو دقیقه: داده‌هایی که زود عوض می‌شوند ولی بار خواندنشان زیاد است؛ مثل شمارنده‌ها و فهرست آخرین مطالب در سایت پربازدید. حتی کش ۳۰ثانیه‌ای روی صفحه‌ای که در ثانیه چند بار خوانده می‌شود، بار پایگاه‌داده را به‌شدت کم می‌کند.
  • چند دقیقه تا یک ساعت: آمار، گزارش‌ها و پاسخ API‌های بیرونی.
  • چند ساعت تا یک روز: داده‌های تقریباً ثابت مثل دسته‌بندی‌ها و منو، به شرطی که پاک‌کردن کش هنگام تغییر را هم پیاده کرده باشید.

TTL را همیشه به‌صورت صریح بنویسید (now()->addMinutes(10) یا عدد ثانیه) و از عددهای جادویی پراکنده در کد پرهیز کنید. اگر یک TTL در چند جا تکرار می‌شود، آن را ثابت یک کلاس یا مقداری در config کنید.

کلید کش: نیمی از کار همین است

بیشتر باگ‌های کش از کلید بد می‌آیند. چند قاعده:

  1. پیشوند معنادار بگذارید: product:42:card بهتر از p42 است.
  2. هر چیزی که خروجی را تغییر می‌دهد باید در کلید باشد: زبان، شناسه‌ی کاربر، شماره‌ی صفحه، فیلترها.
  3. نسخه یا زمان تغییر را در کلید بگذارید تا کش قدیمی خودبه‌خود بی‌اثر شود.

استفاده از updated_at در کلید ساده‌ترین روش است. وقتی مدل ذخیره شود، updated_at عوض می‌شود و کلید جدیدی ساخته می‌شود؛ کلید قبلی هم بعد از TTL خودش از بین می‌رود.

app/Models/Product.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class Product extends Model
{
    public function cachedDescriptionHtml(): string
    {
        $key = "product:{$this->id}:description:{$this->updated_at?->timestamp}";

        return Cache::remember($key, now()->addDay(), function () {
            return (string) Str::markdown($this->description ?? '');
        });
    }
}

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

php
$version = Cache::rememberForever('products:version', fn () => 1);

$page = request()->integer('page', 1);

$products = Cache::remember("products:v{$version}:page:{$page}", 600, function () {
    return Product::query()->latest()->paginate(20)->toArray();
});

پاک‌کردن کش با رویدادهای مدل

وقتی TTL طولانی است، باید هنگام تغییر داده کش را پاک کنیم. جای طبیعی این کار رویدادهای Eloquent است:

app/Models/Category.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;

class Category extends Model
{
    protected static function booted(): void
    {
        $flush = function (): void {
            Cache::forget('categories:menu');

            // نام دسته در کارت محصولات هم دیده می‌شود، پس فهرست محصولات هم باید بی‌اثر شود.
            // add فقط وقتی کلید وجود ندارد مقدار می‌گذارد؛ increment روی کلید ناموجود کاری نمی‌کند.
            Cache::add('products:version', 1);
            Cache::increment('products:version');
        };

        static::saved($flush);
        static::deleted($flush);
    }
}

دو هشدار مهم:

  • رویدادهای مدل فقط وقتی اجرا می‌شوند که از طریق مدل ذخیره کنید. Category::where(...)->update([...]) یا کوئری مستقیم روی پایگاه‌داده هیچ رویدادی نمی‌فرستد و کش را پاک نمی‌کند. اگر جایی update گروهی دارید، کش را همان‌جا دستی پاک کنید.
  • اگر تغییر داخل یک تراکنش است، ممکن است کش قبل از commit پاک شود و درخواستی هم‌زمان، داده‌ی قدیمی را دوباره در کش بگذارد. در چنین مواردی پاک‌کردن را با DB::afterCommit(...) به بعد از commit منتقل کنید.

تگ‌ها فقط روی درایورهای پشتیبان

تگ‌ها اجازه می‌دهند گروهی از کلیدها را یکجا پاک کنیم:

php
Cache::tags(['products', 'category:5'])->remember('category:5:list', 600, fn () => /* ... */ []);

Cache::tags('category:5')->flush();

اما همه‌ی درایورها تگ را پشتیبانی نمی‌کنند. در لاراول ۱۳ درایورهای redis، memcached، array و apc تگ دارند؛ ولی database و file، که پیش‌فرض بسیاری از پروژه‌ها هستند، ندارند و صدا زدن tags() روی آن‌ها خطا می‌دهد. اگر روی درایور database هستید، از روش کلید نسخه‌دار که بالاتر دیدیم استفاده کنید؛ روی همه‌ی درایورها کار می‌کند.

جلوگیری از هجوم هم‌زمان با lock

فرض کنید کلید آمار داشبورد منقضی می‌شود و در همان ثانیه پنجاه درخواست می‌رسد. هر پنجاه درخواست کش را خالی می‌بینند و هر پنجاه کوئری سنگین را اجرا می‌کنند. به این وضعیت cache stampede می‌گویند.

flexible این مشکل را تا حد زیادی حل می‌کند، چون بازسازی را پشت lock انجام می‌دهد. اما اگر به remember با انقضای سخت نیاز دارید، خودتان lock بگذارید:

app/Support/DashboardStats.php
<?php

namespace App\Support;

use App\Models\Order;
use Illuminate\Support\Facades\Cache;

class DashboardStats
{
    public static function get(): array
    {
        $key = 'dashboard:stats';

        if (($cached = Cache::get($key)) !== null) {
            return $cached;
        }

        // فقط یک پردازش مقدار را می‌سازد؛ بقیه حداکثر ۱۰ ثانیه منتظر می‌مانند
        return Cache::lock("lock:{$key}", 30)->block(10, function () use ($key) {
            return Cache::remember($key, now()->addMinutes(5), fn () => [
                'orders' => Order::count(),
                'revenue' => (int) Order::sum('total'),
            ]);
        });
    }
}

داخل lock دوباره از remember استفاده کرده‌ایم: درخواست‌هایی که منتظر مانده‌اند، وقتی نوبتشان می‌رسد مقدار را در کش پیدا می‌کنند و کوئری را تکرار نمی‌کنند. اگر انتظار از ۱۰ ثانیه بیشتر شود، block استثنای LockTimeoutException می‌دهد که باید تصمیم بگیرید چطور مدیریتش کنید. lock روی درایورهای redis، database، file، memcached و dynamodb کار می‌کند، ولی اگر چند سرور دارید، کش باید مشترک باشد (مثلاً Redis)، نه file.

شیء کش نکنید؛ آرایه و مقدار ساده کش کنید

لاراول ۱۳ در config/cache.php گزینه‌ای به نام serializable_classes دارد که در پروژه‌های تازه مقدارش false است. یعنی هنگام خواندن از کش، هیچ کلاس PHPی unserialize نمی‌شود. دلیلش امنیتی است: اگر APP_KEY یا دسترسی به Redis لو برود، مهاجم نمی‌تواند با جاسازی یک شیء دستکاری‌شده در کش، کد اجرا کند1.

نتیجه‌ی عملی: اگر یک مدل Eloquent، یک Collection یا حتی یک شیء Carbon را کش کنید، موقع خواندن به‌جای شیء اصلی یک __PHP_Incomplete_Class تحویل می‌گیرید و کد جایی دورتر خطا می‌دهد.

php
// اشتباه: Collection از مدل‌ها
Cache::remember('categories:menu', 3600, fn () => Category::orderBy('position')->get());

// درست: آرایه‌ی ساده
Cache::remember('categories:menu', 3600, fn () => Category::orderBy('position')
    ->get(['id', 'name', 'slug'])
    ->toArray());

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

config/cache.php
'serializable_classes' => [
    App\Data\ExchangeRate::class,
],

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

جمع‌بندی

  • اول اندازه بگیرید؛ فقط داده‌ی گران و کم‌تغییر را کش کنید و اعداد حساس مالی را هرگز.
  • برای بیشتر صفحه‌های پربازدید Cache::flexible بهترین تعادل بین تازگی و سرعت است.
  • TTL را از روی «کهنگی قابل‌قبول» انتخاب کنید و هر چیزی که خروجی را تغییر می‌دهد در کلید بیاورید.
  • با updated_at یا شماره‌ی نسخه در کلید، بی‌اثرکردن کش را ساده و قابل‌اعتماد کنید.
  • تگ فقط روی Redis و Memcached؛ روی database و file سراغ کلید نسخه‌دار بروید.
  • برای کلیدهای گران، با lock جلوی هجوم هم‌زمان را بگیرید.
  • آرایه و مقدار ساده کش کنید، نه شیء.
  1. توضیح کامل گزینه‌های کش در مستندات رسمی لاراول آمده است: https://laravel.com/docs/cache ↩