JOURNAL INDEX
2026.09.17/7 VIEWS/5 MIN READ

تحسين أداء Laravel: الكاش والفهارس واستعلامات Eloquent

عبدالرحمن ربيع
عبدالرحمن ربيع Software Engineer & AI Builder

دليل عملي لتحسين أداء Laravel عبر قياس الاستعلامات، وضبط الكاش والفهارس، وتجنب N+1، مع أمثلة Eloquent وأوامر نشر قابلة للتطبيق.

تحسين أداء Laravel: الكاش والفهارس واستعلامات Eloquent

لماذا يبدأ تحسين أداء Laravel بالقياس؟

تحسين أداء Laravel ليس مجموعة أوامر تُنفّذ مرة واحدة، بل دورة تبدأ بقياس المشكلة وتنتهي بالتحقق من أثر الحل. قد تكون الصفحة بطيئة بسبب استعلام غير مفهرس، أو تحميل علاقات كثيرة، أو اتصال بخدمة خارجية، وليس بسبب إطار العمل نفسه. لذلك، لا تبدأ بإضافة الكاش إلى كل شيء. اختر مسارًا مهمًا، مثل قائمة المنتجات أو لوحة المبيعات، وحدد زمن استجابته وعدد استعلاماته وحجم البيانات التي يعيدها.

استخدم بيانات قريبة من حجم الإنتاج، لأن جدولًا يحتوي على خمسين سجلًا لن يكشف المشكلات التي تظهر عند وصوله إلى مليون سجل. كرر الطلب عدة مرات، وافصل بين الطلب الأول والطلبات اللاحقة، وسجل الوسيط والمئين الخامس والتسعين بدل الاكتفاء بمتوسط واحد. راقب كذلك استهلاك الذاكرة ونسبة الأخطاء؛ فالتحسين الحقيقي لا يسرّع التطبيق على حساب صحته أو استقراره.

راقب الاستعلامات في بيئة التطوير

يمكن تسجيل الاستعلامات البطيئة داخل دالة boot في AppServiceProvider. المثال التالي يسجل الاستعلامات التي تتجاوز مئة مللي ثانية محليًا. هذه العتبة نقطة بداية وليست معيارًا ثابتًا. تجنب تسجيل القيم المرتبطة بالاستعلام دون تنقيح، فقد تتضمن بيانات شخصية أو رموزًا حساسة. كذلك، لا تجعل التسجيل التفصيلي عبئًا دائمًا على الإنتاج.

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    if (app()->environment('local')) {
        DB::listen(function (QueryExecuted $query) {
            if ($query->time > 100) {
                Log::debug('Slow query', [
                    'sql' => $query->sql,
                    'time_ms' => $query->time,
                ]);
            }
        });
    }
}

عالج مشكلة N+1 قبل التفكير في الكاش

تحدث مشكلة N+1 عندما تجلب قائمة سجلات ثم تنفذ استعلامًا إضافيًا لكل سجل للوصول إلى علاقة. مثلًا، عرض ثلاثين مقالًا مع اسم الكاتب قد ينتج واحدًا وثلاثين استعلامًا بدل استعلامين. يبدو الكود بسيطًا، لكن تكلفة الاتصال بقاعدة البيانات تتضاعف مع نمو الصفحة. الحل المعتاد هو التحميل المسبق للعلاقات باستخدام with، وليس تخزين النتيجة البطيئة مباشرة في الكاش.

use App\Models\Post;

$posts = Post::query()
    ->select(['id', 'user_id', 'title', 'published_at'])
    ->with('user:id,name')
    ->where('status', 'published')
    ->orderByDesc('published_at')
    ->orderByDesc('id')
    ->paginate(20);

foreach ($posts as $post) {
    echo $post->user?->name;
}

عند تحديد أعمدة العلاقات، احتفظ بالمفاتيح اللازمة لربط النتائج. نحتاج هنا إلى user_id في المقال وإلى id في المستخدم. لا تحمّل جميع العلاقات احتياطيًا؛ فهذا يستهلك الذاكرة وينقل بيانات غير مستخدمة. راجع ما يحتاجه القالب أو مورد الواجهة فعلًا، ثم حمّل العلاقات المناسبة لذلك المسار فقط.

