لو الـ GraphQL endpoint عندك بيرجّع قائمة 100 منتج، وكل منتج له category و author، ممكن تلاقي السيرفر بيضرب 201 SQL query في request واحد. ده مش بطء عشوائي، ده نمط اسمه N+1، وله حل اتفق عليه فريق Facebook نفسه اللي صمّم GraphQL: DataLoader. المقال ده بيوريك إزاي تنزل من 1200 query لـ 8 في أقل من 30 سطر كود.
N+1 في GraphQL: ليه السيرفر بيموت في query بسيط
المشكلة باختصار
في GraphQL، كل field في الـ schema بيتنفّذ بـ resolver خاص بيه. لو عندك query بترجّع products وكل منتج فيه category، الـ resolver بتاع category بيتنادى مرة لكل منتج. النتيجة: query واحد للقائمة + N استعلام لكل علاقة. ده بالظبط N+1.
الـ trade-off اللي بنشتري بيه مرونة GraphQL هو إن الـ runtime مش شايف الـ query الكاملة زي SQL، فبيشتغل بـ resolver-per-field. لو سبت ده من غير batching، أي لستة بمئة عنصر بتتحوّل لقنبلة استعلامات.
مثال يوضّح N+1 (للمبتدئ)
تخيّل إنك في كافيتيريا. طلبت 100 ساندوتش، وبدل ما الويتر يدخل المطبخ مرة واحدة بقائمة الـ 100، هو بيدخل 100 مرة، كل مرة يقول "ساندوتش واحد لو سمحت". المطبخ شغّال، لكن الويتر هو الـ bottleneck. DataLoader هو الويتر اللي بيقفل ورق الطلبات الـ 100 ويدخل المطبخ مرة واحدة.
تطبيق ده على الكود: من غير DataLoader، 100 منتج بـ category مختلفة بيتحوّلوا لـ 100 استعلام منفصل على جدول categories. مع DataLoader، الـ 100 طلب بيتجمّعوا في SELECT * FROM categories WHERE id IN (1,2,3,...) واحد.
التعريف العلمي بدقة
N+1 query هي حالة بترجع فيها قائمة بطول N، وبعدين بتعمل استعلام إضافي لكل عنصر علشان تجيب علاقة مرتبطة، فينتج عندك N+1 round trip على الـ database. التكلفة الحقيقية مش في وقت تنفيذ الاستعلام، التكلفة في زمن الشبكة (network round-trip latency) ومحدودية الـ connection pool.
DataLoader بيشتغل بـ request-scoped batching + caching. كل resolver بيستدعي loader.load(id)، الـ DataLoader بيجمع كل الـ ids اللي اتطلبت في نفس الـ event loop tick، وبيناديك بـ batchFn واحد بيرجّع كل النتائج معًا. مع كاش داخلي بنفس الـ request علشان لو نفس الـ id اتطلب مرتين، يرجع من الذاكرة.
الحل: كود قابل للنسخ بـ Node.js و Apollo Server
// loaders/categoryLoader.js
import DataLoader from "dataloader";
import { db } from "../db.js";
export function createCategoryLoader() {
return new DataLoader(async (categoryIds) => {
const rows = await db.query(
"SELECT id, name, slug FROM categories WHERE id = ANY($1)",
[categoryIds]
);
const byId = new Map(rows.map((r) => [r.id, r]));
return categoryIds.map((id) => byId.get(id) ?? null);
});
}