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

القفل التشاؤمي

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

القفل التشاؤمي

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

مع القفل التشاؤمي تقول للقاعدة: "سأعدّل هذا الصف — قفله الآن قبل أي شيء آخر." ستنتظر المعاملات الأخرى التي تطلب قفل نفس الصف حتى تُيدع معاملتك أو تُرجع. لا توجد أعمدة إصدار ولا حلقات إعادة محاولة ولا OptimisticLockException في النهاية — إما تحصل على القفل وتكمل، أو تنتظر.

متى يكون القفل التشاؤمي هو الخيار الصحيح

تخيّل نظام حجز مقاعد في الطائرة. يُحمّل وكيلان في آنٍ واحد آخر مقعد متاح. مع القفل التفاؤلي سيفشل أحدهما عند الإيداع وسيحتاج إعادة المحاولة — لكن حينها يكون المقعد محجوزاً وعليك إظهار رسالة خطأ. بالنسبة لحجز مقعد هذا مقبول. أما في معاملة مالية حيث قد تقرأ رسالة الفشل "حاولنا خصم حسابك مرتين" فهذا غير مقبول. يناسب القفل التشاؤمي السيناريوهات التالية:

  • خصم المخزون أو الرصيد حيث القراءات المتزامنة الزائدة خطيرة.
  • سير العمل الذي يتطلب "استعارة" سجل حصرياً (قوائم الوظائف، سلاسل الموافقة).
  • المعاملات القصيرة ذات التنافس الكثيف حيث القفل الفوري أرخص من حلقة إعادة المحاولة.
  • أي حالة يكون فيها تكلفة التراجع وإعادة المحاولة من جانب المستخدم غير مقبولة.

أوضاع القفل التشاؤمي في JPA

تُعرّف JPA ثلاثة أوضاع قفل تشاؤمي في jakarta.persistence.LockModeType:

  • PESSIMISTIC_READ — يكتسب قفلاً مشتركاً. يمكن للمعاملات الأخرى أيضاً القراءة (واكتساب أقفالها المشتركة) لكن لا يمكن لأحد الكتابة حتى تُحرَّر جميع الأقفال المشتركة. استخدمه حين تحتاج قراءة متسقة لا يجب أن تتغير لكنك لست بالضرورة ستكتب.
  • PESSIMISTIC_WRITE — يكتسب قفلاً حصرياً (SELECT ... FOR UPDATE في معظم قواعد البيانات). لا تستطيع أي معاملة أخرى القراءة بقفل أو الكتابة في الصف حتى تُيدع معاملتك. هذا هو الخيار الأكثر شيوعاً لأي نمط "اقرأ ثم عدّل".
  • PESSIMISTIC_FORCE_INCREMENT — قفل حصري بالإضافة إلى زيادة حقل @Version حتى لو لم تتغير أي حقول. مفيد لتنسيق جذور التجميع في سيناريوهات القفل المختلطة.

اكتساب قفل تشاؤمي مع EntityManager

يمكنك طلب قفل عند تحميل كيان، أو قفل كيان محمّل مسبقاً.

import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class InventoryService { private final EntityManager em; public InventoryService(EntityManager em) { this.em = em; } @Transactional public void reserveStock(Long productId, int qty) { // قفل الصف فوراً عند التحميل — لا أحد غيرنا يستطيع الكتابة فيه Product product = em.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE); if (product.getStock() < qty) { throw new InsufficientStockException("Not enough stock for product " + productId); } product.setStock(product.getStock() - qty); // em.merge() غير مطلوب — الكيان مُدار وHibernate يُفرغه عند الإيداع } }

تترجم Hibernate القفل PESSIMISTIC_WRITE إلى قفل حصري على مستوى قاعدة البيانات. على PostgreSQL يصبح:

SELECT id, stock, version FROM products WHERE id = ? FOR UPDATE

أي معاملة أخرى تحاول قفل نفس الصف ستُعلَّق بواسطة قاعدة البيانات حتى تُيدع أو تُرجع المعاملة الأولى.

استخدام الأقفال التشاؤمية في Spring Data JPA Repositories

يمكنك إضافة تعليق @Lock على دوال المستودع لتطبيق وضع القفل بشكل تعريفي. ادمجه مع @QueryHints لضبط مهلة حتى لا يتوقف التطبيق إلى أجل غير مسمى عند التنافس الكثيف.

import jakarta.persistence.LockModeType; import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.QueryHints; import java.util.Optional; public interface AccountRepository extends JpaRepository<Account, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) Optional<Account> findById(Long id); }
تلميح مهلة القفل: يُفسَّر تلميح jakarta.persistence.lock.timeout بواسطة مشغّل قاعدة البيانات الأساسي. القيمة 0 تعني "افشل فوراً إذا تعذّر اكتساب القفل" (لا انتظار). القيمة 3000 تعني الانتظار حتى 3 ثوانٍ. إذا انتهت المهلة ترمي JPA استثناء LockTimeoutException وهو فئة فرعية من PessimisticLockException. اضبط مهلة دائماً في الإنتاج لمنع استنزاف الخيوط.

القفل في استعلامات JPQL

يمكنك أيضاً تطبيق قفل تشاؤمي على استعلام يُعيد كيانات متعددة، وهذا مفيد لعمليات الدُفعات أو معالجة قوائم الانتظار.

