تخطّى إلى المحتوى الرئيسي
استعلامات N+1 في واجهات Node.js البرمجية: كيف نكتشفها ونجمّعها باستخدام DataLoader ونفرض ميزانية للاستعلامات — ملاحظات ميدانية

استعلامات N+1 في واجهات Node.js البرمجية: كيف نكتشفها ونجمّعها باستخدام DataLoader ونفرض ميزانية للاستعلامات — ملاحظات ميدانية

October 31, 2026
هندسة الواجهات الخلفية
7 دقيقة قراءة

أهم النقاط

• استعلام N+1 هو استعلام واحد لتحميل قائمة، ثم استعلام إضافي لكل عنصر لتحميل علاقة مرتبطة به، فيزداد عدد الرحلات إلى قاعدة البيانات خطيًا مع حجم الصفحة حتى لو كان كل استعلام سريعًا بمفرده.

• نكتشف استعلامات N+1 بعدّ الاستعلامات في كل طلب، لا بالبحث عن الاستعلامات البطيئة: عدّاد لكل طلب، أو سلسلة متدرجة من فترات (spans) متطابقة في تتبّع OpenTelemetry، أو صف في pg_stat_statements بقيمة calls مرتفعة بشكل غير طبيعي.

• معظم استعلامات N+1 تُعالج باستخدام JOIN أو استعلام مجمّع واحد من نوع WHERE id IN (...) لكل علاقة، ثم دمج النتائج في الذاكرة باستخدام Map.

• استخدام Promise.all على N استعلام ليس حلًا: فهو ما زال يرسل N استعلامًا وقد يستنزف مجمّع اتصالات node-postgres الذي تكون قيمة max الافتراضية فيه 10 اتصالات.

• يجب إنشاء نسخ DataLoader لكل طلب على حدة وليس على مستوى الوحدة (module)، ويجب أن يتحقق اختبار في CI من ثبات عدد الاستعلامات مهما تغيّر حجم الصفحة.

ما هو استعلام N+1، ولماذا يمر من الاختبار المحلي؟

استعلام N+1 هو نمط للوصول إلى البيانات ينفّذ فيه الكود استعلامًا واحدًا لجلب N صفًا رئيسيًا، ثم استعلامًا إضافيًا لكل صف لجلب البيانات المرتبطة به، أي N+1 استعلامًا في المجموع. في قواعد كود Node.js التي يديرها فريقنا، ينتج هذا النمط غالبًا من await داخل حلقة for: تحميل صفحة من الطلبات، ثم البحث عن عميل كل طلب واحدًا تلو الآخر.

كل استعلام من هذه الاستعلامات رخيص. التكلفة الحقيقية في الرحلة ذهابًا وإيابًا: تحويل البيانات، وزمن الشبكة حتى قاعدة البيانات، واتصال محجوز من المجمّع في كل دورة. على حاسوب محمول مع PostgreSQL محلي وبيانات تجريبية من عشرة صفوف، تبدو نقطة النهاية فورية. أما في الإنتاج، حيث تقع قاعدة البيانات عبر الشبكة وحجم الصفحة 50، فإن المعالج نفسه يُجري 51 رحلة متتالية. يزداد زمن الاستجابة مع حجم الصفحة، ولا يظهر أي استعلام منفرد في سجل الاستعلامات البطيئة.

كيف نكتشف استعلامات N+1 قبل أن يلاحظها المستخدمون؟

نكتشف استعلامات N+1 بعدّ الاستعلامات في كل طلب، لأن العَرَض المميِّز هو عدد الاستعلامات وليس مدتها. ثلاث إشارات تكشفها:

• عدّاد استعلامات لكل طلب. خطّاف تسجيل في الـ ORM، مثل Logger في Drizzle ORM الذي تُستدعى دالته logQuery مع كل استعلام، يزيد عدّادًا مخزّنًا في AsyncLocalStorage، وهي واجهة Node.js التي تربط قيمة بسلسلة استدعاءات غير متزامنة واحدة. ويسجّل الطلب تحذيرًا عندما يتجاوز العدد حدًا معينًا.

• شكل التتبّع. في تتبّع OpenTelemetry يظهر استعلام N+1 كسلسلة متدرجة من فترات قاعدة بيانات قصيرة ومتطابقة تحت فترة HTTP واحدة.

• pg_stat_statements. هذه الإضافة في PostgreSQL تسجّل إحصاءات التنفيذ لكل استعلام بعد توحيد صيغته. استعلام بحث بالمعرّف تكون قيمة calls فيه أعلى بكثير من عدد الطلبات على نقطة النهاية التي تنفّذه، مع قيمة mean_exec_time ضئيلة، هو البصمة المميزة لـ N+1.

