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

القفل التفاؤلي مع @Version

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

القفل التفاؤلي مع @Version

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

الفكرة الأساسية: أعمدة الإصدار

يعمل القفل التفاؤلي بوضع رمز إصدار على كل صف. تدير JPA / Hibernate هذا تلقائيًا عند تعليق حقل بـ @Version:

import jakarta.persistence.*; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Version private int version; // تُديره Hibernate بالكامل private String status; private BigDecimal total; // getters / setters ... }

لا تقرأ حقل version أو تكتب إليه بنفسك أبدًا. تقرأ Hibernate قيمته عند SELECT، وعند إصدار UPDATE تُضيف شرطًا على الإصدار:

-- ما تُنشئه Hibernate لعملية الحفظ: UPDATE orders SET status = 'SHIPPED', version = 2 -- زيادة بمقدار 1 WHERE id = 42 AND version = 1; -- التحقق: هل لا يزال 1؟

إذا تطابقت جملة WHERE مع صفر صفوف — لأن معاملة أخرى رفعت الإصدار إلى 2 بالفعل — رمت Hibernate الاستثناء jakarta.persistence.OptimisticLockException الذي تُغلّفه Spring في org.springframework.orm.ObjectOptimisticLockingFailureException.

اختيار نوع حقل الإصدار المناسب

يدعم التعليق @Version عدة أنواع Java:

  • int / Integer — يزداد بمقدار 1؛ الاختيار الأبسط لمعظم الكيانات.
  • long / Long — استخدمه عند تحديث الكيان بشكل متكرر جدًا وتخشى تجاوز نطاق العدد الصحيح (نادر لكن ممكن على مدى عقود).
  • short / Short — نادر الاستخدام؛ نطاق صغير.
  • java.sql.Timestamp — تضبطها Hibernate على طابع زمني لقاعدة البيانات الحالية عند كل تحديث. تجنّبه في البيئات المُجمَّعة أو السحابية حيث قد يتسبب الانجراف الزمني بين العقد في تعارضات وهمية أو تعارضات مفقودة.
فضّل الإصدارات الرقمية. النوعان Integer أو Long حتميان ومستقلان عن قاعدة البيانات ومحصّنان ضد الانجراف الزمني. استخدم Long إذا كان الكيان صفًا ساخنًا يُحدَّث مرات عديدة في الثانية؛ وإلا يكفي int.

التعامل مع OptimisticLockException في طبقة الخدمة

النمط المعياري هو اصطياد استثناء التعارض وإعادة المحاولة عددًا محدودًا من المرات:

import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { private final OrderRepository orderRepository; public OrderService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Retryable( retryFor = OptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2) ) @Transactional public void shipOrder(Long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new EntityNotFoundException("Order " + orderId)); if (!order.getStatus().equals("PAID")) { throw new IllegalStateException("Cannot ship unpaid order"); } order.setStatus("SHIPPED"); // تُدفع Hibernate هنا؛ تناقض الإصدار يرمي OptimisticLockingFailureException } }

يأتي @Retryable من Spring Retry (مكتبة spring-retry في مسار الفئات مع @EnableRetry على فئة الإعدادات). التراجع الأسي — 50 مللي ثانية، 100، 200 — يُقلل من ظاهرة القطيع الرعدي عندما تتصادم خيوط كثيرة على الصف نفسه.

أعد القراءة داخل كل محاولة. يُعاد تشغيل الدالة المعلَّمة بـ @Transactional بالكامل، مما يعني إعادة جلب الكيان بأحدث إصدار. لا تُخزّن الكيان خارج حدود المعاملة وتعيد استخدامه عبر المحاولات — ذلك يُبطل الآلية برمّتها.

القفل التفاؤلي في Spring Data JPA

تحترم Spring Data REST وطرق المستودعات المخصصة @Version تلقائيًا. يمكنك أيضًا تصريح @Lock بوضع القفل التفاؤلي في طرق الاستعلام:

