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

حل مشكلة N+1: Join Fetch وEntity Graphs

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

حل مشكلة N+1: Join Fetch وEntity Graphs

في الدرس السابق رأيت كيف يُطلق التحميل الكسول استعلام SQL منفصلاً لكل عنصر في القائمة — وهي مشكلة N+1. يتناول هذا الدرس الأداتين الرئيسيتين اللتين تمنحانك إياهما Hibernate وJPA لإصلاح ذلك: JPQL JOIN FETCH وEntity Graphs. يوجّه كلا الأسلوبين مزوّد المثابرة لتحميل الارتباط في استعلام واحد كفؤ بدلاً من N استعلام إضافي. الاختيار بينهما يعتمد أساسًا على المكان الذي تريد فيه التعبير عن تلك النية: في نص الاستعلام أو في بيانات التعريف (metadata).

الأسلوب الأول — JPQL JOIN FETCH

ربط JPQL العادي (JOIN o.items i) يُرشّح الصفوف لكنه لا يُهيّئ مجموعة items على كائنات Order المُعادة. إضافة الكلمة المفتاحية FETCH تغيّر ذلك: تُنفّذ Hibernate ربطًا داخليًا بـ SQL وتستخدم النتيجة لملء المجموعة في الذاكرة، كل ذلك في جولة اتصال واحدة.

// بدون fetch — وكيل كسول، خطر N+1 @Query("SELECT o FROM Order o JOIN o.items i WHERE i.productId = :pid") List<Order> findByProduct(@Param("pid") Long productId); // مع FETCH — يُملأ كائن items في استعلام واحد @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.customer.id = :cid") List<Order> findWithItems(@Param("cid") Long customerId);

الكلمة المفتاحية DISTINCT مهمة هنا. ربط SQL يُكرّر صف الأصل لكل صف فرعي. بدون DISTINCT ستعيد Hibernate كائن Order ذاته عدة مرات في القائمة — مرة لكل OrderItem. تُخبر DISTINCT تلك Hibernate بإزالة التكرار من مجموعة النتائج في الذاكرة بعد تحديث الكيانات.

SQL DISTINCT مقابل JPQL DISTINCT: في Hibernate 6، لا تُضيف DISTINCT في JPQL عبارة DISTINCT إلى SQL افتراضيًا (لأنها تمنع استخدام الفهرس وتُجبر على الفرز). فهي تُزيل التكرار فقط في الرسم البياني لكائنات Java. إن أردت منع التكرار على مستوى SQL أيضًا، اضبط تلميح الاستعلام HINT_PASS_DISTINCT_THROUGH على false.

الجلب عبر مستويات متعددة

يمكنك تسلسل عبارات JOIN FETCH لتحديث الارتباطات عند مستويات متعددة في استعلام واحد:

@Query(""" SELECT DISTINCT o FROM Order o JOIN FETCH o.customer c JOIN FETCH o.items i JOIN FETCH i.product p WHERE o.status = :status """) List<Order> findOrdersWithDetails(@Param("status") OrderStatus status);

ينتج عن ذلك جملة SQL واحدة بثلاثة ربطات. كل Order في النتيجة سيكون customer وitems وحقل product لكل عنصر مُهيَّئة بالكامل — لا وكلاء كسولة في أي مكان في الرسم البياني.

لا تُنجز JOIN FETCH لمجموعتين في آنٍ واحد. إن كان لدى Order كلٌّ من items وvouchers كمجموعات وجلبت كليهما في استعلام واحد، فستُطلق Hibernate استثناء MultipleBagFetchException (عند استخدام List) أو تُنتج ضرب ديكارتيًا يُضاعف عدد الصفوف. اجلب مجموعة واحدة على الأكثر لكل استعلام؛ استخدم استعلامًا ثانيًا أو رسمًا بيانيًا فرعيًا للمجموعة الثانية.

الأسلوب الثاني — Entity Graphs

تتيح لك Entity Graphs وصف خطة الجلب كبيانات تعريف — إما بشكل ثابت مع تعليقات توضيحية أو ديناميكيًا في وقت التشغيل — ثم تطبيقها على أي استعلام أو استدعاء find(). الرسم البياني منفصل عن نص JPQL، مما يُبقي الاستعلامات قابلة للإعادة الاستخدام عبر سيناريوهات تحميل مختلفة.

الرسم البياني الثابت المُسمّى

import jakarta.persistence.*; @Entity @Table(name = "orders") @NamedEntityGraph( name = "Order.withItems", attributeNodes = { @NamedAttributeNode("items"), @NamedAttributeNode("customer") } ) public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) private Customer customer; @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List<OrderItem> items = new ArrayList<>(); // getters / setters ... }

طبّق الرسم البياني المُسمَّى في مستودع Spring Data باستخدام التعليق التوضيحي @EntityGraph:

