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

المعاملات للقراءة فقط والتحسين

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

المعاملات للقراءة فقط والتحسين

كل استدعاء لقاعدة البيانات يجري داخل معاملة (transaction) سواء طلبت ذلك أم لا. حين يقتصر الاستدعاء على قراءة البيانات، يصبح قفل الصفوف وتتبع الحالة المتغيّرة وإعداد سجل التراجع (rollback journal) مجرد عبء — عمل تؤديه قاعدة البيانات و Hibernate دون أي فائدة. يُعدّ الخيار readOnly في @Transactional الإشارة التي تتيح لكلا الطبقتين تخطّي هذا العمل والعمل بسرعة أكبر.

ما الذي يفعله readOnly فعلًا

يُشغّل تعيين @Transactional(readOnly = true) على إحدى الدوال تحسينات على طبقتين مستقلتين:

  1. طبقة Hibernate / JPA — يتحوّل سياق المثابرة (persistence context) إلى وضع الإخلاء NEVER. يعني ذلك أن Hibernate لن ينفّذ فحص الحالة المتغيّرة (dirty checking) عند الإخلاء: لن يفحص جميع الكيانات المدارة بحثًا عن تغييرات، ولن يحسب مجموعات التغييرات، ولن يولّد جمل UPDATE. تظل ذاكرة التخزين المؤقت من المستوى الأول تحمّل الكيانات لكنها تُعاملها كلقطات ثابتة طوال دورة حياة الدالة.
  2. طبقة JDBC / قاعدة البيانات — يمرّر Spring تلميح القراءة فقط إلى اتصال JDBC (connection.setReadOnly(true)). تستخدم كثير من قواعد البيانات (PostgreSQL وMySQL InnoDB وOracle) هذا التلميح لتجنّب أقفال الكتابة على مستوى الصف، أو تخطّي إدخالات سجل التراجع، أو توجيه الاتصال إلى نسخة القراءة. يعتمد السلوك الدقيق على المشغّل وقاعدة البيانات، لكن الفائدة حقيقية وقابلة للقياس تحت الضغط.
وضع الإخلاء NEVER مقابل COMMIT: في المعاملة العادية القابلة للقراءة والكتابة تستخدم Hibernate وضع الإخلاء AUTO — تُخلي الحالة المتغيّرة قبل كل استعلام حتى ترى دائمًا كتاباتك الخاصة. مع readOnly = true تتخطى Hibernate هذا العمل كليًا. إن غيّرت كيانًا مُدارًا داخل معاملة للقراءة فقط عن طريق الخطأ، فلن يُحفظ التغيير بصمت تام — لا خطأ، فقط تحديث مفقود. ذلك خطأ برمجي وليس ميزة.

تعريف الدوال للقراءة فقط في خدمة

من الأنماط الشائعة وضع علامة @Transactional على مستوى الفئة كقراءة فقط، ثم تجاوز الدوال التي تُعدّل البيانات بالإعداد الافتراضي للكتابة:

package com.example.shop.service; import com.example.shop.model.Order; import com.example.shop.repository.OrderRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service @Transactional(readOnly = true) // الافتراضي لجميع الدوال: للقراءة فقط public class OrderQueryService { private final OrderRepository repo; public OrderQueryService(OrderRepository repo) { this.repo = repo; } // ترث readOnly = true من الفئة — لا فحص حالة، لا إخلاء public List<Order> findByCustomer(Long customerId) { return repo.findByCustomerId(customerId); } public Order findById(Long id) { return repo.findById(id) .orElseThrow(() -> new IllegalArgumentException("Order not found: " + id)); } // تجاوز: هذه الدالة تكتب بيانات، لذا نستخدم الافتراضي (readOnly = false) @Transactional public Order createOrder(Order order) { return repo.save(order); } }

يعمل التعليق التوضيحي على مستوى الفئة بوصفه إعدادًا افتراضيًا. أي دالة لا تملك @Transactional خاصًا بها ترث إعداد الفئة. أي دالة تحتاج إلى الكتابة تُضيف فقط @Transactional — الذي يكون افتراضيًا readOnly = false — لتجاوز الإعداد الافتراضي للفئة.

افصل خدمات القراءة عن خدمات الكتابة. تذهب بعض الفرق أبعد من ذلك فتنشئ فئتين منفصلتين: *QueryService (تُعلَّم الفئة بـ readOnly = true) و*CommandService (تستخدم معاملات الكتابة). يُبقي هذا الكودَ موثّقًا ذاتيًا، ويُسهّل توجيه القراءات إلى نسخة مخصصة لها، ويقلّل من خطر الكتابات العرضية في مسارات الاستعلام.

مستودعات القراءة فقط مع Spring Data JPA

يتيح Spring Data JPA الإعلان عن مستودع للقراءة فقط بتوسيع Repository (لا JpaRepository) وتعليق دوال الاستعلام:

package com.example.shop.repository; import com.example.shop.model.Product; import org.springframework.data.repository.Repository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface ProductReadRepository extends Repository<Product, Long> { Optional<Product> findById(Long id); List<Product> findByCategory(String category); }

بتوسيع واجهة علامة Repository الأساسية بدلًا من JpaRepository، لا تتوفر أي دوال للتعديل (save وdelete وغيرها) في هذه الواجهة، مما يُطبّق عقد القراءة فقط في وقت التصريف.

