Hibernate وتخطيط الكيانات

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

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

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

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

ما هي ذاكرة التخزين المؤقت من المستوى الأول؟

يحتفظ Hibernate بخريطة هوية داخلية مُفهرَسة بنوع الكيان مضافًا إليه المفتاح الأساسي. في اللحظة التي تحمّل فيها كيانًا أو تثبّته أو تدمجه، يُسجّله Hibernate في هذه الخريطة. كل بحث لاحق عن النوع والمعرّف ذاتهما داخل EntityManager نفسه يُعيد الكائن Java المطابق تمامًا — دون أي رحلة إلى قاعدة البيانات ودون إنشاء نسخة ثانية.

هذه الذاكرة تعمل دائمًا. لا يوجد مفتاح إعداد لتعطيلها. إنها أساس ضمان هوية الكائن في JPA: ضمن سياق استمرارية واحد، يُمثَّل الصف نفسه في قاعدة البيانات دائمًا بالمرجع Java ذاته.

سياق الاستمرارية = خريطة الهوية. تشترط مواصفات JPA أن استدعاءين لـfind() على نفس فئة الكيان والمفتاح الأساسي ضمن سياق استمرارية واحد يجب أن يُعيدا نفس نسخة الكائن. يُحقق Hibernate ذلك عبر ذاكرة التخزين المؤقت من المستوى الأول.

ضمان الهوية في التطبيق العملي

تأمّل هذا السيناريو داخل دالة خدمة Spring مُعلَّمة بـ@Transactional:

