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

الذاكرة المؤقتة من المستوى الثاني

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

الذاكرة المؤقتة من المستوى الثاني

تحتفظ كل EntityManager بالفعل بـذاكرة مؤقتة من المستوى الأول — وهي سياق الاستمرارية (persistence context) — بحيث تُعيد استدعاءات find(Order.class, 1L) المتكررة ضمن الجلسة ذاتها الكائن نفسه من الذاكرة دون أي رحلة إلى قاعدة البيانات. هذه الذاكرة المؤقتة مُقيَّدة بجلسة واحدة: تختفي بمجرد إغلاق EntityManager. أما الذاكرة المؤقتة من المستوى الثاني (L2 Cache) فتعيش على مستوى SessionFactory / EntityManagerFactory، وبذلك تُشارَك عبر جميع الجلسات وجميع الخيوط وطوال عمر التطبيق. عند الإصابة بالذاكرة المؤقتة L2 قد تتجنب رحلة إلى قاعدة البيانات تمامًا، بصرف النظر عن عدد الجلسات التي بدأت وانتهت.

لماذا تهمّنا ذاكرة L2 المؤقتة؟

تخيّل كتالوج منتجات يضم 10,000 عنصر. كل طلب HTTP ينشئ EntityManager جديدة، تبحث عن عدة منتجات بالمعرّف ثم تُغلق. بدون ذاكرة L2 المؤقتة يضرب كل بحث قاعدة البيانات. بوجودها تُحوَّل الصفوف من قاعدة البيانات في الوصول الأول وتُخزَّن في الذاكرة المشتركة، ثم تُقدَّم عمليات البحث اللاحقة — عبر جميع الخيوط — من الذاكرة. بالنسبة للبيانات المرجعية الثقيلة القراءة (الدول، الفئات، مجموعات الأذونات) يمكن لهذا أن يقلل الحمل على قاعدة البيانات بنسبة 80–95 %.

الذاكرة المؤقتة من المستوى الأول مقابل المستوى الثاني: ذاكرة المستوى الأول إلزامية وشفافة — لا يمكن إيقافها. ذاكرة المستوى الثاني اختيارية ومشتركة وتتطلب ضبطًا صريحًا واشتراكًا منفصلًا لكل كيان.

اختيار موفّر الذاكرة المؤقتة

يُفوِّض Hibernate 6 التخزين المؤقت L2 إلى موفّر ذاكرة مؤقتة قابل للتوصيل. الخياران الرئيسيان لتطبيقات Spring Boot 3 هما:

  • Ehcache 3 (عبر JCache / JSR-107): ناضج ومدمج (لا يحتاج عملية منفصلة)، ويوفر استراتيجيات إخلاء غنية. الأنسب للنشر على عقدة واحدة أو مجموعات صغيرة.
  • Redis (عبر Redisson أو Spring Cache): موزّع، يبقى بعد إعادة التشغيل، ويتوسّع أفقيًا. ضروري عندما يجب أن تتشارك عقد تطبيق متعددة نفس الذاكرة المؤقتة.

بالنسبة لمعظم خدمات الواجهة الخلفية، يُعدّ Ehcache 3 أسهل نقطة انطلاق. أضف التبعيات:

<!-- pom.xml --> <dependency> <groupId>org.hibernate.orm</groupId> <artifactId>hibernate-jcache</artifactId> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <classifier>jakarta</classifier> </dependency>

تفعيل الذاكرة المؤقتة في Spring Boot

ثلاثة أسطر في application.properties تُنشط دعم L2 في Hibernate وتوجّهه نحو موفّر JCache المدعوم بـ Ehcache:

spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider

بدون هذه الأسطر يُتجاهَل أي تعليق @Cache على كياناتك بصمت.

تأهيل كيان للتخزين المؤقت

تفعيل المصنع لا يكفي — يجب أن تختار كل كيان صراحةً باستخدام @Cache (Hibernate) مع @Cacheable القياسية من JPA:

import jakarta.persistence.*; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; @Entity @Table(name = "products") @Cacheable // JPA: مشاركة ذاكرة L2 المؤقتة @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate: استراتيجية التزامن public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String sku; private java.math.BigDecimal price; // getters / setters محذوفة للإيجاز }
اختر استراتيجية التزامن المناسبة من البداية. تغييرها لاحقًا يستلزم إخلاء الذاكرة المؤقتة. اختر READ_ONLY للبيانات المرجعية الثابتة (رموز العملات، أسماء الدول) — فهي الأسرع. استخدم READ_WRITE للكيانات القابلة للتعديل التي تُحدَّث أحيانًا. احتفظ بـ NONSTRICT_READ_WRITE للكيانات عالية الكتابة حيث قراءة قيمة قديمة لفترة وجيزة أمر مقبول.

شرح استراتيجيات التزامن

  • READ_ONLY: لا دعم للتحديث. يرمي Hibernate استثناءً إن حاولت تحديث كيان مخزَّن مؤقتًا. أسرع استراتيجية ممكنة — لا قفل ولا تكلفة إخلاء.
  • NONSTRICT_READ_WRITE: تُخلي مدخل الذاكرة المؤقتة عند التحديث لكنها لا تستخدم أقفالًا ناعمة. توجد نافذة ضيقة قد يقرأ فيها خيط آخر قيمة قديمة. مناسبة عندما تكون التناسق غير المتكامل مقبولًا.
  • READ_WRITE: تستخدم أقفالًا ناعمة لمنع القراءات القديمة أثناء التحديث. آمنة لمعظم التطبيقات التعاملية. لها تكلفة طفيفة مقارنة بالاستراتيجيتين السابقتين.
  • TRANSACTIONAL: تنسيق كامل مع JTA. مطلوبة فقط عندما يعمل موفّر JPA داخل مدير معاملات موزّع (نادر في تطبيقات Spring Boot الحديثة).

ضبط مناطق الذاكرة المؤقتة مع Ehcache

يحصل كل نوع كيان على منطقة ذاكرة مؤقتة خاصة به تُسمّى افتراضيًا باسم الفئة الكامل. تحدد حدود كل منطقة في ملف XML لـ Ehcache:

<!-- src/main/resources/ehcache.xml --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.ehcache.org/v3" xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd"> <cache alias="com.example.shop.domain.Product"> <expiry> <ttl unit="minutes">30</ttl> </expiry> <heap unit="entries">5000</heap> </cache> <cache alias="com.example.shop.domain.Category"> <expiry> <ttl unit="hours">12</ttl> </expiry> <heap unit="entries">200</heap> </cache> </config>

أشر Hibernate إلى هذا الملف:

spring.jpa.properties.hibernate.javax.cache.uri=classpath:ehcache.xml

تخزين الارتباطات والمجموعات مؤقتًا

لا تُخزَّن ارتباطات الكيانات (مجموعات one-to-many، مراجع many-to-one) مؤقتًا تلقائيًا حتى لو كان الكيان المالك مُهيَّأ للتخزين المؤقت. يجب أن تُعلّم كل مجموعة أو ارتباط على حدة:

@Entity @Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Category { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany(mappedBy = "category", fetch = FetchType.LAZY) @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // تخزين المجموعة مؤقتًا أيضًا private List<Product> products = new ArrayList<>(); }
إخلاء الذاكرة المؤقتة عند التحديثات: عند تحديث Product، يُخلي Hibernate مدخل ذلك المنتج من ذاكرة L2 المؤقتة. لكن إن حدّثت منتجات عبر تحديث JPQL مجمّع (UPDATE Product p SET p.price = ...) فلن يُخلي Hibernate المدخلات المتأثرة تلقائيًا — تصبح الذاكرة المؤقتة قديمة. بعد العمليات المجمّعة يجب إخلاء الذاكرة يدويًا: entityManager.getEntityManagerFactory().getCache().evict(Product.class).

التحقق من إصابات الذاكرة المؤقتة بالإحصاءات

فعّل إحصاءات Hibernate للتأكد من عمل الذاكرة المؤقتة:

spring.jpa.properties.hibernate.generate_statistics=true logging.level.org.hibernate.stat=DEBUG

سيتضمن ناتج السجل أسطرًا كهذه:

HHH000117: HQL: select p from Product p where p.id = :id, time: 0ms, rows: 0 Second level cache puts: 1 Second level cache hits: 47 Second level cache misses: 3

تُظهر ذاكرة L2 المؤقتة الصحية نسبة إصابة عالية (الإصابات / (الإصابات + الإخفاقات)). إن رأيت في الغالب إخفاقات، فإما أن TTL قصير جدًا، أو حدّ الكومة صغير جدًا، أو نمط الوصول عشوائي جدًا.

متى لا تستخدم ذاكرة L2 المؤقتة

ذاكرة L2 المؤقتة ليست مجانية. فهي تستهلك ذاكرة الكومة، وتزيد تعقيد منطق إخلاء الذاكرة، وقد تُقدّم بيانات قديمة عند سوء الضبط. تجنّبها في:

  • الكيانات كثيرة الكتابة — التحديثات المتكررة تُخلي المدخلات باستمرار مما يعطي نسبة إصابة شبه صفرية مع إضافة تكلفة في كل كتابة.
  • مجموعات النتائج الكبيرة والفريدة — استعلامات هوية الكيانات (بالمفتاح الرئيسي) تستفيد؛ أما SELECT المجمّعة العشوائية فخدمتها أفضل بذاكرة الاستعلام المؤقتة (موضوع الدرس التالي).
  • البيانات الحساسة أمنيًا — بيانات اعتماد المستخدم، الرموز المميزة، أو البيانات الشخصية المُخزَّنة في كومة مشتركة قد تتسرّب عبر الجلسات عند سوء ضبط مناطق الذاكرة المؤقتة.

الخلاصة

ذاكرة L2 المؤقتة هي مخزن مشترك للكيانات عبر الجلسات يعمل بموفّر قابل للتوصيل (Ehcache 3 هو الاختيار المدمج الأكثر شيوعًا). تُفعّلها في application.properties، وتختار كل كيان باستخدام @Cacheable و@Cache، وتضبط حدود كل منطقة في ملف Ehcache XML. اختيار استراتيجية التزامن الصحيحة — READ_ONLY للبيانات الثابتة، READ_WRITE للقابلة للتعديل — يحدد كلًّا من الصحة والأداء. راقب إحصاءات الذاكرة المؤقتة للتحقق من صحة ضبطك، وتذكّر أن تحديثات JPQL المجمّعة تتجاوز الإخلاء التلقائي وتستلزم إخلاءً يدويًا.