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

مشروع: طبقة بيانات محمية بالمعاملات ومُحسَّنة الأداء

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

مشروع: طبقة بيانات محمية بالمعاملات ومُحسَّنة الأداء

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

نظرة عامة على النطاق

تُدير الخدمة قطبين رئيسيين: Product (مع عداد المخزون) وOrder (مع بنوده). العملية الحرجة هي تقديم طلب: حجز المخزون وتخزين الطلب ونشر حدث — كل ذلك بصورة ذرية. العمليات الثانوية تشمل تصفح الكتالوج والتحقق من حالة الطلب، وهي عمليات يجب أن تكون سريعة وألّا تُقفل الصفوف دون ضرورة.

مبدأ التصميم: افصل مسارات القراءة عن مسارات الكتابة في طبقة الخدمة. تحصل عمليات القراءة على @Transactional(readOnly = true)؛ وتحصل الكتابة على النشر وعزل المناسبَين. هذا الانضباط الواحد يُزيل عشرات الأخطاء العرضية في الأداء والقفل.

طبقة الكيانات

يستخدم القطبان القفلَ التفاؤلي عبر @Version لمعالجة الطلبات المتزامنة على المنتج نفسه دون اللجوء إلى أقفال على مستوى قاعدة البيانات في كل قراءة.

// Product.java package com.example.shop.domain; import jakarta.persistence.*; import java.math.BigDecimal; @Entity @Table(name = "products") @org.hibernate.annotations.Cache( usage = org.hibernate.annotations.CacheConcurrencyStrategy.READ_WRITE) public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String sku; private String name; private BigDecimal price; private int stockQuantity; @Version private int version; // رمز القفل التفاؤلي // getters / setters محذوفة للإيجاز public void reserveStock(int qty) { if (this.stockQuantity < qty) { throw new InsufficientStockException(sku, qty, stockQuantity); } this.stockQuantity -= qty; } }
// Order.java package com.example.shop.domain; import jakarta.persistence.*; import java.time.Instant; import java.util.ArrayList; import java.util.List; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String customerId; @Enumerated(EnumType.STRING) private OrderStatus status; private Instant createdAt; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List<OrderLine> lines = new ArrayList<>(); // مصنع + توابع النطاق public static Order create(String customerId) { Order o = new Order(); o.customerId = customerId; o.status = OrderStatus.PENDING; o.createdAt = Instant.now(); return o; } public void addLine(Product p, int qty) { lines.add(OrderLine.of(this, p, qty)); } }
ضع حقل @Version على جذر المجموع (Product) لا على كل كيان فرعي. تزيد Hibernate قيمة الإصدار عند أي تغيير ضمن المجموع، لذا تحصل على اكتشاف التعارض دون نشر أعمدة الإصدار في كل مكان.

طبقة المستودعات

المستودعات هي واجهات Spring Data JPA. القرار التصميمي الأساسي هو تعريف استعلامات JPQL تستخدم fetch join للمجموعات المطلوبة في حالة استخدام معينة — استعلام واحد بدلًا من N+1.

// ProductRepository.java package com.example.shop.repository; import com.example.shop.domain.Product; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; import jakarta.persistence.LockModeType; import jakarta.persistence.QueryHint; import java.util.List; import java.util.Optional; public interface ProductRepository extends JpaRepository<Product, Long> { // تصفح الكتالوج بدعم الذاكرة المؤقتة — للقراءة فقط، بدون قفل @QueryHints(@QueryHint( name = org.hibernate.jpa.HibernateHints.HINT_CACHEABLE, value = "true")) @Query("SELECT p FROM Product p WHERE p.stockQuantity > 0 ORDER BY p.name") List<Product> findAvailable(); // تفاؤلي — افتراضي؛ يُستخدم عند تقديم الطلب Optional<Product> findBySku(String sku); // كتابة متشائمة — تُستخدم بواسطة مهمة إعادة حساب المخزون @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdForUpdate(Long id); }

طبقة الخدمة

تربط الخدمة كل شيء معًا. لاحظ الفصل بين الاهتمامات: توابع القراءة للقراءة فقط مع دعم الذاكرة المؤقتة للاستعلامات؛ وتابع تقديم الطلب بمعاملة كاملة للقراءة والكتابة مع نشر REQUIRED الافتراضي.

