Spring Data JPA

المعاملات مع Spring Data

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

المعاملات مع Spring Data

كل عملية كتابة على قاعدة البيانات — سواء كانت استدعاءً واحدًا لـ save() أو سلسلة من عشرين عملية — يجب أن تُجيب في نهاية المطاف على سؤال واحد: ماذا يحدث إذا سار الأمر بشكل خاطئ في منتصف الطريق؟ الجواب هو المعاملات (Transactions). في Spring Data JPA، تُدار المعاملات بشكل تصريحي عبر الحاشية @Transactional، وفهم القواعد التي تفرضها — ومقابض الأداء التي تكشفها — هو ما يُفرّق بين الكود الذي يعمل فحسب والكود الصحيح تحت الحمل.

لماذا تهمّ المعاملات

المعاملة هي وحدة عمل إما تُكتمل بالكامل أو تُلغى بالكامل. بدونها، قد يُخلّف الفشل بين عمليتَي كتابة مرتبطتين قاعدةَ بيانات في حالة غير مكتملة لم تقصدها أي قاعدة أعمال قط. يلفّ دعم Spring للمعاملات اتصال JDBC في وكيل AOP: عند دخول الدالة المُحاشاة تبدأ معاملة (أو يُنضمّ إليها)؛ وعند عودة الدالة بشكل طبيعي تُؤكَّد؛ وعند انتشار استثناء منها تُلغى.

دوال مستودعات Spring Data معاملاتية افتراضيًا. تُحاشي الفئة الأساسية SimpleJpaRepository جميع دوال الكتابة (save وdelete وsaveAll وغيرها) بـ @Transactional وجميع دوال القراءة بـ @Transactional(readOnly = true). لا تحتاج إلى كتابة @Transactional الخاصة بك إلا حين تنسّق استدعاءات مستودعات متعددة داخل دالة خدمة واحدة.

الحاشية @Transactional

ضع @Transactional على دالة في باقة Spring المُدارة (أو على الفئة نفسها لتطبيقها على كل دالة عامة). يستبدل Spring الباقة بوكيل يعترض الاستدعاءات ويلفّها بمنطق المعاملات.

