علاقات الكيانات والارتباطات

مشكلة N+1 في الاستعلامات

18 دقيقة الدرس 7 من 13

مشكلة N+1 في الاستعلامات

تُعدّ مشكلة N+1 في الاستعلامات من أكثر مشكلات الأداء شيوعًا وضررًا في أي تطبيق يعتمد على ORM. إنها خفية بما يكفي لتفلت من مراجعة الكود، وشديدة بما يكفي لإسقاط خدمة إنتاجية تحت الحمل. يشرح هذا الدرس بالضبط كيف تنشأ هذه المشكلة في Hibernate، وكيف تكشفها، ولماذا يجب أن تفهمها قبل أن تتعلم الحلول في الدرس القادم.

كيف تبدو المشكلة

تخيّل نموذجًا بسيطًا: لكيان Customer مجموعة من كيانات Order. تحتاج إلى عرض صفحة ملخص تسرد كل عميل مع عدد طلباته.

@Entity public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToMany(mappedBy = "customer", fetch = FetchType.LAZY) private List<Order> orders = new ArrayList<>(); // الـ getters محذوفة للإيجاز } @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private BigDecimal total; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; }

الآن تأمّل هذه الدالة في طبقة الخدمة التي تحمّل جميع العملاء وتكرّر على طلباتهم:

@Service @Transactional(readOnly = true) public class CustomerReportService { @Autowired private CustomerRepository customerRepository; public List<String> buildSummary() { List<Customer> customers = customerRepository.findAll(); // الاستعلام 1 List<String> lines = new ArrayList<>(); for (Customer c : customers) { // الوصول إلى c.getOrders() يُطلق SELECT جديدة لكل عميل int count = c.getOrders().size(); // الاستعلامات 2..N+1 lines.add(c.getName() + " — " + count + " orders"); } return lines; } }

إذا كانت قاعدة البيانات تحتوي على 200 عميل، يُطلق هذا الحلقة البريئة الظاهر 201 جملة SQL: واحدة لتحميل جميع العملاء، ثم واحدة لكل عميل لتحميل مجموعة طلباته. هذا هو نمط N+1: استعلام جذر واحد + N استعلام للمجموعات.

لماذا يتسبّب LAZY Fetch في المشكلة

النوع الافتراضي للتحميل في الترابطات @OneToMany و@ManyToMany هو LAZY، مما يعني أن Hibernate لا يحمّل المجموعة المرتبطة حتى تصل إليها فعليًا. هذا الافتراضي منطقي — لا تريد جلب كل طلبات كل عميل فقط لأنك حمّلت قائمة العملاء. لكن حين تصل إلى كل مجموعة داخل حلقة تكرارية، تحصل على رحلة ذهاب وإياب لقاعدة البيانات لكل صف.

التحميل الكسول ليس السبب الجذري — بل أنماط الوصول غير المخططة هي السبب. يظل LAZY افتراضيًا صحيحًا. تنشأ المشكلة عندما تصل إلى مجموعة كسولة داخل حلقة دون إخبار Hibernate بتحميل البيانات مسبقًا عبر استعلام join واحد.

عدّ الاستعلامات: مثال ملموس

مكّن تسجيل SQL الخاص بـ Hibernate لترى ذلك بنفسك. أضف هذا إلى application.properties:

# application.properties spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.orm.jdbc.bind=TRACE

مع 5 عملاء في قاعدة البيانات ستشاهد مخرجات كهذه:

-- الاستعلام 1: تحميل جميع العملاء select c1_0.id, c1_0.name from customer c1_0 -- الاستعلام 2: طلبات العميل id=1 select o1_0.customer_id, o1_0.id, o1_0.total from orders o1_0 where o1_0.customer_id=1 -- الاستعلام 3: طلبات العميل id=2 select o1_0.customer_id, o1_0.id, o1_0.total from orders o1_0 where o1_0.customer_id=2 -- ... 3 استعلامات أخرى ... -- الاستعلام 6: طلبات العميل id=5 select o1_0.customer_id, o1_0.id, o1_0.total from orders o1_0 where o1_0.customer_id=5

ستة استعلامات لخمسة عملاء. قِس ذلك على 1,000 عميل وستحصل على 1,001 رحلة ذهاب وإياب لقاعدة البيانات من أجل عرض صفحة واحدة فقط.

تأثير الأداء على نطاق واسع

لكل رحلة ذهاب وإياب لقاعدة البيانات تكلفة ثابتة: زمن استجابة الشبكة (حتى على localhost هذا عادةً 0.1–1 ملي ثانية)، والحصول على اتصال من المجموعة، وتحليل الاستعلام، وعمليات الإدخال/الإخراج. بافتراض 1 ملي ثانية بشكل تحفظي لكل استعلام:

