مشكلة N+1 في Laravel: كيف تكتشفها وتحلها قبل أن تصل الإنتاج

مشكلة N+1 في Laravel تحوّل صفحة بسيطة إلى عشرات الاستعلامات دون أن تنتبه. إليك كيف تكتشفها بـ Eloquent وتحلها بالتحميل المسبق ومنعها نهائيًا.

صفحة تعمل بسرعة على جهازك ببيانات تجريبية قليلة، ثم تبطؤ في الإنتاج عندما تكبر الجداول. السبب في كثير من الأحيان ليس السيرفر ولا قاعدة البيانات، بل نمط واحد في الكود اسمه N+1. في هذا المقال نشرح ما هو، ولماذا يمر دون أن يلاحظه أحد، وأربع طرق عملية في Laravel لحله ولمنعه من العودة.

ما هي مشكلة N+1 بالضبط؟

عندما تجلب مجموعة من النماذج باستعلام واحد، ثم تصل إلى علاقة كل نموذج داخل حلقة، يُنفَّذ استعلام إضافي لكل نموذج. هذا ما توضحه وثائق Laravel الرسمية بمثال الكتب والمؤلفين:

use App\Models\Book;

$books = Book::all(); // استعلام واحد

foreach ($books as $book) {
    echo $book->author->name; // استعلام لكل كتاب
}

بحسب الوثائق، هذا الكود ينفّذ 26 استعلامًا لـ 25 كتابًا: استعلام لجلب الكتب، و25 استعلامًا لجلب مؤلف كل كتاب. السبب أن Eloquent يستخدم التحميل الكسول (lazy loading): لا يجلب العلاقة إلا عند أول وصول إليها عبر الخاصية الديناميكية $book->author.

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

الحل الأول: التحميل المسبق with()

الحل الأساسي هو التحميل المسبق (eager loading) عبر with():

$books = Book::with('author')->get();

foreach ($books as $book) {
    echo $book->author->name;
}

الآن يُنفَّذ استعلامان فقط، الثاني بصيغة where id in (...) يجلب كل المؤلفين دفعة واحدة. ويمكنك تحميل أكثر من علاقة، أو علاقات متداخلة بالنقطة:

$books = Book::with(['author', 'publisher'])->get();
$books = Book::with('author.contacts')->get();

تقييد التحميل المسبق

إن لم تحتج كل الصفوف المرتبطة، قيّد الاستعلام الثاني بدالة:

use Illuminate\Database\Eloquent\Builder;

$users = User::with(['posts' => function (Builder $query) {
    $query->where('published', true)->orderBy('created_at', 'desc');
}])->get();

وإن كنت تحتاج فقط أعمدة محددة، فاذكرها صراحةً مع التنبيه الذي تذكره الوثائق: يجب تضمين عمود id وأعمدة المفاتيح الأجنبية ذات الصلة:

$books = Book::with('author:id,name,book_id')->get();

الحل الثاني: التحميل المسبق المتأخر load() و loadMissing()

أحيانًا لا تعرف إن كنت ستحتاج العلاقة إلا بعد جلب النماذج. هنا يأتي lazy eager loading:

$books = Book::all();

if ($condition) {
    $books->load('author', 'publisher');
}

وتحمّل العلاقة فقط إن لم تكن محمّلة سابقًا بـ loadMissing:

$book->loadMissing('author');

الفرق المهم عن الوصول الكسول داخل الحلقة: load() على المجموعة كلها ينفّذ استعلامًا واحدًا لكل العلاقة، لا استعلامًا لكل نموذج.

حالة خاصة: تحتاج العدد فقط

كثيرًا ما تحمّل علاقة كاملة لتعرض عدد عناصرها فقط (مثل عدد التعليقات). في هذه الحالة لا حاجة لجلب الصفوف أصلًا؛ استخدم withCount ليحسب قاعدة البيانات العدد في الاستعلام نفسه، فتصل القيمة في خاصية comments_count:

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}

بهذا تتجنب كلًّا من N+1 وتحميل آلاف الصفوف في الذاكرة لمجرد عدّها. وتوجد دوال مشابهة لمجاميع أخرى مثل withSum وwithAvg.

الحل الثالث: التحميل التلقائي

توفّر Laravel خيارين لتقليل الجهد اليدوي:

