المستوى: متوسط
لو endpoint بيرجع قائمة 1000 طلبية مع اسم العميل لكل واحدة وبياخد 8 ثواني والـ DB CPU عالي، المشكلة غالبًا مش في الـ index ولا حجم البيانات. المشكلة إن الـ ORM بيعمل 1001 طلب SQL بدل اتنين، وكل طلب بيدفع round-trip كامل عبر الشبكة. المقال ده بيوريك إزاي تكتشف المشكلة، تقيسها بأرقام، وتحلها بسطر واحد في معظم الأحيان.
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
# الطريقة الغلط - 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 (...)