امنع التحميل الكسول غير المقصود

فعّل منع التحميل الكسول خارج الإنتاج حتى تظهر الأخطاء أثناء التطوير والاختبارات. لا يغني ذلك عن مراجعة الاستعلامات، لكنه يمنع تكرار المشكلة عند إضافة حقول جديدة إلى الواجهات. وإذا كنت تحتاج عدد التعليقات فقط، فاستخدم withCount بدل تحميل نماذج التعليقات كاملة ثم عدّها داخل PHP.

use Illuminate\Database\Eloquent\Model;

// Inside AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

$posts = Post::query()
    ->withCount('comments')
    ->paginate(20);

قلّل البيانات التي تعالجها استعلامات Eloquent

اختيار الأعمدة المطلوبة يقلل نقل البيانات وكلفة تحويل الصفوف إلى نماذج. استخدم exists عندما تريد معرفة وجود سجل، وcount عندما تريد العدد، وpluck عندما تحتاج قائمة قيم فقط. تجنب get ثم إجراء التصفية أو التجميع داخل PHP إذا كانت قاعدة البيانات تستطيع تنفيذ العملية بكفاءة. لكن تذكر أن اختصار الكود لا يضمن تلقائيًا خطة تنفيذ أفضل.

use App\Models\Order;

$hasPendingOrders = Order::query()
    ->where('user_id', $userId)
    ->where('status', 'pending')
    ->exists();

$total = Order::query()
    ->where('user_id', $userId)
    ->where('status', 'paid')
    ->sum('total');

اختر التصفح والمعالجة على دفعات بوعي

تستخدم paginate استعلامًا لحساب إجمالي النتائج، بينما تتجنب simplePaginate هذا الحساب عندما تكفي روابط السابق والتالي. وللقوائم الكبيرة، قد يكون cursorPaginate أنسب من الإزاحات العميقة، بشرط ترتيب ثابت وفريد ومدعوم بفهرس مناسب. أما مهام المعالجة، فنفذها بدفعات باستخدام chunkById أو lazyById بدل تحميل الجدول كاملًا. لا تعدّل عمود الترتيب أثناء المرور على الدفعات.

$orders = Order::query()
    ->where('user_id', $userId)
    ->orderByDesc('id')
    ->cursorPaginate(50);

Order::query()
    ->select(['id', 'status'])
    ->where('status', 'pending')
    ->chunkById(500, function ($orders) {
        foreach ($orders as $order) {
            // Perform an idempotent operation here.
        }
    });

صمّم الفهارس وفق شروط البحث والترتيب

الفهرس الجيد يعكس شكل الاستعلام المتكرر. إذا كانت صفحة الطلبات ترشح حسب المستخدم والحالة، ثم ترتب بحسب المعرف، فقد يفيد فهرس مركب على user_id وstatus وid. لا تضف فهرسًا لكل عمود عشوائيًا؛ فالفهارس تستهلك مساحة وتزيد تكلفة الإدراج والتحديث والحذف. افحص الفهارس الموجودة أولًا لتجنب التكرار، وقيّم تأثير أي إضافة على عمليات الكتابة.

php artisan make:migration add_user_status_id_index_to_orders_table --table=orders
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->index(
                ['user_id', 'status', 'id'],
                'orders_user_status_id_idx'
            );
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex('orders_user_status_id_idx');
        });
    }
};

ترتيب أعمدة الفهرس مهم. الفهرس السابق لا يضمن أفضل أداء لاستعلام يعتمد على status وحده، كما أنه لا يطابق بالضرورة قائمة مستخدم مرتبة بالمعرف دون شرط الحالة. انطلق من استعلام فعلي، لا من أسماء الأعمدة. قبل تنفيذ الترحيل على جدول ضخم، تحقق من سلوك محرك قاعدة البيانات وإمكان حجز الجدول، ثم اختر نافذة نشر مناسبة.

تحقق من خطة التنفيذ باستخدام EXPLAIN

