ANUPET ACADEMY رجوع للموقع

Eloquent: إزاي تكتب استعلامات مش هتوقّع السيرفر

الصفحة شغالة عندك في نص ثانية وواقعة في الإنتاج. السبب في 90% من الحالات حاجة اسمها N+1 — وده شرحها وحلها بالكود قبل وبعد.

Eloquent: إزاي تكتب استعلامات مش هتوقّع السيرفر

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

المشكلة دي اسمها N+1، وهي أشهر سبب لبطء تطبيقات لارافيل. تعالى نشوفها بعينينا.

اشوفها إزاي أصلًا

قبل أي تحسين، لازم تقيس. أبسط طريقة — سطر واحد مؤقت في AppServiceProvider::boot():

use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    logger()->info($query->sql, ['ms' => $query->time]);
});

افتح الصفحة، وبعدها افتح storage/logs/laravel.log. لو لقيت نفس الاستعلام مكرر مية مرة بأرقام مختلفة، انت لقيت المشكلة. في مشروع حقيقي استخدم Laravel Telescope أو Debugbar بدل ده، بس ابدأ من هنا عشان تفهم بيحصل إيه.

الحالة الأولى: الحلقة اللي بتستعلم

الكود ده بريء الشكل:

$courses = Course::all();

foreach ($courses as $course) {
    echo $course->title . ' — ' . $course->trainees->count();
}

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

الحل مش تحميل مسبق هنا — الحل إنك تسأل قاعدة البيانات تعدّ هي:

$courses = Course::withCount('trainees')->get();

foreach ($courses as $course) {
    echo $course->title . ' — ' . $course->trainees_count;
}

استعلام واحد. والعد بيحصل في قاعدة البيانات، وهي عاملة عشان كده.

الحالة التانية: علاقة بتتقرا في العرض

$articles = Article::latest()->paginate(12);
// وفي الواجهة: {{ $article->author->name }}

اتناشر مقال يبقى تلتاشر استعلام. الحل هو التحميل المسبق:

$articles = Article::with('author')->latest()->paginate(12);

بقوا استعلامين مهما كان عدد المقالات. لكن استنى، فيه خطوة تانية أهم:

$articles = Article::query()
    ->with('author:id,name')
    ->select(['id', 'title', 'slug', 'author_id', 'published_at'])
    ->whereNotNull('published_at')
    ->latest('published_at')
    ->paginate(12);

هنا انت بتجيب الأعمدة اللي محتاجها بس. لو المقال فيه عمود body بمحتوى طويل وانت عايز العنوان بس، فانت كنت بتنقل ميجابايتات من قاعدة البيانات للتطبيق كل ريكوست من غير سبب.

ملحوظة بتوقّع ناس كتير: لما تحدّد أعمدة في علاقة belongsTo زي author:id,name، لازم تحط المفتاح الأساسي id في القائمة، ولازم يكون المفتاح الأجنبي author_id موجود في select بتاع الاستعلام الأصلي. من غيرهم لارافيل مش هيعرف يربط الكاتب بالمقال، وهتلاقي author بترجع null وانت مش فاهم ليه.

امنع المشكلة بدل ما تدوّر عليها

بدل ما تفضل تكتشف N+1 بعد ما توصل الإنتاج، خلي لارافيل يرمي لك استثناء في وشك وانت شغال:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

بالسطر ده أي علاقة بتتحمّل كسول وانت على بيئة التطوير هترمي خطأ صريح باسم الموديل والعلاقة. الشرط ! app()->isProduction() مقصود: انت عايز الخطأ وانت بتطوّر، مش عايز صفحة مكسورة قدام مستخدم حقيقي لأن حد نسي with() في مسار نادر.

الفهارس — الحتة اللي بيتنساها الناس

التحميل المسبق بيقلل عدد الاستعلامات. الفهرس بيقلل زمن الاستعلام الواحد. الاتنين مختلفين، ولازم تعمل الاتنين.

القاعدة العملية: أي عمود بتفلتر بيه في where، أو بترتّب بيه في orderBy، أو بتربط بيه في join أو علاقة — عايز فهرس.

Schema::table('articles', function (Blueprint $table) {
    $table->index('published_at');
    $table->index(['course_id', 'created_at']);
});

الفهرس المركّب في السطر التاني ترتيبه مقصود. الفهرس ده بيخدم استعلام بيفلتر بـ course_id لوحده، أو بـ course_id مع ترتيب بـ created_at — لكنه مش بيخدم استعلام بيفلتر بـ created_at لوحده. القاعدة اسمها البادئة اليسرى، وهي أكتر حاجة بيغلط فيها الناس في الفهارس المركّبة.

عايز تتأكد إن الفهرس بيتستخدم فعلًا؟ اسأل قاعدة البيانات نفسها:

EXPLAIN SELECT id, title FROM articles
WHERE course_id = 3 ORDER BY created_at DESC LIMIT 12;

لو شفت في عمود key اسم الفهرس بتاعك، تمام. لو لقيت NULL وفي Extra مكتوب Using filesort، يبقى قاعدة البيانات بتقرا الجدول كله وترتّبه في الذاكرة — وده اللي بيوقع السيرفر.

الحلقات على بيانات كتير

لو انت بتعالج كل صفوف جدول كبير في أمر مجدول، get() هتحمّلهم كلهم في الذاكرة ومعاها ينتهي الأمر بخطأ نفاد ذاكرة:

// غلط على جدول كبير
foreach (Payment::all() as $payment) { /* ... */ }
// صح — بيجيب ألف سطر في المرة ويفضّيهم من الذاكرة
Payment::query()
    ->where('status', 'pending')
    ->chunkById(1000, function ($payments) {
        foreach ($payments as $payment) {
            // ...
        }
    });

استخدم chunkById مش chunk لما بتعدّل نفس الأعمدة اللي بتفلتر بيها — chunk بيرقّم الصفحات بالإزاحة، فلو غيّرت الحالة اللي بتفلتر بيها هتفوّت سطور من غير ما تحس.

الترتيب اللي بشتغل بيه

لما صفحة تبقى بطيئة، ده الترتيب اللي بيحل 90% من الحالات:

  1. عُدّ الاستعلامات. رقم مش إحساس.
  2. شيل الـ N+1 بـ with أو withCount.
  3. قلّل الأعمدة المرجّعة.
  4. حط الفهارس وتأكد بـ EXPLAIN.
  5. وبعد كل ده بس، فكّر في التخزين المؤقت.

الكاش آخر خطوة عن قصد. الكاش على استعلام سيئ بيخبّي المشكلة لحد ما الكاش يتفضّى في أسوأ وقت، وساعتها بتقع تحت الضغط بالظبط. أصلح الاستعلام الأول.

إحنا في أكاديمية أنوبت بندي الموضوع ده سيشنات كاملة في [كورس الباك إند — المستوى التاني](/courses/back-end-2)، بنقيس فيها على بيانات مزروعة بآلاف السجلات — لأن كود التحسين مالوش أي معنى وانت مجرّبه على عشرين سطر.

الخطوة الجاية

افتح أبطأ صفحة عندك دلوقتي، حط سطر DB::listen، وعُدّ الاستعلامات. لو الرقم فوق العشرة على صفحة عرض عادية، عندك N+1 مستنية. ولو انت لسه بتبني أول واجهة برمجية ليك، ابدأ من [أول API بلارافيل](/blog/laravel-first-api) وارجع هنا بعد ما تشغّله.

الكلمات المفتاحية eloquent n+1 laravel eager loading لارافيل تحسين اداء لارافيل لارافيل بطيء فهارس قاعدة البيانات

كل المقالات