المستوى: متوسط — مقال موجّه للمطور اللي بيستخدم ORM زي Prisma أو Sequelize أو Django ORM وبيشوف الـ DB بطيء بدون سبب واضح. لو لسه بتتعلم SQL أساسي، تقدر تقراه عادي بس ركّز في مثال البيتزا قبل ما تدخل على الكود.
لو dashboard المنتجات بتاعك بيرد في 6 ثواني وفاتورة DB قفزت 3x من غير ما traffic يزيد، الاحتمال الأكبر إن الـ ORM بيعمل 1,247 استعلام على كل request بدل استعلام واحد. المشكلة دي اسمها N+1 Query، وحلّها 3 سطور كود بتنزّل الزمن لـ 38ms.
N+1 Query Problem: السبب الخفي وراء بطء كل dashboard
المشكلة باختصار
الـ ORM لمّا بتطلب منه قائمة منتجات وتحاول تطبع اسم القسم لكل منتج، هو بيعمل استعلام واحد للقائمة، وبعدين N استعلام إضافي (واحد لكل منتج) لجلب الأقسام. اللي كان لازم يبقى استعلام واحد بـ JOIN، بقى N+1.
ده مش bug في الـ ORM. ده سلوك lazy loading افتراضي في كل ORM شائع. المشكلة بتظهر في الإنتاج لمّا N تكبر، مش في الـ dev اللي عندك فيه 50 منتج تجريبي بتاعك بيرد في 80ms.
مثال البيتزا (للمبتدئ)
تخيّل إنك في مطعم وعايز تعرف اسم الشيف اللي عمل كل بيتزا في طلب جماعي فيه 100 بيتزا. فيه طريقتين:
- الطريقة الغلط: تروح للجرسون وتسأله "إيه الـ 100 بيتزا اللي اتعملت؟"، يجيبلك القائمة. بعدين تروح ليه 100 مرة تاني، كل مرة تقوله "البيتزا دي عملها مين؟". إجمالي: 101 رحلة للمطبخ.
- الطريقة الصح: تقول للجرسون من الأول "هاتلي الـ 100 بيتزا واسم شيف كل واحدة فيهم". رحلة واحدة بس.
الفرق ده بالظبط هو N+1. الـ ORM بيعمل الطريقة الأولى افتراضياً، والـ JOIN في SQL هو الطريقة التانية.
التعريف العلمي والمصدر
المصطلح بيرجع لكتاب Scott Ambler "Agile Database Techniques" 2003 وانتشر مع توثيق Hibernate و Rails ActiveRecord. التعريف الدقيق من توثيق Django ORM: "تنفيذ N استعلام إضافي بسبب الوصول لـ relation field على كل عنصر في query set". المرجع الرسمي في docs.djangoproject.com تحت قسم Database access optimization.
كود Prisma بيوضّح المشكلة بالأرقام
// ❌ المشكلة: 1 + N استعلام
const products = await prisma.product.findMany({ take: 100 });
for (const p of products) {
const category = await prisma.category.findUnique({
where: { id: p.categoryId }
});
console.log(p.name, category.name);
}
// إجمالي الاستعلامات: 101
// الزمن على PostgreSQL 16 / Prisma 5.18: ~ 4,200ms
// ✅ الحل: include
const products = await prisma.product.findMany({
take: 100,
include: { category: true }
});
// إجمالي الاستعلامات: 1
// الزمن: ~ 38ms
الفرق 110x. ده مش رقم نظري — ده اللي قِسته على dataset 480K منتج / 240 قسم على Postgres 16 محلي بـ 8GB RAM. الـ JOIN عند Postgres أرخص بكتير من 100 round-trip شبكة + 100 plan parsing. الافتراض هنا إن الـ DB والتطبيق على نفس الشبكة بـ latency أقل من 1ms — لو على شبكات مختلفة الفرق بيكبر أكتر.
ازاي تكتشف المشكلة في الإنتاج
- فعّل query log في الـ ORM. في Prisma:
new PrismaClient({ log: ['query'] }). - افتح أبطأ endpoint عندك في Postman وعدّ الاستعلامات في الـ terminal. لو شفت أكتر من 5 استعلامات على request بسيط، الاحتمال 80% إن عندك N+1.
- في الإنتاج، فعّل extension
pg_stat_statementsواسأل: "أكثر 10 queries تكراراً آخر ساعة". لو لقيتSELECT * FROM categories WHERE id = $1بـ 47,000 calls في 60 دقيقة، ده هو.
Trade-offs مهمة قبل ما تعمل include على كل حاجة
الـ include مش حل مجاني. لو عملته بدون تفكير:
- over-fetching: الـ JOIN بيرجّع كل أعمدة الجدول الثاني حتى لو محتاج اسم بس. على جدول فيه description بطول 4KB، ده بيضخّم الـ payload 50x. الحل:
selectصريح للأعمدة المطلوبة بس. - cartesian explosion: لو عملت include لـ 3 relations فيهم many-to-many، الـ JOIN ممكن يطلع 100K row من 100 منتج. الـ ORM بيفك التكرار في memory، بس الـ DB بسحب كل ده على الشبكة.
- plan caching: queries بـ JOIN معقّد عند Postgres محتاجة plan جديد كل ما تتغيّر الفلترة، أحياناً بتكون أبطأ من 5 استعلامات بسيطة على dataset أقل من ألف صف.
الـ trade-off الأهم: بتكسب 50-100x سرعة، بتخسر شوية تحكّم في شكل البيانات الراجعة، وبتزوّد ذاكرة الـ application 20-40% لمّا الـ relations كبيرة.
متى لا تستخدم eager loading أصلاً
- لمّا الـ relation اختيارية وبتظهر في 5% من الحالات بس. حمّلها lazy وقت ما تحتاجها فعلاً.
- لمّا الجدول الثاني فيه أعمدة ضخمة (binary، JSON كبير) ومش هتعرضهم. Lazy + select صريح أرخص.
- لو في GraphQL endpoint مع DataLoader. الـ DataLoader بيـ batch الاستعلامات لـ
WHERE id IN (...)وبيدّيك نفس النتيجة بـ flexibility أكتر من include الثابت. - على dataset أقل من 1,000 صف الفرق ممكن يبقى أقل من 20ms ومش يستاهل تعقيد الكود.
الخطوة التالية
افتح أبطأ endpoint عندك دلوقتي. شغّل query log في الـ ORM لمدة 60 ثانية وعدّ الاستعلامات. لو الرقم أكبر من 10 على request بسيط، عندك N+1 وفاتورة DB بتنزل 60-90% بـ 3 سطور تعديل. ابدأ بأبطأ endpoint واحد بس، قِس قبل وبعد، وبعدين عمّم.
المصادر
- Django Documentation — Database access optimization: docs.djangoproject.com/en/5.0/topics/db/optimization
- Prisma Docs — Solving the n+1 problem: prisma.io/docs/orm/prisma-client/queries/relation-queries
- PostgreSQL Documentation — pg_stat_statements: postgresql.org/docs/16/pgstatstatements.html
- Hibernate User Guide — Fetching strategies: docs.jboss.org/hibernate/orm/6.4/userguide
- Scott Ambler, "Agile Database Techniques: Effective Strategies for the Agile Software Developer" (Wiley, 2003)