يكلّف العدّاد عملية زيادة واحدة لكل استعلام، لذلك نبقيه مفعّلًا في الإنتاج ونطلق التنبيهات بناءً على التحذير بدلًا من أخذ العينات.

هل نعالج N+1 باستخدام JOIN أم استعلام مجمّع أم DataLoader؟

استخدم JOIN أو التحميل المسبق (eager load) عندما تكون العلاقة معروفة وقت كتابة الاستعلام، واستعلام IN مجمّعًا عندما يكون كود الخدمة تحت سيطرتك، وDataLoader عندما تطلب مواضع استدعاء مستقلة كثيرة النوع نفسه من السجلات. مقارنتنا:

• JOIN أو التحميل المسبق في الـ ORM (with في Drizzle وinclude في Prisma): استعلام واحد، أو استعلام لكل مستوى من العلاقات. الأنسب عندما تكون العلاقة معروفة عند كتابة الاستعلام. انتبه: الـ JOIN في علاقات واحد-إلى-متعدد يكرّر أعمدة الصف الرئيسي مع كل صف فرعي.

• استعلام IN مجمّع مع دمج عبر Map: استعلامان لكل صفحة مهما كان حجمها. الأنسب لمعالجات REST وكود الخدمات. انتبه: أزل المعرّفات المكررة وتعامل مع الصفوف المفقودة والقوائم الفارغة.

• DataLoader لكل طلب: استعلام واحد لكل دفعة. الأنسب لمحلّلات GraphQL وReact Server Components ومواضع الاستدعاء المتفرقة. انتبه: عقد ترتيب النتائج، ولا تشارك نسخة واحدة بين الطلبات.

• Promise.all على N استعلام: ما زال N+1 استعلامًا لكن بشكل متزامن. ليس حلًا، لأنه قد يحجز حتى N اتصالًا من المجمّع في وقت واحد.

الاستعلام المجمّع هو خيارنا الافتراضي لمعالجات REST. نحمّل الصفحة، ونجمع معرّفات العملاء المميزة في Set، وننفّذ استعلامًا واحدًا باستخدام inArray(customers.id, ids) في Drizzle، ثم نبني Map من المعرّف إلى العميل، ونربط كل عميل بطلبه عبر byId.get(order.customerId) ?? null. إزالة التكرار مهمة لأن طلبات كثيرة قد تشترك في عميل واحد، والقيمة null الصريحة تُبقي الطلب الذي حُذف عميله في الاستجابة بدلًا من إسقاطه بصمت.

كيف نستخدم DataLoader دون تسريب البيانات بين المستخدمين؟

DataLoader أداة للتجميع والتخزين المؤقت كُتبت في الأصل لخوادم GraphQL، وهي تجمع كل استدعاءات load(key) التي تحدث في الدورة نفسها من حلقة الأحداث وتمرّر جميع المفاتيح إلى دالة تجميع واحدة. نلجأ إليها عندما تكون مواضع الاستدعاء متفرقة، مثل محلّلات GraphQL أو استدعاءات الخدمات المتداخلة، حيث يصعب تمرير قائمة المعرّفات عبر كل طبقة. واستدعاء load() لكل طلب داخل Promise.all مقبول هنا، لأن DataLoader يحوّل تلك الاستدعاءات الـ N إلى استعلام واحد.

ثلاث قواعد تحافظ على أمان DataLoader:

• أعد نتيجة واحدة لكل مفتاح وبترتيب المفاتيح. يجب أن تعيد دالة التجميع مصفوفة بطول المفاتيح التي استلمتها وبترتيبها نفسه. إعادة صفوف قاعدة البيانات كما هي، وهي تصل بترتيب عشوائي، تربط العملاء بالطلبات الخاطئة.

• أنشئ الـ loaders لكل طلب. يحفظ DataLoader كل مفتاح يحمّله في ذاكرة مؤقتة. النسخة المعرّفة على مستوى الوحدة تقدّم الصفوف المخزّنة لمستخدم ما إلى مستخدم آخر وتنمو بلا حدود، وهذا تسريب للبيانات وتسريب للذاكرة في آن واحد.

• حدّد حجم الدفعة. الدالة inArray في Drizzle تربط معاملًا لكل معرّف، وPostgreSQL يحدّ الاستعلام الواحد بـ 65,535 معاملًا مربوطًا. الخيار maxBatchSize يقسّم الدفعات الكبيرة قبل أن تبلغ هذا الحد.

هل تمنع Prisma وDrizzle استعلامات N+1 تلقائيًا؟

