حل مشكلة N+1: Join Fetch وEntity Graphs
حل مشكلة 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 وتستخدم النتيجة لملء المجموعة في الذاكرة، كل ذلك في جولة اتصال واحدة.
الكلمة المفتاحية DISTINCT مهمة هنا. ربط SQL يُكرّر صف الأصل لكل صف فرعي. بدون DISTINCT ستعيد Hibernate كائن Order ذاته عدة مرات في القائمة — مرة لكل OrderItem. تُخبر DISTINCT تلك Hibernate بإزالة التكرار من مجموعة النتائج في الذاكرة بعد تحديث الكيانات.
DISTINCT في JPQL عبارة DISTINCT إلى SQL افتراضيًا (لأنها تمنع استخدام الفهرس وتُجبر على الفرز). فهي تُزيل التكرار فقط في الرسم البياني لكائنات Java. إن أردت منع التكرار على مستوى SQL أيضًا، اضبط تلميح الاستعلام HINT_PASS_DISTINCT_THROUGH على false.
الجلب عبر مستويات متعددة
يمكنك تسلسل عبارات JOIN FETCH لتحديث الارتباطات عند مستويات متعددة في استعلام واحد:
ينتج عن ذلك جملة SQL واحدة بثلاثة ربطات. كل Order في النتيجة سيكون customer وitems وحقل product لكل عنصر مُهيَّئة بالكامل — لا وكلاء كسولة في أي مكان في الرسم البياني.
Order كلٌّ من items وvouchers كمجموعات وجلبت كليهما في استعلام واحد، فستُطلق Hibernate استثناء MultipleBagFetchException (عند استخدام List) أو تُنتج ضرب ديكارتيًا يُضاعف عدد الصفوف. اجلب مجموعة واحدة على الأكثر لكل استعلام؛ استخدم استعلامًا ثانيًا أو رسمًا بيانيًا فرعيًا للمجموعة الثانية.
الأسلوب الثاني — Entity Graphs
تتيح لك Entity Graphs وصف خطة الجلب كبيانات تعريف — إما بشكل ثابت مع تعليقات توضيحية أو ديناميكيًا في وقت التشغيل — ثم تطبيقها على أي استعلام أو استدعاء find(). الرسم البياني منفصل عن نص JPQL، مما يُبقي الاستعلامات قابلة للإعادة الاستخدام عبر سيناريوهات تحميل مختلفة.
الرسم البياني الثابت المُسمّى
طبّق الرسم البياني المُسمَّى في مستودع Spring Data باستخدام التعليق التوضيحي @EntityGraph:
Entity Graphs الديناميكية في وقت التشغيل
عندما تحتاج إلى تغيير خطة الجلب برمجيًا — مثلاً، تحميل رسوم بيانية أعمق لنقطة نهاية المسؤول لكن رسمًا ضحلاً لواجهة برمجية عامة — ابنِ الرسم البياني في الكود باستخدام EntityManager:
"jakarta.persistence.fetchgraph" لجعل فقط الخصائص في الرسم البياني EAGER (كل شيء آخر يبقى LAZY). استخدم "jakarta.persistence.loadgraph" لتحميل خصائص الرسم البياني كـ EAGER بالإضافة إلى أي خصائص مُعيَّنة EAGER أصلاً. من الناحية العملية، يمنحك fetchgraph أكثر تحكمًا صريحًا.
اختصار Spring Data — مسارات الخصائص المضمّنة
في الحالات البسيطة لا تحتاج إلى @NamedEntityGraph على الكيان إطلاقًا. يتيح لك Spring Data JPA تحديد مسارات الخصائص مباشرةً في تعليق @EntityGraph:
هذا هو الشكل الأكثر إيجازًا ويعمل بشكل جيد عندما ترتبط خطة الجلب بطريقة مستودع واحدة. فضّل الرسوم البيانية المُسمَّاة عند إعادة استخدام الخطة ذاتها عبر طرق أو مستودعات متعددة.
JOIN FETCH مقابل Entity Graphs — الموازنات
- JOIN FETCH صريح ومرئي مباشرةً في الاستعلام. هو الخيار الصحيح عندما تكون منطق الاستعلام وخطة الجلب غير قابلَين للفصل، وعندما تريد التحكم في الترشيح (مثل
WHEREعلى خاصية الربط). - Entity Graphs تُبقي الاستعلامات نظيفة وقابلة للإعادة الاستخدام. يمكن استدعاء الطريقة ذاتها
findByStatusمع الرسم البياني أو بدونه بمجرد إضافة التعليق التوضيحي أو إزالته دون إعادة كتابة JPQL. - كلاهما يُنتج ربطًا بـ 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 أو رسم بياني لتحميل البيانات الكاملة:
الخلاصة
JOIN FETCH والرسوم البيانية للكيانات أدوات تكميلية تحل السبب الجذري ذاته: الرحلات الزائدة للاتصال الناتجة عن التحميل الكسول داخل حلقة. استخدم JOIN FETCH عندما يكون منطق الاستعلام وخطة الجلب غير قابلَين للفصل. استخدم رسوم العلاقات عندما تريد إبقاء الاستعلامات عامة وتغيير خطة الجلب عند موقع الاستدعاء. أكّد دائمًا إصلاحك بتسجيل SQL، وانتبه لفخ التصفح الصفحي عند جلب المجموعات. بهذه التقنيات يمكنك القضاء على كل مشاكل N+1 تقريبًا في تطبيق Spring Boot المدعوم بـ Hibernate.