// OrderService.java package com.example.shop.service; import com.example.shop.domain.*; import com.example.shop.repository.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Isolation; import java.util.List; @Service public class OrderService { private final ProductRepository productRepo; private final OrderRepository orderRepo; private final OrderEventPublisher eventPublisher; public OrderService(ProductRepository productRepo, OrderRepository orderRepo, OrderEventPublisher eventPublisher) { this.productRepo = productRepo; this.orderRepo = orderRepo; this.eventPublisher = eventPublisher; } // ---- مسار القراءة ---------------------------------------- @Transactional(readOnly = true) public List<Product> getAvailableProducts() { return productRepo.findAvailable(); // يصل إلى L2 + ذاكرة الاستعلامات } @Transactional(readOnly = true) public Order getOrder(Long id) { return orderRepo.findWithLines(id) // fetch-join يتجنب N+1 .orElseThrow(() -> new OrderNotFoundException(id)); } // ---- مسار الكتابة --------------------------------------- @Transactional( isolation = Isolation.READ_COMMITTED, // افتراضي لـ MySQL/Postgres rollbackFor = Exception.class) // تراجع عند الاستثناءات المتحقق منها أيضًا public Order placeOrder(String customerId, List<OrderRequest> requests) { Order order = Order.create(customerId); for (OrderRequest req : requests) { Product p = productRepo.findBySku(req.sku()) .orElseThrow(() -> new ProductNotFoundException(req.sku())); p.reserveStock(req.quantity()); // تغيير؛ @Version تحرس التزامن order.addLine(p, req.quantity()); } Order saved = orderRepo.save(order); // انشر بعد التدفق وقبل الالتزام // إذا أطلق النشر استثناءً تراجعت المعاملة eventPublisher.orderPlaced(saved); return saved; } }
OptimisticLockException عند التنافس: إذا حاول طلبان حجز مخزون لنفس رقم SKU في وقت واحد، تُطلق Hibernate استثناء OptimisticLockException للخاسر. تعامل مع ذلك في المتحكم أو في غلاف إعادة المحاولة — لا تبتلعه بصمت أبدًا. النمط الشائع هو اصطياد الاستثناء وإعادة HTTP 409 Conflict لكي يُعيد العميل المحاولة.

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

أضف Caffeine كمزوّد L2 لـ Hibernate وفعّل تخزين الاستعلامات في application.properties:

# application.properties 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.JCacheRegionFactory spring.jpa.properties.hibernate.javax.cache.provider=\ com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider # مدة الصلاحية والحجم لكل منطقة (ملف إعداد Caffeine أو مضمّن) spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=5m

التعليق @Cache على Product يُسجّله باستراتيجية READ_WRITE — تحصل Hibernate على قفل ناعم خلال الكتابة كي لا يرى القرّاء بيانات غير ملتزمة من الذاكرة المؤقتة.

قابلية المراقبة: اكتشاف المشاكل قبل المستخدمين

فعّل إحصاءات Hibernate واعرضها عبر Actuator لمراقبة معدلات إصابة الذاكرة المؤقتة وأعداد الاستعلامات في الإنتاج:

# application.properties spring.jpa.properties.hibernate.generate_statistics=true management.endpoints.web.exposure.include=health,metrics,info

ثم استعلم /actuator/metrics/hibernate.second.level.cache.hit.ratio أو اربط Micrometer بـ Prometheus. تُظهر الخدمة السليمة نسبة إصابة ذاكرة مؤقتة تجاوز 0.8 لقراءات الكتالوج وصفرًا من استعلامات N+1 (عدد الاستعلامات لكل طلب يساوي ثابتًا صغيرًا بغض النظر عن حجم مجموعة النتائج).

أضف اختبار تكامل يُثبت عدد الاستعلامات. استخدم StatisticsService أو Datasource-Proxy في إعداد الاختبار لعدّ جمل SQL. إذا أزال تحسين لاحق اتفاقًا fetch join وأدخل تراجعًا في N+1، سيُمسك الاختبار به قبل وصوله إلى الإنتاج.

تجميع كل شيء: قائمة تحقق الأداء

  1. كل مسار كتابة يحمل @Transactional صريحًا مع rollbackFor = Exception.class.
  2. كل مسار قراءة يستخدم @Transactional(readOnly = true) — هذا يُخبر Hibernate ومشغّل قاعدة البيانات بتجاوز التحقق من القذارة واستخدام تلميح اتصال للقراءة فقط.
  3. استراتيجية الجلب: كل المجموعات LAZY افتراضيًا؛ استخدم JOIN FETCH المسمّى في JPQL في تابع المستودع الذي يحتاج المجموعة.
  4. التحكم في التزامن: القفل التفاؤلي (@Version) للتنافس العالي على القراءة ومنخفض الكتابة؛ القفل المتشائم فقط لمهام المخزون التي تتطلب وصولًا متسلسلًا.
  5. التخزين المؤقت: ذاكرة L2 للبيانات المرجعية كثيرة القراءة (المنتجات والفئات)؛ ذاكرة الاستعلامات فقط للاستعلامات المُعاملة بالمعاملات الثابتة؛ لا تُخزّن مؤقتًا البيانات المعاملاتية المتغيرة أبدًا.
  6. الإحصاءات في الإنتاج: إحصاءات Hibernate + مقاييس Actuator تُعطي إنذارًا مبكرًا بتخبّط الذاكرة المؤقتة أو ارتفاع أعداد الاستعلامات.

الخلاصة

طبقة الثبات المصمّمة جيدًا ليست حيلة ذكية واحدة — بل هي مزيج منضبط من حدود المعاملات والعزل الصحيح والقفل التفاؤلي والتخزين المؤقت الموجّه والمراقبة المستمرة. الأنماط في هذا المشروع — توابع خدمة مفصولة للقراءة والكتابة، ومستودعات بـ fetch-join، ومجاميع محمية بـ @Version، وتخزين L2 واستعلامات مؤقت بدعم الإحصاءات — هي الخط الأساسي الذي تُبنى عليه خدمات Spring Boot الإنتاجية. مع هذا الأساس في مكانه، أصبح بإمكانك بناء طبقات بيانات صحيحة في ظل التزامن وسريعة في ظل الحمل.