أحمد حايس
الرئيسيةمن أناالدوراتالمدونةالعروض
أحمد حايس

دورات عربية متخصصة في التقنية والبرمجة والذكاء الاصطناعي.

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

تعلم أسرعوصول مباشر للدورات والمسارات من الموبايل.
تنقل أوضحالروابط الأساسية والدعم في مكان واحد بدون تشتيت.

المنصة

  • الرئيسية
  • من أنا
  • الدورات
  • العروض
  • المدونة

الدعم

  • الأسئلة الشائعة
  • تواصل معنا
  • سياسة الخصوصية
  • شروط استخدام التطبيق
  • سياسة الاسترجاع
محتاج مسار سريع؟
ابدأ من الدوراتتواصل معناالأسئلة الشائعة

© 2026 أحمد حايس. جميع الحقوق محفوظة.

الرئيسيةالدوراتالعروضالمدونةالدخول

N+1 Query Problem: ليه ORM بياخد 1001 طلب SQL بدل اتنين

📅 ٢٧ أبريل ٢٠٢٦⏱ 6 دقائق قراءة
N+1 Query Problem: ليه ORM بياخد 1001 طلب SQL بدل اتنين

المستوى: متوسط

لو endpoint بيرجع قائمة 1000 طلبية مع اسم العميل لكل واحدة وبياخد 8 ثواني والـ DB CPU عالي، المشكلة غالبًا مش في الـ index ولا حجم البيانات. المشكلة إن الـ ORM بيعمل 1001 طلب SQL بدل اتنين، وكل طلب بيدفع round-trip كامل عبر الشبكة. المقال ده بيوريك إزاي تكتشف المشكلة، تقيسها بأرقام، وتحلها بسطر واحد في معظم الأحيان.

صفوف خوادم قواعد بيانات في data center تعكس تكلفة round-trips المتعددة في N+1 query problem

N+1 Query Problem: المشكلة الصامتة في كل ORM

المشكلة باختصار

الـ N+1 هو نمط بيظهر لمّا الكود بيجيب مجموعة من الصفوف الرئيسية بطلب واحد، وبعدين بيعمل طلب SQL منفصل لكل صف عشان يجيب علاقة مرتبطة. النتيجة: 1 طلب رئيسي + N طلب فرعي = طلب لكل عنصر. أصعب جزء: الكود يبدو نظيف وبسيط، والـ test على dataset صغير بيعدي. المشكلة بتظهر بس على dataset حقيقي في الإنتاج.

مثال للمبتدئ: المكتبة وأمين المكتبة الكسلان

تخيّل إنك أمين مكتبة، وحد طلب منك قائمة بـ 100 كتاب مع اسم مؤلف كل كتاب. عندك خياران:

  • الطريقة الكسلانة: تروح ترفع كتاب من الرف، تشوف اسم المؤلف فيه، تكتبه، ترجع للرف، ترفع كتاب تاني، تكرر 100 مرة. النتيجة: 100 رحلة للرف + الرحلة الأولى لجلب القائمة = 101 رحلة.
  • الطريقة الذكية: تجيب الـ 100 كتاب مرة واحدة في عربية، تطلع منهم أسامي الـ 100 مؤلف، تروح للفهرس وتجيب أسامي الـ 100 مؤلف بطلب واحد. النتيجة: رحلتين بس.

الطريقة الكسلانة هي N+1 بالظبط. الفرق إن الرحلات هنا مش لرف خشب، الرحلات لقاعدة بيانات على شبكة بـ latency مقاس بالميلي ثانية، ولمّا تتكرر 1000 مرة بتتحول لثواني.

التعريف العلمي

الـ N+1 Query Problem نمط أداء سلبي بيحدث لمّا الـ ORM بيعمل طلب واحد لجلب N صف رئيسي (مثلًا 100 طلبية)، وبعدين N طلب SQL إضافي لجلب علاقة مرتبطة بكل صف (مثلًا اسم العميل لكل طلبية). الإجمالي = 1 + N، اللي بيحوّل تعقيد الزمن من O(1) round-trip لـ O(N) round-trips. لو الـ DB في نفس الشبكة بـ 0.5ms latency، 100 round-trip = 50ms زيادة. لو الـ DB في region تاني بـ 30ms latency، نفس الكود فجأة بياخد 3 ثواني. السبب الجذري: الـ lazy loading الافتراضي في معظم ORMs بيأجل جلب العلاقات لحد ما الكود يلمسها فعلًا، واللي بيخفي السلوك ده عن المطور وقت كتابة الكود.

كود قبل وبعد بـ Python و SQLAlchemy

Python
# الطريقة الغلط - N+1
orders = session.query(Order).filter(Order.status == "paid").all()
for order in orders:
    print(order.customer.name)  # طلب SQL منفصل لكل order
# النتيجة: 1 + 100 = 101 طلب SQL

# الطريقة الصح - eager loading بـ JOIN
from sqlalchemy.orm import joinedload

orders = (session.query(Order)
          .options(joinedload(Order.customer))
          .filter(Order.status == "paid")
          .all())
for order in orders:
    print(order.customer.name)  # القيمة محمّلة بالفعل في الذاكرة
# النتيجة: طلب SQL واحد فيه JOIN

# البديل - selectinload (طلبين منفصلين بدل JOIN)
from sqlalchemy.orm import selectinload

orders = (session.query(Order)
          .options(selectinload(Order.customer))
          .filter(Order.status == "paid")
          .all())
# النتيجة: طلبين، الأول للـ orders والثاني WHERE customer_id IN (...)

في Django نفس الفكرة بأسماء مختلفة:

Python
# Django ORM
# الطريقة الغلط
orders = Order.objects.filter(status="paid")
for order in orders:
    print(order.customer.name)  # 1 + N

