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

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

في الكود سطر واحد داخل حلقة؛ وفي سجل قاعدة البيانات 101 استعلام. هذا هو شكل مشكلة N+1: استعلام يجلب N سجلاً، يتبعه استعلام مستقل لكل سجل يلمس علاقته التابعة، والإجمالي يتبع حجم البيانات لا حجم الكود. لهذا تجتاز الشيفرة اختباراتها على عشرة صفوف تجريبية ثم تختنق الصفحة نفسها فوق جدول العملاء الحقيقي، ولا تُكتشف العلة إلا وقد انتشر النمط في الكود كله. هنا خريطة الاكتشاف والعلاج في ستة أطر، ثم سؤال يُهمَل عادة: متى يتفوق استعلامان منفصلان على JOIN واحد، ومتى تكون N+1 نفسها مقبولة؟

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

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

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

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

from django.db.models import Prefetch

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

ما لا تراه في Django: defer() والقوالب وواجهات DRF

توثيق Django REST Framework ينصّ صراحة على أن الإطار لا يحسّن استعلامات المُسلسِلات تلقائياً، فالإصلاح مكانه get_queryset في الـViewSet لا المُسلسِل نفسه، وحقول SerializerMethodField نقطة عمياء حتى لأدوات التحميل التلقائي. فخّ ثانٍ موثّق: كل حقل مؤجَّل بـdefer() أو only() يُجلب وحده عند أول وصول إليه — أي N+1 خفية داخل أي حلقة تلمس حقلاً مؤجلاً. والقوالب تخفي النداءات لأنها تستدعي الدوال بلا أقواس، والحل الموثّق وسم {% with %} لإعادة استخدام النتيجة المخزّنة، مع تحسين صغير: entry.blog_id يقرأ عموداً محلياً بينما entry.blog.id يحمّل الكائن كاملاً.

Laravel: سطر with() أو تحميل تلقائي شامل منذ 12.8.0

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

$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 لا يقبل عليه أي شرط إطلاقاً.

SQLAlchemy: selectinload للمجموعات وjoinedload لعلاقة الواحد

قاعدة تستحق الحفظ: تغيير استراتيجية التحميل لا يغيّر النتائج أبداً، بل عدد جمل SQL فقط. joinedload() يصدر LEFT OUTER JOIN بأسماء مستعارة لا يستطيع الاستعلام الأصلي الفلترة أو الترتيب عليها، ومع علاقة جماعية يكرّر صفوف الأب حتى إن التوثيق يوجب استدعاء Result.unique() وإلا رمى خطأً. أما selectinload() فيصدر استعلاماً ثانياً بشرط IN على المفاتيح الأساسية للآباء، بحد أقصى 500 مفتاح لكل جملة SELECT. التوجيه الرسمي: selectinload الأفضل عموماً للمجموعات، وjoinedload لعلاقة الابن نحو أب واحد، وsubqueryload موسوم رسمياً أسلوباً قديماً حلّ محله selectinload.

from sqlalchemy import select
from sqlalchemy.orm import selectinload

stmt = select(Author).options(selectinload(Author.books))

JPA وHibernate: عيبا JOIN FETCH ومخرج @BatchSize

نعود إلى رقم الافتتاحية: 100 كائن Author بلا fetch join يكلّف 101 استعلام. دمج العلاقة عبر JOIN FETCH في JPQL يقلّصها إلى استعلام واحد:

SELECT a FROM Author a JOIN FETCH a.books

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

المخرج العملي الثالث هو الجلب المجمّع: @BatchSize(size = N) يجعل Hibernate يحمّل حتى N من الكائنات غير المهيأة في استعلام واحد بقائمة IN، ويعمّمه hibernate.default_batch_fetch_size على التطبيق كله. N+1 تصبح تقريباً N/M+1، فمئة مؤلف بحجم دفعة 25 تساوي 5 استعلامات بدل 101. ولأن الجلب يجري باستعلامات لاحقة منفصلة، يحتفظ الاستعلام الأصلي بترقيمه في SQL فلا HHH000104، ولا ضمّ لمجموعتين فلا MultipleBagFetchException. وعند رفع حجم الدفعة انتبه: أوراكل حتى 21c تقف عند 1000 عنصر في IN برسالة ORA-01795 ورفعها 23ai إلى 65,535، وMySQL لا يحدّها إلا max_allowed_packet. ولمستخدم Spring Data JPA بديل لا يمسّ الكيانات: @EntityGraph(attributePaths = { "books" }) على دالة المستودع.

GraphQL: المشكلة في الـresolver والحل DataLoader

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

استعلامان ضد JOIN واحد: الأرقام ثم الاستثناءات

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

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

