مشكلة N+1 في الاستعلامات: كيف تكتشفها وتحلها في كل إطار عمل

بقلم فريق تقني ·· برمجة
مشكلة N+1 في الاستعلامات: كيف تكتشفها وتحلها في كل إطار عمل

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

كيف تكتشفها قبل أن يكتشفها المستخدم

أول خطوة ليست القراءة اليدوية للكود بل قياس عدد الاستعلامات فعلياً: في Django يكشفها Django Debug Toolbar مباشرة عبر لوحة جانبية، وفي Laravel تؤدي المهمة نفسها Laravel Debugbar أو Telescope. في Rails الأداة الأشهر هي gem يسمى Bullet، يكشف حالتين في آن واحد: استعلامات N+1، وأيضاً تحميلاً مسبقاً مفعّلاً لكن غير مُستخدم فعلياً في الصفحة. مشكلته أنه يُعرف بتنبيهات كاذبة أحياناً، وهذا ما يحاول حله بديل أحدث اسمه Prosopite، الذي يدّعي في توثيقه صفر نتائج كاذبة.

أما في بايثون عموماً، لا في Django فقط، فتوجد مكتبة nplusone التي تدعم Django وSQLAlchemy وPeewee معاً، وتكشف أيضاً الحالة المعكوسة: تحميلاً مسبقاً مفعّلاً بلا حاجة فعلية له.

حلول أطر العمل: من مئة استعلام إلى واحد

Django

جلب 10 كائنات Entry مع المدونة (Blog) التابعة لكل واحد منها، دون أي تحسين، يكلّف 11 استعلاماً — وهذا بالضبط ما يظهره Debug Toolbar فور تحميل الصفحة. select_related يحلّ المشكلة بدمج الجلب في استعلام واحد عبر JOIN، لكنه محصور بعلاقات ForeignKey أو OneToOne. أما prefetch_related فيخفّض العدد إلى استعلامين فقط: استعلام إضافي منفصل يُدمج نتائجه في بايثون بعد ذلك، وهو الخيار الوحيد الذي يعمل مع ManyToMany والعلاقات المعكوسة التي يعجز select_related عن ضمّها في JOIN واحد.

خطأ شائع: فلترة نتيجة محمّلة بـprefetch_related عبر .filter() مباشرة تُلغي التخزين المؤقت بالكامل وتُشغّل استعلاماً جديداً من الصفر. الحل تمرير الفلترة داخل Prefetch نفسه:

from django.db.models import Prefetch

Blog.objects.prefetch_related(
    Prefetch("entry_set", queryset=Entry.objects.filter(published=True))
)

Laravel

التوثيق الرسمي لـLaravel 12.x يستخدم مثالاً يسهل تذكّره: 25 كتاباً مع مؤلفيها، بلا تحميل مسبق، يكلّف 26 استعلاماً (1+25)، وسطر واحد يقلّصها إلى استعلامين فقط:

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

ثم ذهبت النسخة 12.8.0 خطوة أبعد: Model::automaticallyEagerLoadRelationships() يحمّل العلاقات تلقائياً عند أول وصول إليها، فلا حاجة لكتابة with() يدوياً في كل استعلام.

Rails

دليل Rails الرسمي يعرض مثالاً مشابهاً: 10 كتب بأسماء مؤلفيها، بلا تحميل مسبق، تكلّف 11 استعلاماً. Book.includes(:author) أو Book.preload(:author) يخفّضها إلى استعلامين. البديل الثالث، Book.eager_load(:author)، يدمجها في استعلام واحد بـLEFT OUTER JOIN، وله ميزة لا يملكها preload: يسمح بالفلترة على الجدول التابع نفسه ضمن الاستعلام.

JPA وHibernate

العدد يعود للظهور مع JPA وHibernate: 100 كائن Author بلا fetch join يكلّف 101 استعلاماً (1+100) — نفس الرقم الذي افتتحنا به هذا المقال. دمج العلاقة عبر JOIN FETCH في JPQL يقلّصها لاستعلام واحد:

SELECT a FROM Author a JOIN FETCH a.books

لكن JOIN FETCH يحمل فخّين يجب معرفتهما مسبقاً. الفخّ الأول: دمجه مع الترقيم (firstResult وmaxResults) على علاقة جماعية يجعل Hibernate يطبّق الترقيم في الذاكرة بدل SQL، وهذا تحذير معروف برمز HHH000104. الفخّ الثاني: دمج JOIN FETCH لعلاقتين جماعيتين معاً في استعلام واحد يرمي MultipleBagFetchException. الحل تحويل إحدى العلاقتين إلى Set، أو تقسيمهما لاستعلامين منفصلين، أو الاعتماد على الجلب المجمّع عبر ضبط hibernate.default_batch_fetch_size.