import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import jakarta.persistence.LockModeType; public interface OrderRepository extends JpaRepository<Order, Long> { // save() الافتراضية تحترم @Version — لا حاجة لتعليق إضافي. // قفل تفاؤلي صريح — مفيد عند الرغبة في التوضيح في طريقة استعلام: @Lock(LockModeType.OPTIMISTIC) Optional<Order> findWithLockById(Long id); // OPTIMISTIC_FORCE_INCREMENT يرفع الإصدار حتى عند القراءة، // لحماية الجذور الكلية من الكتابات الوهمية للكيانات الأبناء: @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) Optional<Order> findForAggregateUpdate(Long id); }

OPTIMISTIC_FORCE_INCREMENT هو الاختيار الصحيح حين يعمل الكيان كـ جذر كلي (aggregate root): تحميل أحد الآباء وتعديل كيان ابن فقط لن يحدّث إصدار الأب عادةً، تاركًا الكل غير محمي. الإجبار على زيادة إصدار الجذر يضمن اكتشاف أي تعديل متزامن على الكل الكامل.

ما لا يحمي منه القفل التفاؤلي

يمنع القفل التفاؤلي شذوذ التحديث المفقود داخل وحدة عمل JPA واحدة. لكنه لا:

  • يمنع معاملتين من قراءة بيانات قديمة في آنٍ واحد — لا يُكتشف التعارض إلا عند الإيداع.
  • يساعد عند تعديل الصف من خارج JPA (SQL خام، خدمة أخرى) إلا إذا حدّثت تلك الأدوات عمود الإصدار أيضًا.
  • يحل مشاكل التنافس في سيناريوهات التعارض العالي. إذا كان الصف نفسه يُحدَّث عشرات المرات في الثانية، ستفشل معظم المحاولات ويزداد العبء. استخدم القفل المتشائم (الدرس 6) للصفوف الساخنة ذات التعارضات المضمونة.
لا تكشف حقل الإصدار في واجهة برمجية عامة دون استراتيجية. إذا أرسل عميل REST إصدارًا قديمًا، سيحصل على خطأ 409 أو 500 ما لم يُعيّن المتحكم OptimisticLockingFailureException إلى استجابة HTTP مناسبة. أضف دائمًا معالجًا عامًا للاستثناءات أو دالة @ExceptionHandler لهذا الاستثناء.

الخصائص الأدائية

القفل التفاؤلي رخيص للغاية حين تكون التعارضات نادرة:

  • لا قفل يُمسك بين القراءة والكتابة — الصف لا يُحجب أبدًا، مما يسمح للقارئين والكاتبين بالمضي معًا دون انتظار.
  • جملة WHERE إضافية — العبء الوحيد هو مقارنة عدد صحيح واحدة في شرط UPDATE، وهو أمر هيّن.
  • عمود إضافي — عمود INT بحجم 4 بايت لكل جدول؛ لا تُضف فهرسًا إلا إذا استعلمت بحسب الإصدار (نادرًا مفيد).
  • تكلفة إعادة المحاولة — إذا كانت التعارضات متكررة، تُضيف حلقات إعادة المحاولة رحلات ذهاب وإياب. راقب معدل التصادم؛ إذا تجاوز التعارض ~5% فهذا مؤشر على مراجعة استراتيجية القفل.

الخلاصة

أضف @Version إلى أي كيان قد يُحدَّث في وقت واحد. تتولى Hibernate إدارة زيادة الإصدار واكتشاف التصادمات تلقائيًا — مسؤوليتك الوحيدة هي اصطياد OptimisticLockingFailureException وإعادة المحاولة أو إظهار خطأ ذي معنى للمُستدعي. بالنسبة للجذور الكلية، استخدم OPTIMISTIC_FORCE_INCREMENT لحماية تغييرات الكيانات الأبناء. القفل التفاؤلي هو الخيار الافتراضي الصحيح لمعظم أعباء عمل CRUD؛ تحوّل إلى الأقفال المتشائمة فقط حين يكشف التوصيف أن التعارضات متكررة جدًا لدرجة تؤثر على الإنتاجية.