@Service public class OrderService { @Autowired private EntityManager em; @Transactional public void demonstrateIdentity(Long orderId) { Order first = em.find(Order.class, orderId); // SELECT تُنفَّذ مرة واحدة Order second = em.find(Order.class, orderId); // يُعيد الكائن المخزَّن — لا SQL System.out.println(first == second); // true — نفس المرجع } }

يُطلق Hibernate جملة SELECT واحدة عند استدعاء find() الأول. عند الاستدعاء الثاني يفحص خريطة الهوية فيجد إدخالًا لـ(Order, orderId) فيُعيد المرجع ذاته. لا تتولّد أي SQL. يمكنك التحقق من ذلك بتفعيل تسجيل SQL في application.properties:

spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.SQL=DEBUG

التفاعل مع التحميل الكسول

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

@Transactional public void sharedAssociation(Long productId) { OrderItem item1 = em.find(OrderItem.class, 1L); OrderItem item2 = em.find(OrderItem.class, 2L); // إذا أشار كلا العنصرين إلى صف المنتج ذاته: Product p1 = item1.getProduct(); // يُطلق تحميلًا كسولًا ويُخزّن (Product, productId) Product p2 = item2.getProduct(); // يُقدَّم من الذاكرة — نفس المرجع System.out.println(p1 == p2); // true }

هذا ليس مجرد ميزة أداء. إنه يمنع حالات شذوذ الكتابة الضائعة: إذا عدّلت p1، يعكس p2 التعديل فورًا لأنهما الكائن ذاته في الذاكرة.

تجاوز الذاكرة: عندما تحتاج بيانات حديثة

بما أن ذاكرة المستوى الأول مقيّدة بـEntityManager الحالي، فهي لا تكون قديمة أبدًا بالنسبة للتعديلات التي أجريتها أنت في المعاملة ذاتها. غير أن معاملة أخرى على خيط مختلف ربما تعدّل الصف ذاته بالتوازي. إذا احتجت إلغاء الحالة المخزَّنة وإعادة التحميل من قاعدة البيانات، استخدم refresh():

@Transactional public void reloadFromDatabase(Long orderId) { Order order = em.find(Order.class, orderId); // ... قد تمر فترة؛ ربما عدّلت معاملة أخرى هذا الصف ... em.refresh(order); // يُطلق SELECT ويُستبدل الحالة الموجودة في الذاكرة System.out.println("الحالة المحدَّثة: " + order.getStatus()); }
لا تُفرط في استخدام refresh(). فهي تصل إلى قاعدة البيانات دائمًا وتتجاوز فحوصات الإصدار المتعلقة بالقفل التفاؤلي. احتفظ باستخدامها للحالات التي تعلم فيها يقينًا أن عملية خارجية غيّرت الصف — مثلًا بعد استدعاء إجراء مخزَّن أو مهمة دفعية تعمل خارج جلسة JPA.

إخراج إدخالات فردية من الذاكرة

يمكنك أيضًا إخراج كيان محدد من خريطة الهوية دون إعادة تحميله، باستخدام detach():

Order order = em.find(Order.class, 42L); em.detach(order); // حُذف من سياق الاستمرارية Order fresh = em.find(Order.class, 42L); // miss في الذاكرة — SELECT جديدة تُطلق System.out.println(order == fresh); // false — نسختان مختلفتان الآن

بعد detach()، لم يعد Hibernate يتتبّع التعديلات التي تطرأ على المرجع القديم عبر آلية الفحص للاتساخ. يدخل الكيان في حالة منفصل التي تناولها الدرس السابق.

مسح السياق كاملًا

في عمليات الدُّفعات — استيراد آلاف الصفوف في حلقة — تصبح ذاكرة المستوى الأول عبئًا. كل كيان تثبّته يتراكم في الذاكرة، ويتكرر فحص الاتساخ على جميعها وقت التدفق. النمط المعياري هو التدفق والمسح الدوري:

@Transactional public void batchImport(List<ProductDto> dtos) { int batchSize = 50; for (int i = 0; i < dtos.size(); i++) { Product p = new Product(); p.setName(dtos.get(i).getName()); p.setPrice(dtos.get(i).getPrice()); em.persist(p); if ((i + 1) % batchSize == 0) { em.flush(); // اكتب عمليات الإدراج المتراكمة إلى قاعدة البيانات em.clear(); // أخرج جميع الكيانات — أعد تهيئة خريطة الهوية } } }

بعد clear()، تصبح خريطة الهوية فارغة. أي مرجع كيان احتفظت به قبل هذا الاستدعاء بات منفصلًا الآن. يُبقي هذا النمط استخدام الكومة ثابتًا بصرف النظر عن عدد الصفوف المعالَجة.

ادمج flush() وclear() في دُفعات، ولا تستخدم clear() وحدها. إذا مسحت دون تدفق أولًا، تُهمَل عمليات الإدراج أو التحديث المعلّقة ولا تُكتب إلى قاعدة البيانات. دائمًا دفّق قبل أن تمسح.

الذاكرة واستعلامات JPQL

ثمة دقة مهمة: استعلامات JPQL (وكذلك Criteria API) تذهب مباشرةً إلى قاعدة البيانات. إنها لا تستشير ذاكرة المستوى الأول قبل تنفيذ SQL. غير أنه بعد استلام Hibernate لصفوف النتائج يدمجها في خريطة الهوية. إذا كان أحد الصفوف المُعادة موجودًا مسبقًا في الذاكرة، يُعيد Hibernate النسخة المخزَّنة (متجاهلًا القيم الجديدة من نتيجة الاستعلام، إلا إذا استخدمت LockModeType.PESSIMISTIC_WRITE أو نفّذت refresh() صراحةً). قد يُفضي هذا إلى قراءات قديمة إذا عدّلت كيانًا ثم استعلمت عنه ضمن المعاملة ذاتها دون تدفق مسبق:

@Transactional public void staleReadDemo(Long orderId) { Order order = em.find(Order.class, orderId); order.setStatus("CANCELLED"); // متّسخ — لم يُدفق بعد // دون em.flush() أولًا، تعكس نتيجة JPQL ما زلت // الحالة القديمة لأن Hibernate يُعيد النسخة المخزَّنة (المتّسخة): List<Order> results = em.createQuery( "SELECT o FROM Order o WHERE o.id = :id", Order.class) .setParameter("id", orderId) .getResultList(); System.out.println(results.get(0).getStatus()); // "CANCELLED" — من الذاكرة }

يُدفق Hibernate تلقائيًا قبل الاستعلامات في وضع FlushModeType.AUTO (الافتراضي) فقط حين يستهدف الاستعلام نوع كيان لديه تعديلات معلّقة. هذا صحيح في الغالب، لكن فهم الآلية يُساعدك على استيعاب نتائج الاستعلامات في المعاملات المعقّدة.

الخلاصة

ذاكرة التخزين المؤقت من المستوى الأول هي خريطة هوية إلزامية مرتبطة بـEntityManager تُلغي عمليات البحث المكرّرة في SQL وتضمن هوية الكائن داخل سياق الاستمرارية. استخدم refresh() لإجبار إعادة التحميل من قاعدة البيانات، وdetach() لإخراج كيان واحد، ونمط flush() + clear() لإبقاء عمليات الدُّفعات موفّرة للذاكرة. الوعي بكيفية تفاعل استعلامات JPQL مع الذاكرة — تجاوزها عند الخروج ودمج النتائج فيها عند العودة — ضروري لتفادي أخطاء القراءات القديمة التي يصعب تشخيصها.