تمنع Prisma وDrizzle استعلامات N+1 فقط عندما تُعلن العلاقة داخل الاستعلام، لا عندما يمرّ كود التطبيق على النتائج في حلقة. واجهة الاستعلامات العلائقية في Drizzle، أي db.query.orders.findMany مع عبارة with، تُترجم إلى جملة SQL واحدة. أما include في Prisma فيجمّع كل مستوى من العلاقات في استعلام خاص به بدلًا من استعلام لكل صف. ويجمّع Prisma Client كذلك استدعاءات findUnique() الصادرة في الدورة نفسها على النموذج نفسه وبالاختيار نفسه، لكنه لا يجمّع استدعاءات findFirst() أو findMany() داخل حلقة. ولا يستطيع أي من الـ ORM رؤية ما يتجاوز await داخل حلقة for.

كيف تحدث استعلامات N+1 في React Server Components؟

تُنتج React Server Components استعلامات N+1 عندما يكون كل عنصر في القائمة مكوّنًا غير متزامن يجلب بياناته بنفسه. وضع جلب البيانات بجانب المكوّن هو جوهر Server Components، لذا نجمّع الاستعلامات تحته بدلًا من رفع كل استعلام إلى مستوى الصفحة. الدالة cache() في React تحفظ نتيجة دالة طوال طلب خادم واحد، فتزيل الاستدعاءات المتطابقة المكررة لكنها لا تجمّع الاستدعاءات ذات الوسائط المختلفة. تغليف دالة إنشاء DataLoader بـ cache() يمنح كل طلب loader خاصًا به، ويستدعي كل مكوّن getLoaders().customerById.load(id).

الاستدعاءات التي تصدرها المكوّنات الشقيقة خلال تمريرة العرض نفسها تُدمج في دفعة واحدة. أما الاستدعاء الذي يأتي بعد await غير مرتبط فيقع في دفعة لاحقة، لذا نجعل load() أول await في المكوّن.

كيف نمنع عودة استعلامات N+1؟

نمنع تراجعات N+1 باختبار ميزانية الاستعلامات: نشغّل المعالج نفسه بحجمين مختلفين للصفحة ونتحقق من تطابق عدد الاستعلامات. الحد المطلق، مثل «خمسة استعلامات كحد أقصى»، ينكسر كلما أضاف أحدهم علاقة مشروعة، بينما العدد الذي ينمو مع حجم الصفحة خطأ دائمًا.

يغلّف الاختبار كل استدعاء بـ queryStats.run مع عدّاد جديد، ويستدعي listOrders بحد 2 ثم 25 على بيانات تجريبية، ويتحقق من تساوي العددين باستخدام node:assert/strict. ويعمل بمشغّل الاختبارات المدمج في Node.js على قاعدة PostgreSQL حقيقية في CI، فيفشل أي await داخل حلقة يُعاد إدخاله وقت المراجعة، وهو الوقت الوحيد الذي يكون فيه الإصلاح رخيصًا.

الأسئلة الشائعة

س: هل Promise.all حل صالح لاستعلامات N+1؟

ج: لا. ينفّذ Promise.all الاستعلامات الـ N بالتوازي، فيقلّل زمن الاستجابة لكنه ما زال يرسل N+1 استعلامًا وقد يشغل كل اتصالات المجمّع. القيمة الافتراضية لـ max في مجمّع node-postgres هي 10، لذا قد تحرم صفحة كبيرة واحدة الطلبات الأخرى من الاتصالات.

س: هل DataLoader مخصصة لـ GraphQL فقط؟

ج: لا. DataLoader أداة تجميع عامة لأي كود Node.js فيه عمليات بحث متفرقة بالمفتاح، بما في ذلك معالجات REST وطبقات الخدمات وReact Server Components. وقد نشأت في GraphQL لأن المحلّلات هي المصدر الأكثر شيوعًا لعمليات البحث المتفرقة.

س: هل نستخدم JOIN أم استعلامًا مجمّعًا منفصلًا لعلاقات واحد-إلى-متعدد؟

ج: في علاقات واحد-إلى-متعدد ذات الصفوف الرئيسية العريضة، ينقل الاستعلام المجمّع المنفصل غالبًا بيانات أقل، لأن الـ JOIN يكرّر أعمدة الصف الرئيسي مع كل صف فرعي. أما في علاقات واحد-إلى-واحد ومتعدد-إلى-واحد فالـ JOIN عادةً أبسط ويعيد كل شيء في رحلة واحدة.

س: كم عدد الاستعلامات لكل طلب الذي يُعد كثيرًا؟

ج: لا يوجد رقم ثابت يصلح لكل نقاط النهاية. القاعدة الموثوقة هي ألا يعتمد عدد استعلامات نقطة نهاية القوائم على حجم الصفحة؛ فإذا تغيّر العدد عند مضاعفة حجم الصفحة، ففي نقطة النهاية استعلام N+1.

قراءات إضافية