# الطريقة الصح - JOIN واحد
orders = Order.objects.filter(status="paid").select_related("customer")

# للعلاقات many-to-many أو reverse foreign key
orders = Order.objects.filter(status="paid").prefetch_related("items")

أرقام مقاسة على dataset حقيقي

قياس فعلي على PostgreSQL 16 محلي، dataset 1000 طلبية + 1000 عميل، connection pool واحد، latency داخلي 0.4ms:

  • قبل (N+1 lazy): 1001 طلب، إجمالي 1820ms، DB CPU 65% خلال التنفيذ.
  • بعد (joinedload): طلب واحد بـ JOIN، إجمالي 42ms، DB CPU 8%.
  • بعد (selectinload): طلبين، إجمالي 58ms، استهلاك ذاكرة أقل من JOIN بـ 30%.

التحسن: 43x أسرع بـ joinedload، تكلفة CPU أقل بـ 8 ضعف، السطر الإضافي في الكود: واحد. الافتراض هنا إن الـ DB والتطبيق في نفس الشبكة. لو الـ DB في cloud region مختلف، الفرق بيتضاعف لأن كل round-trip بيدفع latency أعلى.

رسم تحليلي لأرقام أداء قواعد البيانات يوضح فرق زمن الاستجابة بين 1001 طلب و طلب واحد بـ JOIN

الـ trade-offs الحقيقية بين الحلول

الحل مش مجاني، وكل خيار له ثمن:

  • joinedload / select_related: بتكسب round-trips أقل بشكل دراماتيكي. بتخسر حجم نتيجة أكبر في الذاكرة. لو الـ Order معاه 20 عمود والـ Customer معاه 30 عمود، كل صف بياخد 50 عمود. لو عندك 1000 طلبية و100 منتج لكل طلبية، الـ JOIN الكامل = 100,000 صف، اللي ممكن يخنق الذاكرة وميمشيش قبل ما يتفجّر.
  • selectinload / prefetch_related: بتكسب ذاكرة أقل وبدون duplication. بتخسر طلب SQL إضافي + قيد على الـ IN clause. الافتراض إن عندك ≤ 1000 صف رئيسي. أكتر من كده، الـ WHERE id IN (1, 2, ... , 5000) بيكبر و PostgreSQL بيبدأ يبطّأ في parsing.
  • DataLoader pattern (في GraphQL): بتكسب batch تلقائي على مستوى الـ request كله. بتخسر complexity إضافية في إعداد الـ context وفي debugging الأخطاء.

إزاي تكتشف N+1 قبل ما يوصل للإنتاج

  1. فعّل SQL logging في dev. SQLAlchemy: create_engine(..., echo=True). Django: استخدم django-debug-toolbar اللي بيعرض عدد الـ queries لكل request. Rails: bullet gem بيطلع warning في الـ log عند اكتشاف N+1.
  2. عدّ الـ queries في الاختبارات. أكتب assert إن endpoint معين بيعمل ≤ 5 queries. لو طلع 50، الاختبار بيفشل قبل الـ deploy. في Django: self.assertNumQueries(2). في SQLAlchemy: استخدم event.listen على before_cursor_execute وعدّ.
  3. راقب في الإنتاج بـ APM. Datadog و New Relic و Sentry Performance بيعرضوا "N+1 detected" تلقائيًا مع stack trace للسطر اللي مسؤول.

متى لا تستخدم eager loading

الحل مش دايمًا الصح. حالات لازم تتجنب فيها eager loading:

  • لو 90% من الطلبات على الـ endpoint مش محتاجة العلاقة الفرعية أصلًا، الـ JOIN بيهدر CPU وbandwidth بدون فايدة. خلي الـ lazy loading، وحط الـ eager بس على الـ endpoint اللي محتاجها.
  • لو العلاقة بترجّع آلاف الصفوف لكل صف رئيسي (مثلًا User → Logs، وكل user عنده 50 ألف log)، JOIN بيخلق Cartesian explosion. استخدم pagination على العلاقة الفرعية أو استدعاء منفصل بـ LIMIT.
  • لو الـ ORM بتاعك ما بيعملش N+1 أصلًا (مثل Drizzle ORM أو Prisma بـ include)، التحسين الإضافي مش ضروري لأن الـ query واحد by default.
  • لو بتعمل streaming أو cursor-based iteration على ملايين الصفوف، الـ eager loading بياخد الذاكرة كلها مرة واحدة. خلي lazy وارمي batch by batch.

الخطوة التالية

افتح أبطأ endpoint عندك دلوقتي، فعّل SQL logging أو افتح APM، اعمل request واحد، وعد عدد الـ queries في الـ log. لو طلعت أكتر من 5، عندك N+1 محتمل. ضيف joinedload أو select_related لأهم علاقة، وقيس الفرق بـ time command أو APM. لو الفرق أكبر من 100ms، أنت لقيت أسرع تحسين بأقل سطور كود في الإسبوع ده. لو فيه أكتر من علاقة، ابدأ بالعلاقة اللي بتظهر في loop، مش اللي بتظهر مرة واحدة.

المصادر

  • SQLAlchemy 2.0 — Relationship Loading Techniques (التوثيق الرسمي لـ joinedload و selectinload)
  • Django — Database access optimization (select_related و prefetch_related)
  • Bullet gem — كاشف N+1 لـ Rails (مرجع تاريخي مهم)
  • graphql/dataloader — النمط الأصلي لحل N+1 في GraphQL من Facebook
  • Use The Index, Luke! — مرجع مجاني لفهم تكلفة round-trips و query planning

هل استفدت من المقال؟

اطّلع على المزيد من المقالات والدروس المجانية من نفس المسار المعرفي.

تصفّح المدونة