مشكلة N+1 في الاستعلامات
مشكلة N+1 في الاستعلامات
تُعدّ مشكلة N+1 في الاستعلامات من أكثر مشكلات الأداء شيوعًا وضررًا في أي تطبيق يعتمد على ORM. إنها خفية بما يكفي لتفلت من مراجعة الكود، وشديدة بما يكفي لإسقاط خدمة إنتاجية تحت الحمل. يشرح هذا الدرس بالضبط كيف تنشأ هذه المشكلة في Hibernate، وكيف تكشفها، ولماذا يجب أن تفهمها قبل أن تتعلم الحلول في الدرس القادم.
كيف تبدو المشكلة
تخيّل نموذجًا بسيطًا: لكيان Customer مجموعة من كيانات Order. تحتاج إلى عرض صفحة ملخص تسرد كل عميل مع عدد طلباته.
الآن تأمّل هذه الدالة في طبقة الخدمة التي تحمّل جميع العملاء وتكرّر على طلباتهم:
إذا كانت قاعدة البيانات تحتوي على 200 عميل، يُطلق هذا الحلقة البريئة الظاهر 201 جملة SQL: واحدة لتحميل جميع العملاء، ثم واحدة لكل عميل لتحميل مجموعة طلباته. هذا هو نمط N+1: استعلام جذر واحد + N استعلام للمجموعات.
لماذا يتسبّب LAZY Fetch في المشكلة
النوع الافتراضي للتحميل في الترابطات @OneToMany و@ManyToMany هو LAZY، مما يعني أن Hibernate لا يحمّل المجموعة المرتبطة حتى تصل إليها فعليًا. هذا الافتراضي منطقي — لا تريد جلب كل طلبات كل عميل فقط لأنك حمّلت قائمة العملاء. لكن حين تصل إلى كل مجموعة داخل حلقة تكرارية، تحصل على رحلة ذهاب وإياب لقاعدة البيانات لكل صف.
LAZY افتراضيًا صحيحًا. تنشأ المشكلة عندما تصل إلى مجموعة كسولة داخل حلقة دون إخبار Hibernate بتحميل البيانات مسبقًا عبر استعلام join واحد.
عدّ الاستعلامات: مثال ملموس
مكّن تسجيل SQL الخاص بـ Hibernate لترى ذلك بنفسك. أضف هذا إلى application.properties:
مع 5 عملاء في قاعدة البيانات ستشاهد مخرجات كهذه:
ستة استعلامات لخمسة عملاء. قِس ذلك على 1,000 عميل وستحصل على 1,001 رحلة ذهاب وإياب لقاعدة البيانات من أجل عرض صفحة واحدة فقط.
تأثير الأداء على نطاق واسع
لكل رحلة ذهاب وإياب لقاعدة البيانات تكلفة ثابتة: زمن استجابة الشبكة (حتى على localhost هذا عادةً 0.1–1 ملي ثانية)، والحصول على اتصال من المجموعة، وتحليل الاستعلام، وعمليات الإدخال/الإخراج. بافتراض 1 ملي ثانية بشكل تحفظي لكل استعلام:
- 100 عميل: ~101 ملي ثانية في وقت قاعدة البيانات فقط
- 1,000 عميل: ~1,001 ملي ثانية — أكثر من ثانية كاملة
- 10,000 عميل: ~10 ثوانٍ — انتظار انقضاء المهلة
والأسوأ من ذلك أن كل طلب HTTP متزامن يُطلق نفس فيضان الاستعلامات، فيضرب استنفاد مجمّع الاتصالات وتشبّع CPU لقاعدة البيانات في آنٍ واحد تحت الحمل.
المشكلة لا تقتصر على المجموعات
تضرب المشكلة N+1 أيضًا ترابطات @ManyToOne حين يُحمَّل الجانب المالك بشكل كسول. افترض أنك تستعلم عن جميع الطلبات ثم تصل إلى عميل كل طلب:
رغم أن @ManyToOne يعتمد افتراضيًا على التحميل EAGER في JPA، لا يزال Hibernate قادرًا على إنتاج N+1 في بعض إسقاطات JPQL لـ Spring Data أو حين يُضبط الترابط صراحةً على LAZY. النمط واحد.
الكشف عن N+1 في مشروع Spring Boot
هناك عدة طرق موثوقة لاكتشاف هذه المشكلة في بيئة التطوير:
- تسجيل SQL — مكّن
spring.jpa.show-sql=trueوعدّ الجمل المتكررة. مرهق لكنه دائمًا متاح. - إحصائيات Hibernate — مكّن
spring.jpa.properties.hibernate.generate_statistics=true؛ يسجّل Hibernate ملخصًا عند إغلاق الجلسة يتضمن عدد جلب المجموعات. عدد مساوٍ لعدد الكيانات الجذر علامة حمراء. - datasource-proxy / p6spy — أنشئ وكيلًا للـ DataSource يسجّل كل استعلام مع تتبع المكدس؛ مثالي لاختبارات التكامل. مكتبات كـ
datasource-micrometerتدمج هذا في مقاييس Micrometer. - Hypersistence Optimizer — أداة تحليل ثابتة تجارية تُحدد أنماط N+1 عند وقت الاختبار دون تشغيل استعلام.
لماذا التبديل إلى EAGER Fetch ليس الحل الصحيح
الغريزة الشائعة — والخاطئة — هي تغيير نوع التحميل إلى EAGER:
هذا لا يحل N+1؛ بل ينقل المشكلة فحسب. لا يزال Hibernate يُصدر SELECT منفصلة لكل عميل لتحميل الطلبات ما لم تكتب أيضًا استعلام JOIN FETCH. بالإضافة إلى ذلك، يُجبر EAGER المجموعة على التحميل في كل سياق استعلام، حتى حين لا تحتاج الطلبات — مما يُهدر الذاكرة والنطاق الترددي في كل عملية بحث عن عميل عبر تطبيقك بأكمله. لقد استبدلت مشكلة أداء انتقائية بمشكلة عامة دائمة.
الخلاصة
تنشأ مشكلة N+1 حين يُصدر Hibernate جملة SQL واحدة لتحميل N كيانًا جذريًا ثم يُطلق جملة إضافية لكل كيان حين تصل إلى ترابط كسول — مما يعطيك N+1 استعلامًا إجماليًا بدلًا من استعلام واحد. تنجم عن الوصول إلى المجموعات الكسولة داخل حلقات دون تحميل البيانات مسبقًا. وهي غير مرئية على مجموعات البيانات الصغيرة لكنها كارثية على النطاق الواسع. التبديل إلى تحميل EAGER ليس الحل — إذ يُقايض مشكلة انتقائية بتكلفة دائمة. الحلول الصحيحة — JOIN FETCH وEntity Graphs والتحميل الدفعي — هي موضوع الدرس القادم.