import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; public interface OrderRepository extends JpaRepository<Order, Long> { // يُطبّق رسم "Order.withItems" — يحمل items + customer في استعلام واحد @EntityGraph("Order.withItems") List<Order> findByStatus(OrderStatus status); // يعمل مع JPQL المخصص أيضًا @EntityGraph("Order.withItems") @Query("SELECT o FROM Order o WHERE o.customer.id = :cid") List<Order> findByCustomerId(@Param("cid") Long customerId); }

Entity Graphs الديناميكية في وقت التشغيل

عندما تحتاج إلى تغيير خطة الجلب برمجيًا — مثلاً، تحميل رسوم بيانية أعمق لنقطة نهاية المسؤول لكن رسمًا ضحلاً لواجهة برمجية عامة — ابنِ الرسم البياني في الكود باستخدام EntityManager:

import jakarta.persistence.*; import java.util.List; @Service @Transactional(readOnly = true) public class OrderService { @PersistenceContext private EntityManager em; public List<Order> findOrdersWithFullDetails(Long customerId) { EntityGraph<Order> graph = em.createEntityGraph(Order.class); graph.addAttributeNodes("customer"); Subgraph<OrderItem> itemsGraph = graph.addSubgraph("items"); itemsGraph.addAttributeNodes("product"); // جلب item.product أيضًا return em.createQuery( "SELECT DISTINCT o FROM Order o WHERE o.customer.id = :cid", Order.class) .setParameter("cid", customerId) .setHint("jakarta.persistence.fetchgraph", graph) .getResultList(); } }
fetchgraph مقابل loadgraph: استخدم مفتاح التلميح "jakarta.persistence.fetchgraph" لجعل فقط الخصائص في الرسم البياني EAGER (كل شيء آخر يبقى LAZY). استخدم "jakarta.persistence.loadgraph" لتحميل خصائص الرسم البياني كـ EAGER بالإضافة إلى أي خصائص مُعيَّنة EAGER أصلاً. من الناحية العملية، يمنحك fetchgraph أكثر تحكمًا صريحًا.

اختصار Spring Data — مسارات الخصائص المضمّنة

في الحالات البسيطة لا تحتاج إلى @NamedEntityGraph على الكيان إطلاقًا. يتيح لك Spring Data JPA تحديد مسارات الخصائص مباشرةً في تعليق @EntityGraph:

// لا حاجة لـ @NamedEntityGraph — مسارات الخصائص مُعلَنة مضمَّنة @EntityGraph(attributePaths = {"items", "items.product", "customer"}) List<Order> findAll();

هذا هو الشكل الأكثر إيجازًا ويعمل بشكل جيد عندما ترتبط خطة الجلب بطريقة مستودع واحدة. فضّل الرسوم البيانية المُسمَّاة عند إعادة استخدام الخطة ذاتها عبر طرق أو مستودعات متعددة.

JOIN FETCH مقابل Entity Graphs — الموازنات

  • JOIN FETCH صريح ومرئي مباشرةً في الاستعلام. هو الخيار الصحيح عندما تكون منطق الاستعلام وخطة الجلب غير قابلَين للفصل، وعندما تريد التحكم في الترشيح (مثل WHERE على خاصية الربط).
  • Entity Graphs تُبقي الاستعلامات نظيفة وقابلة للإعادة الاستخدام. يمكن استدعاء الطريقة ذاتها findByStatus مع الرسم البياني أو بدونه بمجرد إضافة التعليق التوضيحي أو إزالته دون إعادة كتابة JPQL.
  • كلاهما يُنتج ربطًا بـ SQL. SQL المُوَّلَد فعليًا متكافئ؛ الاختلاف تنظيمي.
تحقق بتسجيل SQL. فعّل دائمًا spring.jpa.show-sql=true (أو مُدرِج سجلات مناسب لـ org.hibernate.SQL) أثناء التطوير وتأكد من رؤية استعلام واحد بالضبط لا N+1. والأفضل استخدام p6spy أو Hypersistence Optimizer لتأكيد عدد الاستعلامات في الاختبارات.

فخ التصفح الصفحي مع JOIN FETCH

مزج JOIN FETCH مع تصفح Spring Data الصفحي (Pageable) على ارتباط مجموعة أمر خطير. لا تستطيع Hibernate دفع التصفح إلى SQL (لأنها لا تعرف كم كيانًا رئيسيًا يُقابل عددًا محدودًا من الصفوف) فتجلب جميع الصفوف في الذاكرة وتُصفّح هناك. وستظهر تحذير Hibernate: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!"

النمط الموصى به هو أسلوب الاستعلامَين: استعلام صفحي أول يجلب فقط معرفات الكيان الرئيسي، واستعلام ثانٍ يستخدم تلك المعرفات مع JOIN FETCH أو رسم بياني لتحميل البيانات الكاملة:

// الخطوة 1 — استعلام معرفات مُصفَّح (بدون fetch join، SQL LIMIT/OFFSET كفؤ) @Query( value = "SELECT o.id FROM Order o WHERE o.status = :status", countQuery = "SELECT COUNT(o) FROM Order o WHERE o.status = :status" ) Page<Long> findIdsByStatus(@Param("status") OrderStatus status, Pageable pageable); // الخطوة 2 — جلب البيانات الكاملة لتلك المعرفات (بدون Pageable، آمن مع JOIN FETCH) @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids") List<Order> findWithItemsByIds(@Param("ids") List<Long> ids);

الخلاصة

JOIN FETCH والرسوم البيانية للكيانات أدوات تكميلية تحل السبب الجذري ذاته: الرحلات الزائدة للاتصال الناتجة عن التحميل الكسول داخل حلقة. استخدم JOIN FETCH عندما يكون منطق الاستعلام وخطة الجلب غير قابلَين للفصل. استخدم رسوم العلاقات عندما تريد إبقاء الاستعلامات عامة وتغيير خطة الجلب عند موقع الاستدعاء. أكّد دائمًا إصلاحك بتسجيل SQL، وانتبه لفخ التصفح الصفحي عند جلب المجموعات. بهذه التقنيات يمكنك القضاء على كل مشاكل N+1 تقريبًا في تطبيق Spring Boot المدعوم بـ Hibernate.