ومتى تكون N+1 مقبولة؟ حين يكون N صغيراً فعلاً أو الصفوف من تخزين مؤقت فعال. ولمؤسس Rails رأي معروف — مشروط لا قاعدة: مع التخزين المؤقت المتداخل، قد تتفوق استعلامات بسيطة كثيرة تصيبه على JOIN معقّد يضرب قاعدة البيانات كل مرة.

حتى العدّ يولّد استعلامات: count وlength وsize

في Rails، count يصدر SQL COUNT دائماً ولو كانت المجموعة محمّلة، وlength يحمّل كل السجلات ليعدّها، وsize وحده يختار المحمّلة إن وُجدت وإلا COUNT. وفي Django، count() وexists() أوفر من len() على استعلام لم يُقيَّم بعد، لكن استدعاءهما على استعلام قُيِّم فعلاً يضيف استعلامات بلا داع.

الوقاية سياسةً: أوضاع صارمة واختبارات تُفشل البناء

في SQLAlchemy، raiseload() أو lazy="raise" يرمي استثناءً عند أي تحميل كسول، وlazy="raise_on_sql" أدقّ: لا يرمي إلا عند تنفيذ SQL فعلي، فلا يعترض على كائن موجود في ذاكرة الجلسة. وتحفّظه الموثّق الوحيد: لا يسري أثناء دفع وحدة العمل تغييراتها إلى قاعدة البيانات.

في Rails، strict_loading موجود منذ 6.1 على أربعة مستويات: الاستعلام User.strict_loading.first، والسجل user.strict_loading!، والعلاقة has_many :books, strict_loading: true، والتطبيق كله config.active_record.strict_loading_by_default = true. أي انتهاك يرمي ActiveRecord::StrictLoadingViolationError، وضبط action_on_strict_loading_violation = :log يحوّله إلى تسجيل فقط لتبنّي القاعدة تدريجياً. وأضاف Rails 7.0 الوضع mode: :n_plus_one_only الذي يسمح بالتحميل الكسول المفرد ويرمي فقط عند نمط N+1 حقيقي داخل حلقة.

مقابل هذه الدرجات، يقدّم Laravel مفتاحاً واحداً على مستوى التطبيق: Model::preventLazyLoading() منذ 8.43.0 في مايو 2021، يرمي LazyLoadingViolationException عند أي تحميل كسول غير مقصود، والتوصية الرسمية تفعيله خارج الإنتاج فقط داخل AppServiceProvider، مع Model::handleLazyLoadingViolationUsing() للتسجيل بدل الرمي. أما Django فبلا وضع صارم مدمج، والبديل الخارجي مكتبة django-seal التي توفر .seal() فترفع تحذيرات UnsealedAttributeAccess القابلة للترقية إلى أخطاء في الاختبارات.

الاكتشاف اليدوي وحده لا يمنع عودة المشكلة؛ ثبّت عدد الاستعلامات في اختبار يُفشل البناء عند تجاوزه: assertNumQueries في Django ضمن TransactionTestCase أو كمدير سياق، وassert_queries_count في Rails منذ 7.2 ضمن ActiveRecord::Assertions::QueryAssertions. وتجعلها GitLab سياسة هندسية صريحة: طلب الدمج ممنوع من زيادة عدد الاستعلامات، وتقيسه أداة QueryRecorder مع skip_cached: false لأن التخزين المؤقت قد يخفي N+1 قائمة. ربط هذه الاختبارات بخط تكامل مستمر يشبه ما شرحه دليل إعداد GitHub Actions لمشاريع بايثون، حيث يُمنع الدمج إذا فشل أي اختبار منها.

جدول الاختيار: علاج ووقاية لكل إطار

الإطارالاكتشافالعلاجالوقاية الصارمة
DjangoDebug Toolbar، nplusoneselect_related للضم، prefetch_related للمجموعاتdjango-seal خارجياً + assertNumQueries
LaravelDebugbar، Telescopewith()، أو automaticallyEagerLoadRelationships() منذ 12.8.0preventLazyLoading() منذ 8.43.0
RailsBullet أو Prosopiteincludes/preload لاستعلامين، eager_load للفلترة على التابعstrict_loading منذ 6.1 + assert_queries_count منذ 7.2
SQLAlchemynplusoneselectinload للمجموعات، joinedload لعلاقة الواحدraiseload أو lazy=“raise”
JPA وHibernateمراقبة سجل SQLJOIN FETCH، أو @BatchSize، أو @EntityGraph في Spring Data—
GraphQLتكرار نداءات المصدرDataLoader لتجميع النداءاتتخزين مؤقت لكل طلب

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