الإسقاطات: جلب ما تحتاجه فحسب

تتناسب معاملات القراءة فقط طبيعيًا مع الإسقاطات (projections). بدلًا من تحميل رسم بياني كامل للكيان في سياق المثابرة، يُعيد الإسقاط فقط الحقول التي يحتاجها المُستدعي. يُقلّل هذا من عدد الأعمدة التي يجلبها الاستعلام، ويُبقي كائنات الكيان أصغر حجمًا، ويتجنّب مفاجآت التحميل الكسول:

// واجهة الإسقاط — تُولّد Hibernate وكيلًا في وقت التشغيل public interface OrderSummary { Long getId(); String getStatus(); java.math.BigDecimal getTotalAmount(); } // في المستودع @Transactional(readOnly = true) List<OrderSummary> findSummariesByCustomerId(Long customerId);

يستنتج Spring Data JPA قائمة SELECT من أسماء جالبات الواجهة. تجلب SQL المُولَّدة فقط id وstatus وtotal_amount — لا الصف الكامل من جدول ORDER ولا كل الجداول المرتبطة به.

قياس الأثر

تبرز مكاسب readOnly = true بوضوح في حالتين:

  • رسوم بيانية كبيرة للكيانات: إذا حمّل استعلام واحد مئات الكيانات مع ارتباطاتها، فإن تخطّي فحص الحالة المتغيّرة عند الإخلاء يوفّر عددًا ملحوظًا من دورات المعالج لكل طلب.
  • أعباء العمل ثقيلة القراءة: إذا كان 80–90% من حركة المرور قراءات — وهو الوضع الشائع في معظم خدمات الويب — فإن تحسين مسار القراءة يُضاعف مفعوله على الإنتاجية.

استخدم إحصاءات Hibernate أو أداة مثل Datasource-Proxy لتسجيل عدد جمل SQL لكل طلب. ستلاحظ في الغالب أن دالة خدمة ساذجة تُصدر استعلامات N+1 أو فحوص إخلاء زائدة يُلغيها readOnly = true مقترنًا بالإسقاطات كليًا.

# application.properties — تفعيل إحصاءات Hibernate للتحليل spring.jpa.properties.hibernate.generate_statistics=true logging.level.org.hibernate.stat=DEBUG

نموذج لمخرجات الإحصاءات بعد تفعيلها — لاحظ أن عدد فحوص الحالة المتغيّرة يصل إلى الصفر في معاملات القراءة فقط:

// معاملة كتابة (readOnly = false): // Sessions opened: 1, Flushes: 1, Dirty entities checked: 47 // معاملة للقراءة فقط: // Sessions opened: 1, Flushes: 0, Dirty entities checked: 0

المزالق الشائعة

  • تعديل الكيانات داخل معاملة للقراءة فقط: كما أُشير أعلاه، تُلغى التغييرات بصمت. إذا رأيت حفظًا لا يُثبَّت، تحقق مما إذا كانت المعاملة المحيطة للقراءة فقط.
  • استدعاء دالة للقراءة فقط من دالة كتابة في نفس الحبة (bean): لا يعترض AOP المستند إلى الوكيل في Spring الاستدعاءات الذاتية. إذا استدعت الدالة A (قراءة-كتابة) مباشرةً this.methodB() (قراءة فقط) في نفس الفئة، تعمل methodB ضمن معاملة A لا ضمن معاملة جديدة للقراءة فقط. استخدم الانتشار (propagation) أو إعادة الهيكلة في حبات مختلفة لتجنّب ذلك.
  • افتراض أن جميع قواعد البيانات تحترم التلميح: تتجاهل H2 (المستخدمة شيوعًا في الاختبارات) تلميح القراءة فقط. قد يبدو الكود الذي يُعدّل كيانات داخل معاملة readOnly = true أنه يعمل في الاختبارات لكنه يفشل بصمت في الإنتاج.
فخ ضياع الكتابة بصمت: إذا كان تطبيقك يعتمد على كتابات داخل معاملة readOnly = true لتعمل "أحيانًا"، فلديك خطأ برمجي كامن في الإنتاج. عَلِّم دائمًا دوال الخدمة التي تُعدّل البيانات صراحةً بـ @Transactional (الذي يكون قراءة-كتابة افتراضيًا)، وشغّل اختبارات التكامل على قاعدة بيانات حقيقية (PostgreSQL / MySQL) تُطبّق التلميح.

الخلاصة

لا يُعدّ @Transactional(readOnly = true) مجرد تلميح — بل هو عقد يُخبر Hibernate ومشغّل JDBC بالتخلّص من العمل غير الضروري. تُعطّل Hibernate فحص الحالة المتغيّرة والإخلاء، وتتجنّب قاعدة البيانات أقفال الكتابة وإدخالات التراجع، وتتحسّن الإنتاجية الإجمالية لمسارات القراءة. استخدمه كإعداد افتراضي في خدمات الاستعلام فقط والمستودعات، اقترنه بالإسقاطات لتقليل نقل البيانات، وانتبه للمزلقَين الرئيسيين: ضياع الكتابة بصمت، وتجاوز الاستدعاء الذاتي.