  • 100 عميل: ~101 ملي ثانية في وقت قاعدة البيانات فقط
  • 1,000 عميل: ~1,001 ملي ثانية — أكثر من ثانية كاملة
  • 10,000 عميل: ~10 ثوانٍ — انتظار انقضاء المهلة

والأسوأ من ذلك أن كل طلب HTTP متزامن يُطلق نفس فيضان الاستعلامات، فيضرب استنفاد مجمّع الاتصالات وتشبّع CPU لقاعدة البيانات في آنٍ واحد تحت الحمل.

المشكلة N+1 غير مرئية في بيئة التطوير. قاعدة بياناتك المحلية تحتوي على 10 صفوف؛ عدد الاستعلامات يبدو تافهًا. في الإنتاج مع 50,000 صف، يصبح نفس الكود فشلًا متتاليًا. دائمًا قِس باستخدام بيانات بحجم الإنتاج قبل الإطلاق.

المشكلة لا تقتصر على المجموعات

تضرب المشكلة N+1 أيضًا ترابطات @ManyToOne حين يُحمَّل الجانب المالك بشكل كسول. افترض أنك تستعلم عن جميع الطلبات ثم تصل إلى عميل كل طلب:

List<Order> orders = orderRepository.findAll(); // استعلام 1 for (Order o : orders) { // كل استدعاء قد يُطلق SELECT على جدول العملاء System.out.println(o.getCustomer().getName()); // N استعلامات }

رغم أن @ManyToOne يعتمد افتراضيًا على التحميل EAGER في JPA، لا يزال Hibernate قادرًا على إنتاج N+1 في بعض إسقاطات JPQL لـ Spring Data أو حين يُضبط الترابط صراحةً على LAZY. النمط واحد.

الكشف عن N+1 في مشروع Spring Boot

هناك عدة طرق موثوقة لاكتشاف هذه المشكلة في بيئة التطوير:

  1. تسجيل SQL — مكّن spring.jpa.show-sql=true وعدّ الجمل المتكررة. مرهق لكنه دائمًا متاح.
  2. إحصائيات Hibernate — مكّن spring.jpa.properties.hibernate.generate_statistics=true؛ يسجّل Hibernate ملخصًا عند إغلاق الجلسة يتضمن عدد جلب المجموعات. عدد مساوٍ لعدد الكيانات الجذر علامة حمراء.
  3. datasource-proxy / p6spy — أنشئ وكيلًا للـ DataSource يسجّل كل استعلام مع تتبع المكدس؛ مثالي لاختبارات التكامل. مكتبات كـ datasource-micrometer تدمج هذا في مقاييس Micrometer.
  4. Hypersistence Optimizer — أداة تحليل ثابتة تجارية تُحدد أنماط N+1 عند وقت الاختبار دون تشغيل استعلام.
أضف تأكيد عدد الاستعلامات إلى اختبارات التكامل. استخدم مكتبة وكيل DataSource لتفشيل الاختبار إذا نُفّذت أكثر من عتبة معينة من جمل SQL لاستدعاء خدمة معين. هذا يمنع تراجعات N+1 من التسلل إلى الإنتاج دون ملاحظة.

لماذا التبديل إلى EAGER Fetch ليس الحل الصحيح

الغريزة الشائعة — والخاطئة — هي تغيير نوع التحميل إلى EAGER:

@OneToMany(mappedBy = "customer", fetch = FetchType.EAGER) // لا تفعل هذا private List<Order> orders;

هذا لا يحل N+1؛ بل ينقل المشكلة فحسب. لا يزال Hibernate يُصدر SELECT منفصلة لكل عميل لتحميل الطلبات ما لم تكتب أيضًا استعلام JOIN FETCH. بالإضافة إلى ذلك، يُجبر EAGER المجموعة على التحميل في كل سياق استعلام، حتى حين لا تحتاج الطلبات — مما يُهدر الذاكرة والنطاق الترددي في كل عملية بحث عن عميل عبر تطبيقك بأكمله. لقد استبدلت مشكلة أداء انتقائية بمشكلة عامة دائمة.

الخلاصة

تنشأ مشكلة N+1 حين يُصدر Hibernate جملة SQL واحدة لتحميل N كيانًا جذريًا ثم يُطلق جملة إضافية لكل كيان حين تصل إلى ترابط كسول — مما يعطيك N+1 استعلامًا إجماليًا بدلًا من استعلام واحد. تنجم عن الوصول إلى المجموعات الكسولة داخل حلقات دون تحميل البيانات مسبقًا. وهي غير مرئية على مجموعات البيانات الصغيرة لكنها كارثية على النطاق الواسع. التبديل إلى تحميل EAGER ليس الحل — إذ يُقايض مشكلة انتقائية بتكلفة دائمة. الحلول الصحيحة — JOIN FETCH وEntity Graphs والتحميل الدفعي — هي موضوع الدرس القادم.