في MySQL، يساعد EXPLAIN على معرفة الفهرس المختار والعدد التقديري للصفوف وخطوات الفرز. افحص الخطة قبل الإضافة وبعدها، ثم قارن الزمن تحت ظروف متشابهة. ظهور فهرس في الخطة ليس دليلًا كافيًا على نجاح التحسين؛ فقد يظل الاستعلام يقرأ صفوفًا كثيرة. كذلك، تختلف تفاصيل الخطط وأدوات تحليلها بين محركات قواعد البيانات.

EXPLAIN
SELECT id, total
FROM orders
WHERE user_id = 42
  AND status = 'paid'
ORDER BY id DESC
LIMIT 50;

استخدم الكاش للبيانات المناسبة لا لإخفاء العيوب

يناسب الكاش النتائج المكلفة التي تتكرر قراءتها وتتحمل قدرًا معلومًا من التأخر، مثل قائمة التصنيفات أو مؤشرات عامة. حدد أولًا مدة صلاحية البيانات، ومن يحق له رؤيتها، ومتى يجب إبطالها. لا تخزن نتيجة شخصية تحت مفتاح مشترك. يجب أن يتضمن المفتاح كل ما يغير النتيجة، مثل المستخدم والمتجر واللغة والمرشحات ذات الصلة.

use Illuminate\Support\Facades\Cache;

$key = 'dashboard:v1:user:'.$userId.':paid-total';

$total = Cache::remember($key, now()->addMinutes(5), function () use ($userId) {
    return Order::query()
        ->where('user_id', $userId)
        ->where('status', 'paid')
        ->sum('total');
});

الإصدار v1 داخل المفتاح يسهّل الانتقال إلى بنية جديدة دون تفسير قيم قديمة بطريقة خاطئة. ومدة الخمس دقائق مثال يحتاج ضبطًا وفق متطلبات المنتج. لا تعتمد على هذا الإجمالي المخزن لاتخاذ قرار مالي حساس؛ ارجع إلى البيانات الأصلية وقواعد الاتساق المناسبة عند التنفيذ. الكاش طبقة تسريع، وليس بديلًا عن مصدر الحقيقة.

اضبط مخزن الكاش وخطة الإبطال

في مشروع حديث يستخدم CACHE_STORE داخل config/cache.php، يمكن اختيار Redis عبر الإعداد التالي. إذا لم يتوفر امتداد PhpRedis، يمكن تثبيت عميل Predis باستخدام Composer. اختر عميلًا واحدًا واضبط الاتصال بخدمة Redis العاملة. أما المشاريع الأقدم، فقد تستخدم CACHE_DRIVER؛ راجع ملف الإعداد الفعلي بدل افتراض اسم متغير ثابت لكل الإصدارات.

composer require predis/predis

# .env
CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

بعد نجاح تعديل الطلب واعتماد المعاملة، أبطل المفتاح المرتبط بالمستخدم. إذا كانت الكتابات تحدث من مسارات متعددة، فاجمع منطق الإبطال في خدمة أو مستمع أحداث يعمل بعد الاعتماد. انتبه إلى أن طلبًا متزامنًا قد يعيد ملء المفتاح بقيمة قديمة؛ التطبيقات ذات الاتساق الصارم تحتاج استراتيجية أقوى، مثل مفاتيح بإصدارات متغيرة أو تجاوز الكاش للقراءات الحساسة.

DB::transaction(function () use ($order) {
    $order->update(['status' => 'paid']);

    $userId = $order->user_id;

    DB::afterCommit(function () use ($userId) {
        Cache::forget('dashboard:v1:user:'.$userId.':paid-total');
    });
});

تجنب تدافع الطلبات عند انتهاء الكاش

عند انتهاء مفتاح شائع، قد تحاول طلبات كثيرة إعادة حسابه في اللحظة نفسها. Cache::remember لا يوفر قفلًا تلقائيًا يمنع هذه الحالة. استخدم قفلًا موزعًا مع مخزن يدعم الأقفال، وأعد فحص القيمة بعد الحصول عليه. اضبط مدة القفل فوق زمن الحساب المتوقع، وعالج مهلة الانتظار باستجابة محسوبة أو قيمة قديمة مقبولة، لا باستعلامات احتياطية غير محدودة.

$value = Cache::get($key);

