لو الـ 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 واحد: