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

ذاكرة التخزين المؤقت للاستعلامات واستراتيجيات التخزين المؤقت

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

ذاكرة التخزين المؤقت للاستعلامات واستراتيجيات التخزين المؤقت

تناول الدرس السابق ذاكرة التخزين المؤقت من المستوى الثاني (L2)، التي تخزّن كائنات الكيانات منفردةً مُفهرَسةً بمفتاحها الأساسي. تذهب ذاكرة التخزين المؤقت للاستعلامات خطوةً أبعد: فهي تخزّن مجموعة النتائج لاستعلام مسمًّى أو معياري — تحديدًا القائمة المرتّبة من المفاتيح الأساسية المُعادة — وتُعيد استخدامها عند كل تنفيذ متطابق لاحق دون الرجوع إلى قاعدة البيانات. إنّ فهم متى تُفعّلها وأيّ استراتيجية تخزين مؤقت تمنح لكل كيان هو ما يُفرّق بين طبقة بيانات مضبوطة جيدًا وأخرى تهدر الموارد دون أن يُلاحَظ.

كيف تعمل ذاكرة التخزين المؤقت للاستعلامات

عندما تُعلّم استعلامًا بأنه قابل للتخزين المؤقت وينفَّذ للمرة الأولى، يخزّن Hibernate إدخال ذاكرة مؤقت مفتاحه عبارة الاستعلام مع قيم المعاملات المرتبطة. القيمة المخزّنة ليست كائنات الكيانات المُهيدرة، بل مجرد قائمة المفاتيح الأساسية (ولاستعلامات القيم الخام: قيم الأعمدة المباشرة). في الاستدعاء التالي بالاستعلام والمعاملات ذاتها، يسترجع Hibernate قائمة المفاتيح من ذاكرة الاستعلامات، ثم يبحث عن كل كيان في ذاكرة L2 للكيانات (أو يُحمّله من قاعدة البيانات إن لم يكن مخزّنًا).

ذاكرة التخزين المؤقت للاستعلامات تعتمد على ذاكرة الكيانات. إذا فعّلت ذاكرة الاستعلامات لكنك نسيت جعل الكيان المستعلَم عنه @Cacheable، سيصل Hibernate إلى قاعدة البيانات لكل كيان في كل ضربة من ذاكرة الاستعلامات — وهذا في الغالب أسوأ من غياب التخزين المؤقت تمامًا، لأنك تدفع تكلفة البحث في الذاكرة دون الاستفادة.

فعّل ذاكرة التخزين المؤقت للاستعلامات في application.properties:

# Caffeine (أو Ehcache) كمزوّد L2 مُهيَّأ مسبقًا spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.use_query_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=\ org.hibernate.cache.jcache.JCacheCacheRegionFactory

علّم استعلام JPQL بأنه قابل للتخزين المؤقت عبر @QueryHints أو ثابت تلميح JPA:

import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.jpa.repository.Query; import jakarta.persistence.QueryHint; import org.hibernate.jpa.HibernateHints; public interface ProductRepository extends JpaRepository<Product, Long> { @Query("SELECT p FROM Product p WHERE p.category = :category ORDER BY p.name") @QueryHints(@QueryHint( name = HibernateHints.HINT_CACHEABLE, value = "true" )) List<Product> findByCategory(@Param("category") String category); }

من Session أو EntityManager يمكنك أيضًا تعيين التلميح برمجيًا:

TypedQuery<Product> q = em.createQuery( "SELECT p FROM Product p WHERE p.category = :cat", Product.class); q.setParameter("cat", "electronics"); q.setHint(HibernateHints.HINT_CACHEABLE, true); List<Product> results = q.getResultList();

مناطق الذاكرة المؤقتة

تُخزَّن كل فئة كيان وكل استعلام قابل للتخزين في منطقة ذاكرة مؤقتة مسمّاة. الاسم الافتراضي للمنطقة في الكيان هو اسم الفئة الكامل (مثل com.example.shop.Product)، والافتراضي لنتائج الاستعلام هو default-query-results-region. يمكنك تجاوز المنطقة في استعلام معيّن:

q.setHint(HibernateHints.HINT_CACHE_REGION, "product.byCategory");

تتيح المناطق المسمّاة ضبط سياسات TTL والإخلاء المختلفة لكل مجموعة بيانات في إعداد JCache / Ehcache / Caffeine. البيانات سريعة التغيّر (مثل مستوى المخزون الحالي) تحصل على TTL مدته 30 ثانية؛ بيانات المرجعية البطيئة (مثل رموز الدول) يمكن أن تعيش ساعةً كاملة.

اختيار استراتيجية التخزين المؤقت

يعرض Hibernate أربع استراتيجيات تزامن لذاكرة L2. اختيار الاستراتيجية الصحيحة هو أهم قرار تخزين مؤقت تتخذه لكل كيان.

  • READ_ONLY — الكيان لا يُحدَّث بعد أول حفظ له (مثل: جداول البحث، رموز العملات، فئات المنتجات). أعلى معدل نقل بيانات، وبدون أي تكلفة قفل. أي محاولة تحديث ترمي استثناءً. استخدمها متى أمكن.
  • NONSTRICT_READ_WRITE — الكيان يُحدَّث أحيانًا لكن القراءة المتقادمة مقبولة لفترة وجيزة. تُبطَل الذاكرة المؤقتة بعد التحديث دون الاحتفاظ بقفل خلال الإبطال. مناسبة لعمليات الكتابة منخفضة التزامن على بيانات غير حرجة.
  • READ_WRITE — تستخدم قفلًا ناعمًا للحفاظ على دلالات read-committed: تُقفَل الإدخال أثناء الكتابة، وترى الخيوط الأخرى قيمة قاعدة البيانات (أو لا شيء) بدلًا من القيمة المتقادمة. آمنة للكيانات المُحدَّثة تحت تزامن معتدل. تكلفة أداء طفيفة لكل كتابة.
  • TRANSACTIONAL — ذاكرة مؤقتة معاملاتية كاملة، مندمجة مع JTA. تضمن دلالات ذاكرة مؤقتة بقراءة قابلة للتكرار عبر عقد الكلاستر. تتطلب مدير معاملات JTA ومزوّد ذاكرة مؤقتة متوافقًا مع JTA. نادرًا ما تحتاجها؛ وتضيف تعقيدًا كبيرًا.

طبّق الاستراتيجية باستخدام @Cache:

import jakarta.persistence.*; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; @Entity @Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Product { @Id @GeneratedValue private Long id; private String name; private String category; private int stockLevel; // getters / setters محذوفة }
افترض افتراضيًا READ_ONLY لبيانات المرجعية، وREAD_WRITE للكيانات القابلة للتعديل. لا تلجأ إلى NONSTRICT_READ_WRITE إلا إذا أظهر التوصيف تنافسًا على الكتابة في كيان READ_WRITE وكان نطاقك يتحمّل فعلًا قراءات متقادمة لفترة قصيرة.

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

مجموعات الكيانات (مثل مجموعات @OneToMany) لها منطقة ذاكرة مؤقتة خاصة بها ويجب تعليمها بشكل منفصل:

@Entity @Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Order { @Id @GeneratedValue private Long id; @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private List<OrderLine> lines = new ArrayList<>(); }

بدون @Cache الثاني على المجموعة، يتجاوز Hibernate المجموعة عند الكتابة في الذاكرة المؤقتة، مما يُسبّب تحميل N+1 في كل ضربة من ذاكرة الكيان الأب.

إبطال الذاكرة المؤقتة

تُبطَل منطقة ذاكرة الاستعلامات تلقائيًا عند كتابة أي كيان من نوع مشارك في الاستعلام المخزّن. هذا يعني أن معدل كتابة مرتفعًا على Product سيُبطل باستمرار كل استعلام مخزّن يتعامل مع Product، محوّلًا الذاكرة المؤقتة إلى عبء. راقب معدلات الضربة في الذاكرة المؤقتة باستخدام Spring Boot Actuator أو Micrometer قبل الالتزام بذاكرة الاستعلامات في سيناريو الكتابة المكثّفة.

# كشف مقاييس الذاكرة المؤقتة عبر Actuator management.endpoints.web.exposure.include=health,info,metrics,caches management.metrics.cache.instrument-defaults=true

لإخلاء منطقة معينة برمجيًا:

import org.springframework.cache.CacheManager; @Service public class CatalogService { @Autowired private CacheManager cacheManager; public void invalidateProductCache() { Cache c = cacheManager.getCache("product.byCategory"); if (c != null) c.clear(); } }

متى لا تستخدم ذاكرة التخزين المؤقت للاستعلامات

يستحق تفعيل ذاكرة الاستعلامات فقط عندما:

  1. يُنفَّذ الاستعلام ذاته بالمعاملات ذاتها بشكل متكرر.
  2. تتغيّر البيانات الأساسية نادرًا نسبةً إلى تكرار القراءة.
  3. مجموعة النتائج صغيرة بشكل معقول (آلاف الصفوف، لا ملايين).
ذاكرة التخزين المؤقت للاستعلامات مصدر شائع لتراجع مفاجئ في الأداء. لأن أي كتابة على نوع كيان مشارك تُبطل المنطقة بأكملها، قد تؤدّي مسار قراءة يبدو مخزَّنًا جيدًا أداءً أسوأ من غياب الذاكرة المؤقتة تمامًا في نظام يحوي عمليات كتابة متزامنة. قِس دائمًا قبل تفعيلها في الإنتاج.

الخلاصة

تخزّن ذاكرة التخزين المؤقت للاستعلامات قوائم مفاتيح نتائج الاستعلامات، وهي فعّالة فقط عند اقترانها بذاكرة L2 للكيانات مُهيَّأة بشكل صحيح. اختر READ_ONLY للبيانات المرجعية غير القابلة للتغيير، وREAD_WRITE للكيانات القابلة للتعديل بأمان، واحتفظ بـ TRANSACTIONAL للبيئات الموزّعة مع JTA. علّم المجموعات صراحةً أو ستواجه تحميل N+1 عند ضربات الذاكرة المؤقتة. استخدم مقاييس Actuator للتحقق من معدل الضربة قبل التخزين المؤقت وبعده، وتذكّر أن الجداول عالية الكتابة تجعل ذاكرة الاستعلامات عبئًا لا أصلًا.