استعلامات N+1 في Laravel: اعثر عليها قبل أن يعثر الإنتاج
N+1 أشيع عطل أداء في Laravel وأسهله عودةً. أين يختبئ، وكيف تجعله يُفشل اختبارًا بدل صفحة، ومتى يكون التحميل المسبق هو الخطأ.
N+1 أول عطل أداء يسمع به الناس في Laravel، وهو الذي ما زلنا نجده أكثر من غيره في الإنتاج. لا لأن الفرق لا تعرف ما هو، بل لأن معرفة ما هو لا تمنع عودته.
ما هو فعلًا
استعلام لجلب مجموعة، ثم استعلام إضافي لكل عنصر في تلك المجموعة:
$orders = Order::latest()->take(50)->get(); // استعلام واحد
foreach ($orders as $order) {
echo $order->customer->name; // 50 استعلامًا
}واحد وخمسون استعلامًا حيث يكفي اثنان. مقابل قاعدة بيانات محلية فيها أربعون صفًا لا يكلّف شيئًا. وفي الإنتاج هو نقطة النهاية التي يشتكي منها الجميع.
الإصلاح الذي يعرفه الكل:
$orders = Order::with('customer')->latest()->take(50)->get(); // استعلامانلو توقف المقال هنا لكان المقال نفسه الذي عند الجميع. الجزء المثير هو لماذا يظل هذا يحدث في شيفرات يعرف فيها كل مطوّر ما سبق.
أين يختبئ فعلًا
في accessor. هذا هو الذي يخدع الناس، لأن موضع الاستدعاء يبدو تنسيق نص لا وصولًا إلى قاعدة بيانات:
public function getDisplayNameAttribute(): string
{
return $this->customer->name . ' (' . $this->customer->country->code . ')';
}لا شيء في موضع الاستدعاء يقول "استعلام". والأسوأ أن الـ accessors تعمل كثيرًا أثناء التسلسل، فنقطة نهاية تبدو وكأنها تحمّل علاقة واحدة تُصدر اثنتين لكل صف.
في جزء Blade أُعيد استخدامه في مكان جديد. كُتب الجزء لصفحة تفصيل، حيث يكون تحميل علاقة استعلامًا واحدًا. ثم يُدرجه أحدهم داخل حلقة في صفحة قائمة. والفرق الذي سبّب الانتكاسة لا يحوي استعلامًا واحدًا.
خلف شرط. تُحمَّل العلاقة مسبقًا في المتحكم الذي جرى تحليله. ويعيد متحكم ثانٍ
المورد نفسه بلا with()، لأن من كتبه لم يعلم أن المورد يلمس علاقة أصلًا.
داخل policy. يعمل التخويل لكل عنصر. وpolicy تقرأ $user->team->settings
هي استعلام لكل عنصر في المجموعة التي تخوّلها.
في مهمة، لكل سجل. عمّال الطوابير يجعلون N+1 غير مرئي - لا أحد ينتظر الصفحة، فالعَرَض الوحيد طابور يُفرَّغ أبطأ مما ينبغي وقاعدة بيانات بحمل لا يعرف أحد إلى أين ينسبه.
اجعله يفشل بصوت عالٍ، في سطر واحد
هذا أثمن تغيير في المقال:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());صار التحميل الكسول يرمي LazyLoadingViolationException في التطوير والاختبار.
ولم يعد N+1 شيئًا تلاحظه على رسم بياني بل شيئًا يفشل في التكامل المستمر، في طلب
الدمج الذي أدخله، بأثر يشير إلى السطر.
تفعّله أغلب الفرق في كل مكان إلا الإنتاج، لأن مخالفة فاتتك كصفحة بطيئة أفضل منها كخطأ 500. هذا افتراض معقول ويستحق إعادة النظر بعد أن تنظف الشيفرة - فاستثناء في الإنتاج هو كيف تجد مسار الشيفرة الذي لا تغطيه اختباراتك.
ثم اجعله يبقى مصلَحًا
المنع يلتقط العطل والمطوّر لا يزال ممسكًا به. والحماية من الانتكاس تلتقطه لاحقًا. تريد الاثنين، والثاني ثلاثة أسطر:
it('يعرض الطلبات دون N+1', function () {
Order::factory()->count(20)->create();
DB::enableQueryLog();
$this->get('/orders')->assertOk();
expect(DB::getQueryLog())->toHaveCount(4);
});اختبار يؤكد رقمًا لا مجالًا، على نقاط النهاية التي تحمل الحركة. وحين يزيل أحدهم تحميلًا مسبقًا، يفشل البناء برقم انتقل من أربعة إلى أربعة وعشرين، ويكون السبب في الفرق الذي أمامه.
الاعتراض على هذا أن الرقم هشّ. وتلك هي الميزة: أنت تريد أن تُخبَر حين يتغير عدد الاستعلامات، وتحديث التأكيد هو اللحظة التي تقرر فيها هل كان التغيير مقصودًا.
متى يكون التحميل المسبق الجواب الخطأ
التحميل المسبق يستبدل باستعلامات N استعلامًا واحدًا. وأحيانًا يكون الرقم الصحيح صفرًا.
لا تحتاج إلا عدًّا. الدالة $post->comments->count() تحمّل كل تعليق لعدّه.
أما withCount('comments') فتطلب رقمًا من قاعدة البيانات:
$posts = Post::withCount('comments')->get();
// $post->comments_count، بلا تهيئة صفوفوينطبق الأمر نفسه على المجاميع والقيم القصوى والوجود. الدوال withSum
وwithMax وwhereHas كلها تُبقي العمل في SQL.
لا تحتاج إلا الأحدث. تحميل تاريخ طلبات عميل كاملًا لعرض أحدث طلب له هو علاقة بقيد لا تحميل مسبق كامل.
أنت تقسّم شيئًا هائلًا. استخدام with() على علاقة فيها آلاف الصفوف لكل أب
يحوّل مشكلة إلى مشكلة ذاكرة. قسّمها على دفعات، أو أعد هيكلة الاستعلام لتقوم
قاعدة البيانات بالترشيح.
البيانات قابلة لإلغاء التطبيع. عمود عدّاد يُصان عند الكتابة ليس قلة أناقة - بل هو الجواب الصحيح حين تُقرأ قيمة ألف مرة لكل كتابة ويجب الترتيب بها.
قائمة قصيرة
preventLazyLoadingمفعَّلًا خارج الإنتاج.- تأكيدات على عدد الاستعلامات في نقاط النهاية الأعلى حركة.
- دقّق في الـ accessors لديك: أي واحد منها يلمس علاقة هو مولّد N+1 متنكّر.
- افحص policies وموارد الواجهة البرمجية، لا المتحكمات وحدها.
- قبل إضافة
with()، اسأل هل تحتاج الصفوف أصلًا أم رقمًا فقط.
لا شيء من هذا صعب. إنه الفرق بين تطبيق سريع لأن أحدهم حلّله الربع الماضي وتطبيق سريع لأنه لا يستطيع أن يكفّ عن كونه سريعًا بصمت.
وإن كانت الاستعلامات في الإنتاج بالفعل ولا أحد يعرف أيّها يكلّفك، فذلك هو العمل الذي نؤديه - ويبدأ بالقياس لا بقائمة ممارسات فضلى.
