المعاملات والتخزين المؤقّت والأداء

ضبط الأداء والتحليل

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

ضبط الأداء والتحليل

كتابة كود Hibernate صحيح لا تمثّل إلا نصف العمل. في بيئة الإنتاج، تطبيق يُصدر مئات الاستعلامات في كل طلب، أو يحمّل كائنات لا تُقرأ أبدًا، أو يُدرج الصفوف واحدًا تلو الآخر، سيُعيق تطبيقك في صمت قبل أن تبلغ أي حد للأجهزة. يُزوّدك هذا الدرس بمجموعة أدوات عملية للكشف عن هذه المشكلات وإصلاحها: محرّك إحصاءات Hibernate المدمج، والجلب الدفعي للارتباطات، والإدراج والتحديث المجمّع على مستوى JDBC، وأكثر الأنماط المضادة شيوعًا.

تفعيل إحصاءات Hibernate

يتضمّن Hibernate نظامًا غنيًا للإحصاءات معطّلًا افتراضيًا. تفعيله يضيف عبئًا ضئيلًا في بيئات التطوير والاختبار، وهو لا غنى عنه لتحديد نقاط الازدحام قبل وصولها إلى الإنتاج.

في ملف application.properties:

# تفعيل إحصاءات Hibernate spring.jpa.properties.hibernate.generate_statistics=true # تسجيل الاستعلامات البطيئة (العتبة بالمللي ثانية) spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=25 # اختياري: تسجيل كل SQL مع المعاملات (عطّله في الإنتاج) spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

بمجرد تفعيلها، يصبح كائن الإحصاءات متاحًا برمجيًا عبر SessionFactory:

import org.hibernate.SessionFactory; import org.hibernate.stat.Statistics; import org.springframework.stereotype.Component; @Component public class HibernateStatsLogger { private final SessionFactory sessionFactory; public HibernateStatsLogger(SessionFactory sessionFactory) { this.sessionFactory = sessionFactory; } public void printStats() { Statistics stats = sessionFactory.getStatistics(); System.out.printf("الاستعلامات المُنفَّذة : %d%n", stats.getQueryExecutionCount()); System.out.printf("أبطأ استعلام (ms) : %d%n", stats.getQueryExecutionMaxTime()); System.out.printf("تحميلات الكيانات : %d%n", stats.getEntityLoadCount()); System.out.printf("إدراجات الكيانات : %d%n", stats.getEntityInsertCount()); System.out.printf("إصابات الذاكرة المؤقتة: %d%n", stats.getSecondLevelCacheHitCount()); System.out.printf("عدد الاتصالات : %d%n", stats.getConnectCount()); stats.clear(); // إعادة التعيين بين تشغيلات الاختبار } }
حقن EntityManagerFactory لا SessionFactory في كود JPA المعياري. افكّه عند بدء التشغيل باستخدام emf.unwrap(SessionFactory.class). يُهيئ Spring Boot تلقائيًا EntityManagerFactory من خصائص JPA، لذا يكون دائمًا متاحًا كحبّة (bean).

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

تُعدّ مشكلة N+1 أكثر ثغرات الأداء شيوعًا في Hibernate. تحدث عندما تُحمّل مجموعة من N كيان أب، ثم لكل منها تُشغّل ارتباطًا يُحمَّل كسولًا — ما يُنتج استعلامًا واحدًا للآباء ثم N استعلامًا للأبناء.

// المشكلة: استعلام واحد لكل الطلبات + استعلام لكل طلب لعناصره List<Order> orders = orderRepository.findAll(); for (Order o : orders) { // الوصول إلى o.getItems() يُطلق SELECT في كل مرة لم يُحمَّل بعد System.out.println(o.getItems().size()); }

الحل هو إخبار Hibernate بالضم الجشع (join fetch) للارتباط في الاستعلام الأوّلي، ما يُلغي رحلات الذهاب والإياب الإضافية:

// الحل أ: JPQL مع JOIN FETCH @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status") List<Order> findWithItems(@Param("status") OrderStatus status); // الحل ب: Entity Graph (تصريحي، دون تغيير الاستعلام) @EntityGraph(attributePaths = {"items", "items.product"}) List<Order> findByStatus(OrderStatus status);
JOIN FETCH مع التصفح الصفحي فخ. حين تضيف LIMIT/OFFSET إلى استعلام يستخدم JOIN FETCH على مجموعة، لا يستطيع Hibernate تطبيق الحد في SQL (لأن صف الأب الواحد يمتد على صفوف نتائج متعددة). يجلب كلّ الصفوف في الذاكرة ويُصفّحها هناك — وتظهر في السجل كـ: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!". الحل: صفّح على معرّف الأب ثم اجلب المجموعة في استعلام ثانٍ، أو استخدم تلميح @BatchSize.

الجلب الدفعي مع @BatchSize

تُمثّل @BatchSize استراتيجيةً وسطى بين JOIN FETCH الكامل والتحميل الكسول البحت. بدلًا من إصدار SQL واحد لكل وصول إلى ارتباط، يجمّع Hibernate معرّفات الوكلاء (proxies) غير المُهيَّأة في دفعات ويجلبها بعبارة IN (...) واحدة. لا يستلزم ذلك أي تعديل على الكود المُستدعي.

import jakarta.persistence.*; import org.hibernate.annotations.BatchSize; import java.util.List; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // بدون @BatchSize: SELECT واحد لكل طلب. معها: SELECT واحد لكل 25 طلبًا. @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) @BatchSize(size = 25) private List<OrderItem> items; }

يمكنك أيضًا تطبيق قيمة افتراضية عالمية في application.properties لتشمل كل الارتباطات الكسولة دفعةً واحدة:

spring.jpa.properties.hibernate.default_batch_fetch_size=25

اختيار الحجم المناسب ينطوي على مفاضلة: صغير جدًا وستظل تُصدر استعلامات كثيرة؛ كبير جدًا وستُحمّل بيانات لا تحتاجها. القيمة بين 16 و50 نقطة بداية شائعة — قِس أولًا ثم اضبط.

الإدراج والتحديث المجمّع عبر JDBC

عند حفظ أو دمج مئات الكيانات داخل معاملة واحدة، يُرسل Hibernate افتراضيًا كل INSERT وUPDATE إلى قاعدة البيانات بشكل منفرد. يجمّع تجميع JDBC تلك العبارات ويُرسلها في رحلة شبكة واحدة، مما قد يُقلّص زمن الاستجابة بمقدار عشرة أضعاف.

فعّله في application.properties:

# حجم كل دفعة JDBC spring.jpa.properties.hibernate.jdbc.batch_size=50 # رتّب الإدراجات/التحديثات لتجميع صفوف الجدول نفسه معًا spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true # مهم لـ MySQL/MariaDB: فعّل إعادة كتابة العبارات المجمّعة على مستوى المشغّل spring.datasource.url=jdbc:mysql://localhost:3306/shop?rewriteBatchedStatements=true
توليد IDENTITY يُعطّل التجميع. حين تستخدم GenerationType.IDENTITY، تُعيّن قاعدة البيانات المفتاح الأساسي عند الإدراج وتُعيده فورًا. يجب على Hibernate مسح كل صف منفردًا لاسترداد معرّفه — وهذا يُعطّل التجميع بصمت. انتقل إلى GenerationType.SEQUENCE (مع ضبط allocationSize ليطابق حجم دفعتك) لاستعادة التجميع.

مثال على حلقة إدراج مجمّع تستفيد من تجميع JDBC:

import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service public class BulkImportService { @PersistenceContext private EntityManager em; @Transactional public void importProducts(List<ProductDto> dtos) { int batchSize = 50; for (int i = 0; i < dtos.size(); i++) { Product p = new Product(dtos.get(i)); em.persist(p); if ((i + 1) % batchSize == 0) { em.flush(); // كتابة هذه الدفعة إلى قاعدة البيانات em.clear(); // إخلاء الذاكرة المؤقتة الأولى لتحرير الذاكرة } } // مسح وإخلاء أي كيانات متبقية في نهاية المعاملة } }

زوج flush() وclear() بالغ الأهمية. بدون clear()، تنمو الذاكرة المؤقتة الأولى بلا حدود — كل كيان تحفظه يبقى في الذاكرة طوال عمر الجلسة، وستتسبّب عملية استيراد مجمّع لـ 100,000 صف في OutOfMemoryError في نهاية المطاف.

الأنماط المضادة الشائعة في الأداء

  • تحميل الكيان بالكامل عند الحاجة لأعمدة قليلة. استخدم الإسقاطات (interface-based أو DTO-based) أو JPQL مع SELECT new MyDto(e.id, e.name) لتجنب نقل أعمدة غير مستخدمة.
  • استدعاء findAll() على جدول كبير. استخدم التصفح الصفحي دائمًا: repository.findAll(PageRequest.of(page, size)). جدول بمليون صف سيُرسل كل صفوفه.
  • مزج القراءات والكتابات في حلقة واحدة. كل كتابة تُشغّل فحص الاتساق على كل الكيانات في الجلسة. افصل الاستعلامات للقراءة فقط (مع @Transactional(readOnly = true)) عن عمليات الكتابة.
  • غياب فهارس قاعدة البيانات. يُصدر Hibernate SQL صحيحًا؛ لكن بدون فهرس على عمود المرشّح أو الربط، تُجري قاعدة البيانات مسحًا كاملًا للجدول. استخدم @Index على الكيان أو أدر الفهارس في نصوص الترحيل.
  • النمط المضاد Open Session in View. يُفعّل Spring Boot هذا افتراضيًا (spring.jpa.open-in-view=true). يُبقي OSIV جلسة Hibernate مفتوحة طوال طلب HTTP بما يُتيح التحميل الكسول في طبقة العرض — لكنه يربط اتصال قاعدة بيانات طوال مدة الطلب بما فيها وقت التصيير. عطّله في الخدمات عالية الإنتاجية وحمّل الارتباطات جشعًا في طبقة الخدمة بدلًا من ذلك.

التحليل مع P6Spy وDatasource-Proxy

لتحليل أعمق في بيئة التطوير، تعترض أدوات كـ P6Spy أو datasource-proxy كل استدعاء JDBC وتُبلّغ عن SQL الفعلي مع المعاملات المرتبطة ووقت التنفيذ وعدد الاستدعاءات. أضف P6Spy إلى مسار الفئات في التطوير:

<!-- pom.xml (النطاق: test أو provided) --> <dependency> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> </dependency>

غيّر بادئة عنوان URL لمصدر البيانات إلى jdbc:p6spy:mysql://... وأضف ملف spy.properties. ستظهر كل جملة SQL في السجل مع استبدال المعاملات كاملًا — أكثر قراءةً بكثير من مخرجات Hibernate التي تستخدم ? بدلًا من القيم الفعلية.

الخلاصة

ضبط الأداء في Hibernate منهجي لا عشوائي. فعّل الإحصاءات للقياس أولًا، ثم أصلح أكبر المشكلات: حُلّ مشكلة N+1 باستخدام JOIN FETCH أو @BatchSize، فعّل تجميع JDBC لعمليات الكتابة المجمّعة (وانتقل إلى SEQUENCE حتى يعمل)، تجنّب تحميل بيانات أكثر مما تحتاج، وعطّل OSIV في الخدمات التي تهتم بضغط تجمّع الاتصالات. قِس في بيئة واقعية قبل كل تغيير وبعده لتأكيد التحسّن.