@Transactional public List<WorkItem> claimNextBatch(int batchSize) { return em.createQuery( "SELECT w FROM WorkItem w WHERE w.status = 'PENDING' ORDER BY w.createdAt", WorkItem.class) .setMaxResults(batchSize) .setLockMode(LockModeType.PESSIMISTIC_WRITE) .setHint("jakarta.persistence.lock.timeout", 0) // دلالات SKIP LOCKED حيث مدعومة .getResultList(); }
SKIP LOCKED لمعالجة قوائم الانتظار: تدعم PostgreSQL وMySQL 8+ التعليمة FOR UPDATE SKIP LOCKED التي تتخطى الصفوف المقفولة من معاملات أخرى بدلاً من الانتظار. تُفعّل Hibernate هذا حين تمرر المهلة -2 (أو باستخدام تلميح PESSIMISTIC_SKIP_LOCKED على الموفرين المدعومين). هذا هو عمود فقري قوائم الوظائف متعددة الخيوط على مستوى قاعدة البيانات.

القفل التشاؤمي مع الاستعلامات المسمّاة

لدوال مستودع Spring Data التي تستخدم @Query، أضف @Lock على نفس الدالة — يصلهما Spring Data تلقائياً:

@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT o FROM Order o WHERE o.id = :id AND o.status = 'OPEN'") Optional<Order> findOpenOrderForUpdate(@Param("id") Long id);

المفاضلات في الأداء والمخاطر الشائعة

القفل التشاؤمي آلية تسلسل على مستوى قاعدة البيانات. كل قفل ممسوك يُمدّد فترة انتظار المعاملات الأخرى. ضع ما يلي في اعتبارك:

  • أبقِ المعاملات قصيرة. يُحفَظ القفل التشاؤمي طوال عمر المعاملة. معاملة طويلة تمسك قفلاً حصرياً على صف مطلوب كثيراً تُشكّل عنق زجاجة لكل عملية أخرى تلمسه.
  • اقفل في آخر لحظة مناسبة. إذا كنت بحاجة للتحقق للقراءة فقط قبل التعديل، افعل القراءة بدون قفل ثم أعد الجلب بقفل مباشرةً قبل الكتابة لتقليل فترة إمساك القفل.
  • الأقفال المتبادلة ممكنة. معاملة A تقفل الصف 1 ثم تحاول قفل الصف 2؛ معاملة B تقفل الصف 2 ثم تحاول قفل الصف 1. ستكتشف قاعدة البيانات هذه الدورة وتُرجع معاملةً واحدة بـ DeadlockException. اكتسب الأقفال دائماً بترتيب متسق عبر المعاملات لمنع الأقفال المتبادلة.
  • لا تُمسك أقفالاً عبر وقت تفكير المستخدم. لا تكتسب قفلاً تشاؤمياً ثم تنتظر نقرة المستخدم على زر قبل الإيداع. سيظل القفل ممسوكاً لثوانٍ أو دقائق وسيُعطّل الإنتاجية.
لا يعيش القفل التشاؤمي عبر طلبات HTTP متعددة. تعيش معاملة JPA (وأقفالها) بالكامل داخل استدعاء خدمة واحد. إذا كنت بحاجة لدلالات "تسجيل الخروج" عبر طلبات HTTP متعددة — مثل نظام إدارة محتوى يقفل مقالاً بينما يُحرره محرر — يجب تنفيذ قفل على مستوى التطبيق: خزّن عمود locked_by / locked_until في قاعدة البيانات وفرضه في طبقة الخدمة.

الاختيار بين القفل التفاؤلي والقفل التشاؤمي

لا توجد إجابة صحيحة عالمية. استخدم ما يلي كنقطة انطلاق:

  • استخدم التفاؤلي حين تكون التعارضات نادرة وتكلفة إعادة المحاولة منخفضة وإنتاجية القراءة أهم من إنتاجية الكتابة.
  • استخدم التشاؤمي حين تكون التعارضات متكررة أو متوقعة أو تكلفة إعادة المحاولة عالية أو تتطلب متطلبات الصحة ملكية حصرية قبل المتابعة.
  • فكّر في استخدامهما معاً: قد يستخدم نظام ما القفل التفاؤلي لمعظم الكيانات والقفل التشاؤمي فقط لمجاميع التنافس العالي كعدادات المخزون أو أرصدة الحسابات.

الخلاصة

يكتسب القفل التشاؤمي أقفالاً على مستوى قاعدة البيانات عند وقت القراءة، مما يلغي أي فرصة لتعارض في منتصف المعاملة. تعرضه JPA عبر LockModeType.PESSIMISTIC_READ وPESSIMISTIC_WRITE وPESSIMISTIC_FORCE_INCREMENT. في Spring Data تطبّقه بـ @Lock على دوال المستودع. اقرن دائماً الأقفال التشاؤمية بتلميح مهلة، وأبقِ المعاملات قصيرة، واكتسب الأقفال بترتيب متسق لتجنّب الأقفال المتبادلة، ولا تُمسك قفلاً أبداً عبر عمليات مواجهة المستخدم. يستكشف الدرس التالي ذاكرة التخزين المؤقت من المستوى الثاني — استراتيجية تكميلية لتقليل الحمل على قاعدة البيانات دون أي تكلفة قفل.