لو الـ API بتاعك بياخد 218ms في endpoint بسيط بيرجّع قائمة بـ 50 منتج، المشكلة غالبًا مش في السيرفر ولا الـ DB. المشكلة اسمها N+1 query problem، والحل أمر واحد بيوفّر 90% من الوقت.
N+1 Query Problem: المشكلة الصامتة في كل ORM
المشكلة باختصار
لما تجيب 50 منتج من قاعدة البيانات، وكل منتج يطلب صنفه (category) في query منفصل، أنت بتعمل 51 query بدل query واحد. ده اسمه N+1: استعلام أساسي + N استعلام فرعي.
الـ ORMs (Sequelize، Prisma، Django ORM، ActiveRecord) بتخفي ده عنك. الكود بيبان نظيف وبسيط، لكن في الخلفية فيه طوفان queries بيضرب الـ DB ويأكل الـ latency.
مثال للمبتدئين: تخيّل إنك في مكتبة
عندك قائمة بـ 50 كتاب، وعايز تعرف اسم المؤلف لكل واحد. الطريقة الغلط: تروح لأمين المكتبة، تطلب الكتاب الأول، يفتح حاسوبه، يجيبلك اسم المؤلف، ترجع لمكتبك، تطلب الكتاب التاني، وهكذا. بتعمل 50 رحلة ذهاب وإياب.
الطريقة الصح: تقول للأمين "هاتلي الـ 50 كتاب بأسامي مؤلفينهم مرة واحدة". رحلة واحدة، نتيجة كاملة.
ده بالظبط الفرق بين N+1 query وeager loading. كل round trip للـ DB تكلفته شبكة + parsing + execution، فلما تعملها 51 مرة بدل مرة، الفرق بيوصل لأكتر من ضعف ونص.
التعريف العلمي الدقيق
N+1 query problem هو نمط استعلام ينتج عن استعلام أساسي يُرجع N سجل، يتبعه تنفيذ N استعلام إضافي لجلب بيانات مرتبطة لكل سجل على حدة. النتيجة الكلية N+1 round trip للـ database بدل round trip واحد. كل round trip بيضيف latency شبكي + parsing overhead + execution، حتى لو كل query منهم بسيط وسريع منفردًا.
مثال كود حقيقي بالأرقام
ده كود Sequelize بيعاني من N+1 بشكل كلاسيكي:
// 51 query: 1 للمنتجات + 50 للأصناف
const products = await Product.findAll({ limit: 50 });
for (const p of products) {
const category = await p.getCategory(); // كل iteration = query جديد
console.log(p.name, category.name);
}الـ profiling على PostgreSQL محلي مع جدولين فيهم index على foreign key:
- عدد queries: 51
- زمن إجمالي: 218ms
- زمن round trips الشبكي: 165ms
- P95 على staging مع latency 2ms للـ DB: 410ms
الحل: eager loading عبر include بيخلي الـ ORM يبني JOIN واحد:
// query واحد بـ JOIN
const products = await Product.findAll({
limit: 50,
include: [{ model: Category }]
});
for (const p of products) {
console.log(p.name, p.Category.name);
}بعد التعديل على نفس البيانات ونفس الجهاز:
- عدد queries: 1
- زمن إجمالي: 22ms
- تحسّن: حوالي 90%
- الأرقام دي تقديرية لبيئة محلية، الفرق على بيئة prod مع latency أعلى بيكون أكبر.
نفس الفكرة في Prisma
// قبل: N+1
const products = await prisma.product.findMany({ take: 50 });
for (const p of products) {
const cat = await prisma.category.findUnique({ where: { id: p.categoryId } });
}
// بعد: include
const products = await prisma.product.findMany({
take: 50,
include: { category: true },
});في Django ORM الأمر اسمه select_related للـ foreign key وprefetch_related للـ many-to-many. في ActiveRecord الأمر includes.
ازاي تكتشف N+1 في كودك
- فعّل log الـ queries في الـ ORM. في Sequelize:
{ logging: console.log }في الـ config. - افتح endpoint واحد، عدّ الـ queries في الـ console.
- لو endpoint بسيط دخل أكتر من 5 queries، فيه N+1 على الأرجح.
- على PostgreSQL استخدم
pg_stat_statementsتشوف الـ queries المتكررة بنفس الـ shape بأرقام مختلفة. - أدوات APM زي New Relic أو Datadog بتظهرلك "N+1 detected" مباشرة لو مفعّلة.
الـ trade-offs اللي لازم تعرفها
eager loading مش مجاني. بتكسب round trips أقل، بتخسر:
- حجم نتيجة أكبر في الـ query الواحد، خصوصًا لو الـ JOIN على one-to-many (الصفوف بتتكرّر جزئيًا).
- memory أعلى على السيرفر لو الـ relation فيها أعمدة كبيرة (TEXT، JSON).
- complexity في الـ query plan لو الـ JOIN بقى على 4+ جداول. ممكن الـ planner يختار خطة أسوأ.
الافتراض هنا: N صغير نسبيًا (≤ 1000 سجل في الـ batch)، وعندك indexes صح على الـ foreign keys. لو N = 100K، الحل مش include، الحل pagination + cursor + dataloader pattern.
متى لا تستخدم eager loading
مش كل N+1 لازم يتحل، وفيه حالات الـ include بيضرّ أكتر ما ينفع:
- الـ relation بتُستخدم في 10% من الـ requests فقط: lazy أوفر للذاكرة وللـ DB.
- الجدول المرتبط فيه أعمدة TEXT/JSON كبيرة ومش هتعرضها كلها: استخدم
attributesأوselectلاختيار أعمدة بعينها بدل eager loading كامل. - عندك caching layer قوي قدام الـ DB والـ N+1 كله بيضرب الـ cache في O(1).
- الـ relation polymorphic (سجل ممكن يرتبط بأكتر من جدول): JOIN ساعتها معقد، dataloader أنضف.
الخطوة التالية
افتح أبطأ endpoint عندك دلوقتي، فعّل query logging على staging لمدة 5 دقائق، وعدّ كم query بيتنفّذ في request واحد. لو الرقم أكبر من 10، عندك N+1 شغّال. ابدأ بأكبر استعلام لـ list endpoint وطبّق include أو select_related حسب الـ ORM بتاعك، وقيس قبل وبعد. لو الفرق أقل من 30% راجع الـ indexes على الـ foreign key قبل ما تكمل تحسينات تانية.
المصادر
- Sequelize Eager Loading: sequelize.org/docs/v6/advanced-association-concepts/eager-loading
- Prisma Query Optimization & N+1: prisma.io/docs/orm/prisma-client/queries/query-optimization-performance
- Django select_related & prefetch_related: docs.djangoproject.com/en/stable/ref/models/querysets
- Active Record Eager Loading Guide: guides.rubyonrails.org/active_record_querying
- PostgreSQL pg_stat_statements: postgresql.org/docs/current/pgstatstatements.html
- DataLoader pattern (GraphQL Foundation): github.com/graphql/dataloader