GraphQL

المشكلة هنا مختلفة جوهرياً عن مشكلة التحميل المسبق في ORM: كل resolver في GraphQL يعمل بشكل مستقل بلا أي تجميع تلقائي للنداءات، حتى لو كانت البيانات نفسها محمّلة مسبقاً بشكل صحيح في قاعدة البيانات. الحل هو DataLoader في graphql-js، الذي يجمع نداءات .load(key) المتعددة في نداء واحد لدالة batchLoadFn(keys)، ويخزّن النتائج مؤقتاً طوال مدة طلب GraphQL واحد فقط.

حين يكون التحميل المسبق نفسه المشكلة

تفعيل التحميل المسبق لكل العلاقات «احتياطاً»، دون تحقق من الاستخدام الفعلي في الصفحة، يُنتج نقيض الفائدة: الجلب الزائد يستهلك ذاكرة وعرض نطاق بلا أي استخدام حقيقي. علاقة جماعية ضخمة محمّلة مسبقاً قد تُغرق ذاكرة السيرفر وحده. وكما ذكرنا في JPA، دمج JOIN FETCH لعلاقتين جماعيتين معاً يولّد جداءً ديكارتياً يضخّم النتيجة بلا حاجة. هذه ليست مسألة استعلامات قواعد بيانات فقط؛ سلوك المعاملة نفسه يتغيّر بين المحرّكات، وهو ما يفصّله مقال مستويات العزل في قواعد البيانات عند الحديث عن كلفة رفع مستوى العزل مقابل القفل الصريح.

الفرق بالأرقام: قياس PlanetScale

الفرق هنا ليس نظرياً؛ منشور هندسي قاس الحالتين مباشرة: استعلام JOIN واحد قرأ 834 صفاً وأرجع 815 صفاً في 14 ميلي ثانية، بينما النسخة المكافئة بمشكلة N+1 قرأت 13,889 صفاً لتنتج النتيجة نفسها في 42 ميلي ثانية إجمالاً. في مثال ثانٍ لـ800 عنصر موزعة على 17 فئة، استغرقت نسخة N+1 (18 استعلاماً) أكثر من ثانية كاملة، مقابل نحو 0.16 ثانية لاستعلام JOIN واحد فقط.

كيف تمنع رجوعها عبر اختبارات CI

الاكتشاف اليدوي لا يكفي لمنع الرجوع؛ الحل تثبيت عدد الاستعلامات في اختبار يفشل البناء عند تجاوزه. في Django تستخدم assertNumQueries، ضمن TransactionTestCase أو مباشرة كمدير سياق. في Rails منذ الإصدار 7.2 توجد assert_queries_count ضمن ActiveRecord::Assertions::QueryAssertions. في Laravel الأداة أصرم: Model::preventLazyLoading() متاحة منذ الإصدار 8.43.0 في مايو 2021، وترفع LazyLoadingViolationException فوراً عند أي تحميل كسول غير مقصود، فتفشل الاختبارات بدل أن يُكتشف الخلل في الإنتاج. ربط هذه الاختبارات بخط أنابيب تلقائي يشبه ما شرحه دليل إعداد GitHub Actions لمشاريع بايثون، حيث يمنع الدمج إذا فشل أي اختبار من هذه.

القرار العملي: أي حل تختار حسب إطارك

الإطارأداة الاكتشافحل التحميل المسبقاختبار منع الرجوع
DjangoDjango Debug Toolbar، nplusoneselect_related لعلاقات ForeignKey/OneToOne، prefetch_related لـManyToMany والمعكوسةassertNumQueries
LaravelLaravel Debugbar، Telescopewith()، أو automaticallyEagerLoadRelationships() منذ 12.8.0Model::preventLazyLoading() منذ 8.43.0
RailsBullet، أو Prosopite لتجنّب النتائج الكاذبةincludes/preload لاستعلامين، eager_load لاستعلام واحد مع إمكانية الفلترةassert_queries_count منذ Rails 7.2

إن كنت على JPA أو Hibernate، فالحل JOIN FETCH مباشرة، بشرط الانتباه لتحذير الترقيم في الذاكرة وتجنّب دمج علاقتين جماعيتين في نفس الاستعلام. وإن كانت المشكلة في طبقة GraphQL فوق قاعدة بيانات محسّنة أصلاً، فلن يحلّها تعديل الاستعلامات؛ الحل DataLoader لتجميع نداءات الـresolver نفسها.

قبل دمج أي طلب سحب يلمس علاقات بين الجداول، شغّل عداد الاستعلامات داخل الاختبار لا في العين المجردة؛ العين تُخطئ العدّ بعد السجل العاشر، والاختبار لا يخطئ أبداً.