خاصية $with في النموذج لعلاقات تريدها دائمًا:

class Book extends Model
{
    protected $with = ['author'];
}

ولتعطيلها في استعلام معيّن تستخدم without('author')، أو withOnly('genre') لتجاوز القائمة كلها. انتبه: ما تضعه في $with يُحمَّل في كل استعلام، فلا تضع فيه إلا ما تحتاجه فعلًا تقريبًا في كل مرة.

التحميل التلقائي للعلاقات، وتصفه وثائق Laravel 13 بأن الإطار يحاول جلب أي علاقة تصل إليها ولم تُحمَّل، لكل النماذج في المجموعة دفعة واحدة. تفعّله في AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::automaticallyEagerLoadRelationships();
}

وإن لم ترد تفعيله عالميًا، فعّله على مجموعة واحدة:

$users = User::where('vip', true)->get();

return $users->withRelationshipAutoloading();

هذا مفيد كشبكة أمان، لكنه لا يغني عن الفهم: التحميل الصريح بـ with() يبقى أوضح في القراءة ويجعل عدد الاستعلامات متوقعًا.

الحل الرابع: امنع المشكلة من الأصل

أفضل علاج هو ألا تصل المشكلة إلى الإنتاج أصلًا. تتيح Laravel منع التحميل الكسول بالكامل، فيرمي استثناء LazyLoadingViolationException عند أي وصول كسول:

Model::preventLazyLoading(! $this->app->isProduction());

وضع الشرط ! isProduction() مقصود، كما تشرح الوثائق: تظهر الأخطاء في التطوير والاختبار، بينما يستمر الإنتاج بالعمل حتى لو سقط تحميل كسول بالخطأ. وإن فضّلت تسجيل المخالفة بدل الاستثناء:

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    $class = $model::class;

    info("Attempted to lazy load [{$relation}] on model [{$class}].");
});

بهذا تتحول المشكلة من «بطء غامض لا يلاحظه أحد» إلى خطأ واضح يظهر في اختبارات CI أو في السجل.

كيف تكتشف N+1 في تطبيق موجود؟

  • فعّل preventLazyLoading في بيئة التطوير وشغّل الاختبارات وتصفّح الصفحات الثقيلة؛ كل استثناء يدلّك على موضع.
  • راقب عدد الاستعلامات للطلب الواحد عبر أداة تشخيص مثل Telescope أو Debugbar. علامة التحذير: استعلامات متطابقة الشكل تتكرر بمعرّفات مختلفة.
  • ابدأ بالصفحات التي تعرض قوائم وجداول، فهي المكان الأشهر لهذا النمط.

اكتب اختبارًا يحمي الصفحة من العودة

يمكنك قياس عدد الاستعلامات في اختبار بسيط عبر سجل الاستعلامات، ليفشل الاختبار إن أضاف أحدهم علاقة دون تحميل مسبق:

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$this->get('/books')->assertOk();

$this->assertCount(2, DB::getQueryLog());

الرقم 2 هنا مثال لصفحة تجلب الكتب ومؤلفيهم فقط؛ اضبطه على الصفحة الفعلية عندك. هذا الاختبار حارس رخيص، لكنه يحتاج صيانة كلما تغيّرت الصفحة عمدًا، لذا استخدمه للصفحات الحرجة فقط.

أخطاء شائعة

  • التحميل المسبق الزائد: تحميل علاقات لا تستخدمها يضيف استعلامات ويستهلك ذاكرة بلا فائدة.
  • نسيان العلاقات المتداخلة: with('author') لا يحمّل author.contacts؛ اذكرها صراحةً.
  • الوصول إلى العلاقة من داخل Accessor أو Resource: كثيرًا ما يكون هنا مصدر N+1 المخفي، فتأكد أن الاستعلام الذي يغذّي الـ Resource حمّل علاقاته مسبقًا.

الخلاصة

N+1 ليست خطأً في Eloquent بل نتيجة لسلوكه الكسول الافتراضي. القاعدة العملية: حمّل علاقاتك مسبقًا بـ with() حين تعرف أنك ستستخدمها، وفعّل Model::preventLazyLoading(! app()->isProduction()) اليوم في مشروعك لتكتشف ما تبقى منها. جرّبها على أبطأ صفحة عندك، وقد تُفاجأ بعدد الاستعلامات التي تختفي.

المصادر