if ($value === null) {
    $value = Cache::lock($key.':lock', 15)->block(3, function () use ($key, $userId) {
        return Cache::remember($key, 300, function () use ($userId) {
            return Order::query()
                ->where('user_id', $userId)
                ->where('status', 'paid')
                ->sum('total');
        });
    });
}

يفترض المثال أن null ليست قيمة صالحة للنتيجة، وأن التطبيق يعالج LockTimeoutException في طبقته المناسبة. وفي بيئة متعددة الخوادم، يجب أن تتشارك جميع النسخ مخزن القفل نفسه. يمكن أيضًا توزيع أوقات انتهاء المفاتيح بإضافة تفاوت صغير إلى مدة الصلاحية، لتجنب موجة إعادة حساب جماعية.

جهّز Laravel للإنتاج دون مفاجآت

بعد تحسين مسار البيانات، جهّز ملفات التطبيق للإنتاج. نفذ الأوامر داخل عملية نشر مجربة، وبعد توفير متغيرات البيئة الصحيحة. يعمل Composer على استبعاد اعتماديات التطوير وتحسين التحميل التلقائي، بينما تجمع أوامر Artisan الإعدادات والمسارات والقوالب. هذه الخطوات تقلل العمل المتكرر، لكنها لا تصلح استعلامًا سيئًا أو اتصالًا خارجيًا بطيئًا.

composer install --no-dev --prefer-dist --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart

اضبط APP_DEBUG=false في الإنتاج، واستخدم env داخل ملفات الإعداد فقط، ثم اقرأ القيم عبر config في التطبيق. إعادة تشغيل عمال الطوابير مهمة لتحميل الكود الجديد، ويجب أن يديرهم مشرف عمليات يعيد تشغيلهم بعد خروجهم. وإذا كان المشروع يستخدم عمليات طويلة العمر أخرى، فاتبع آلية إعادة تحميلها الخاصة ضمن خطة النشر.

قائمة تحقق لقياس النتيجة والمحافظة عليها

اختبر الصفحة نفسها بالبيانات والصلاحيات والمرشحات ذاتها قبل التعديل وبعده. قارن الكاش البارد والدافئ، واختبر تزامن الطلبات بدل الاكتفاء بطلب منفرد. أضف اختبارات للتأكد من عزل بيانات المستخدمين وإبطال النتائج بعد التحديث. ثم راقب التطبيق بعد النشر، لأن توزيع البيانات وحركة الاستخدام الفعلية قد يختلفان عن بيئة الاختبار.

  • سجل زمن الاستجابة وعدد الاستعلامات واستهلاك الذاكرة قبل التحسين.
  • عالج N+1 والتحميل الزائد قبل إضافة طبقات تخزين مؤقت.
  • اربط كل فهرس باستعلام واضح وتحقق من خطة تنفيذه.
  • حدد صلاحية الكاش ومفاتيحه وسياسة الإبطال وسلوك الفشل.
  • راقب المئين الخامس والتسعين والأخطاء وتكلفة الكتابة بعد النشر.

الترتيب العملي هو القياس، ثم تقليل العمل، ثم تسريع الوصول بالفهرسة، وأخيرًا إعادة استخدام النتائج بالكاش. باتباع هذا التسلسل، يصبح تحسين أداء Laravel عملية قابلة للتفسير والاختبار، لا مجموعة تغييرات يصعب معرفة سبب نجاحها أو فشلها.

SHARE شارك المقال
عبدالرحمن ربيع
كتبه

عبدالرحمن ربيع

Software Engineer & AI Builder

مطور برمجيات متكامل ومصمم جرافيك مع أكثر من 4 سنوات خبرة في بناء تطبيقات الويب الحديثة باستخدام PHP و JavaScript و HTML و CSS. خلفية قوية في تصميم UI/UX واستخدام متقدم لأدوات الذكاء الاصطناعي لتعزيز كفاءة التطوير والأتمتة واتخاذ القرارات. حاصل على ماجستير تنفي...

RELATED / READ NEXT

مقالات ذات صلة

كل المقالات
COMMENTS

التعليقات (0)

كن أول من يعلّق على هذا المقال.

أضف تعليقك

يظهر تعليقك بعد المراجعة.