import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { private final OrderRepository orderRepo; private final InventoryRepository inventoryRepo; public OrderService(OrderRepository orderRepo, InventoryRepository inventoryRepo) { this.orderRepo = orderRepo; this.inventoryRepo = inventoryRepo; } @Transactional // معاملة واحدة تلفّ عمليتَي الكتابة public Order placeOrder(Long productId, int qty, Long customerId) { Inventory inv = inventoryRepo.findByProductIdOrThrow(productId); inv.reserve(qty); // يرمي استثناءً إذا كان المخزون غير كافٍ inventoryRepo.save(inv); Order order = new Order(customerId, productId, qty); return orderRepo.save(order); // يتأكدان معًا أو يُلغيان معًا } }

لأن عمليتَي الكتابة تشتركان في المعاملة ذاتها، فإن InsufficientStockException المرمي داخل inv.reserve(qty) سيُلغي وحدة العمل كاملةً — لن يصل أي تحديث جزئي لـ Inventory إلى قاعدة البيانات.

الانتشار: ما الذي يحدث حين تتداخل المعاملات

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

  • REQUIRED (الافتراضي) — يشارك في المعاملة الموجودة؛ يبدأ واحدة إن لم تكن موجودة. استخدمه في غالب الأحوال.
  • REQUIRES_NEW — يبدأ دائمًا معاملة جديدة ويُعلّق المعاملة الخارجية. مفيد لعمليات كتابة سجل التدقيق التي يجب أن تُؤكَّد حتى لو أُلغيت المعاملة الرئيسية.
  • NOT_SUPPORTED — يُعلّق أي معاملة نشطة ويُنفّذ بدونها. نادر؛ بشكل رئيسي لاستعلامات التقارير الثقيلة القراءة على الجداول الكبيرة جدًا.
  • MANDATORY — يجب استدعاؤه من داخل معاملة موجودة؛ يرمي استثناءً إن لم تكن هناك معاملة. مفيد للمساعدات الداخلية التي يجب ألا تُستدعى من خارج حدود الخدمة.
@Transactional(propagation = Propagation.REQUIRES_NEW) public void recordAuditEvent(String action, Long entityId) { auditRepo.save(new AuditEvent(action, entityId, Instant.now())); // تُؤكَّد بشكل مستقل — تبقى حتى لو أُلغيت معاملة المُستدعي }

قواعد الإلغاء

بشكل افتراضي، يُلغي Spring المعاملة فقط على الاستثناءات غير المفحوصة (فئات RuntimeException والفئات الفرعية منها، بالإضافة إلى Error). لا تُشغّل الاستثناءات المفحوصة الإلغاء. يمكنك تجاوز هذا:

@Transactional(rollbackFor = PaymentException.class) // استثناء مفحوص يُشغّل الإلغاء public void processPayment(Long orderId) throws PaymentException { // ... } @Transactional(noRollbackFor = OptimisticLockException.class) // اكبت الإلغاء public void retryableSave(Product p) { productRepo.save(p); }
الاستدعاء الذاتي يتجاوز الوكيل. إذا استدعت دالة @Transactional دالةً أخرى @Transactional على نفس الباقة، يتم تجاهل الحاشية الثانية — يذهب الاستدعاء مباشرةً إلى this لا عبر الوكيل. استخرج الدالة الثانية إلى باقة Spring منفصلة للحصول على سلوك معاملة مستقل.

المعاملات للقراءة فقط

للدوال التي تقرأ البيانات فحسب ينبغي دائمًا تصريح @Transactional(readOnly = true). هذه العلامة الواحدة تحمل تبعات ذات مغزى:

  • يُتخطّى فحص الاتساق لدى Hibernate. عند وقت التفريغ (flush)، تقارن Hibernate عادةً كل كيان مُدار بنسخته الاحتياطية لكشف التغييرات. مع readOnly = true تتخطى هذا الفحص كليًا، وهو أسرع بشكل ملحوظ حين تُحمِّل مجموعات كبيرة.
  • تلميح اتصال قاعدة البيانات. يمرر Spring تلميحًا للقراءة فقط إلى مشغّل JDBC. بعض قواعد البيانات (PostgreSQL، وإعدادات النسخ المتماثل في MySQL) تستطيع توجيه الاتصالات للقراءة فقط إلى نسخة ثانوية، مما يُخفّف الحمل عن الخادم الأساسي.
  • يمنع الكتابات العَرَضية. إذا عدّلت الدالة كيانًا عن غير قصد، لن تُفرّغ Hibernate التغيير — مما يُساعدك على اكتشاف الأخطاء المنطقية مبكرًا.
@Transactional(readOnly = true) public List<OrderSummary> findRecentOrders(Long customerId) { // تُحمّل Hibernate الكيانات لكنها تتخطى فحص الاتساق عند التفريغ return orderRepo.findTop20ByCustomerIdOrderByCreatedAtDesc(customerId); }
احرص على تحاشي دوال الاستعلام في طبقة الخدمة بـ readOnly = true كعادة. لا تكلفة إضافية حين تقرأ فحسب، لكنها توفّر على Hibernate مسح آلاف الكائنات المُدارة على خيط مشغول.

عزل المعاملات

تُعيّن خاصية isolation مستويات عزل SQL. الافتراضي هو DEFAULT الذي يُخبر Spring باستخدام ما تختاره قاعدة البيانات الأساسية (عادةً READ_COMMITTED لـ PostgreSQL وMySQL). يمكنك تشديد أو تخفيف هذا لكل دالة:

@Transactional(isolation = Isolation.SERIALIZABLE) public void transferFunds(Long fromId, Long toId, BigDecimal amount) { // متسلسل كليًا: أعلى اتساق، أدنى إنتاجية Account from = accountRepo.findByIdOrThrow(fromId); Account to = accountRepo.findByIdOrThrow(toId); from.debit(amount); to.credit(amount); accountRepo.save(from); accountRepo.save(to); }

عمليًا، تعمل معظم التطبيقات بشكل جيد عند READ_COMMITTED. استخدم REPEATABLE_READ أو SERIALIZABLE فقط حين يكون لديك شذوذ تزامن محدد لمنعه وقد قست تكلفة الإنتاجية.

المهلة الزمنية

المعاملات طويلة الأمد تحتجز أقفال قاعدة البيانات واتصالات تجمّع الاتصالات. تُجبر خاصية timeout (بالثواني) على الإلغاء إذا لم تنته المعاملة في الوقت المحدد:

@Transactional(timeout = 5) // يُلغي بعد 5 ثوانٍ public void importBatch(List<Record> records) { records.forEach(r -> recordRepo.save(new DbRecord(r))); }

تجميع الأمور معًا: طبقة خدمة نموذجية

@Service @Transactional(readOnly = true) // الافتراضي لكل الدوال: للقراءة فقط public class ProductCatalogService { private final ProductRepository productRepo; private final CategoryRepository categoryRepo; public ProductCatalogService(ProductRepository productRepo, CategoryRepository categoryRepo) { this.productRepo = productRepo; this.categoryRepo = categoryRepo; } public List<Product> findByCategory(String slug) { return productRepo.findByCategorySlug(slug); // يرث readOnly = true } @Transactional // يُلغي مستوى الفئة: قراءة-كتابة كاملة public Product createProduct(CreateProductCommand cmd) { Category cat = categoryRepo.findBySlugOrThrow(cmd.categorySlug()); Product p = new Product(cmd.name(), cmd.price(), cat); return productRepo.save(p); } @Transactional // قراءة-كتابة؛ يلفّ مستودعَين بشكل ذري public void adjustPrice(Long id, BigDecimal newPrice) { Product p = productRepo.findByIdOrThrow(id); p.setPrice(newPrice); // لا حاجة لـ save() صريح — فحص الاتساق في Hibernate يُؤكّد التغيير } }

تحاشي الفئة بـ @Transactional(readOnly = true) ثم تجاوز دوال الكتابة الفردية بـ @Transactional العادية هو النمط المعياري. وهو صريح وآمن افتراضيًا ويُعظّم أداء القراءة.

الخلاصة

تُعدّ @Transactional الطريقة التي تُبقي بها Spring Data عمليات الكتابة ذرية وعمليات القراءة فعّالة. القواعد الأساسية: استخدم readOnly = true على كل دالة استعلام؛ الفّ عمليات المستودعات المتعددة في معاملة خدمة واحدة؛ تذكر أن الاستدعاء الذاتي يتجاوز الوكيل؛ واضبط قواعد الإلغاء حين تحتاج إلى أن تُشغّل الاستثناءات المفحوصة الإلغاء. بهذه العادات، ستكون طبقة البيانات صحيحة وسريعة معًا.