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

السبب الجذري هو نفسه تقريبًا دائمًا: سلسلة من عمليات الكتابة في قاعدة البيانات التي عُوملت كأحداث منفصلة ومعزولة. عندما يفشل أحدها، تظل العمليات الأخرى عالقة. الحل هو معاملة قاعدة البيانات (database transaction)، وفي Laravel، الأداة هي DB::transaction().

ما الذي تضمنه المعاملة (Transaction) فعليًا

تربط معاملة قاعدة البيانات عمليات متعددة في وحدة عمل واحدة. يضمن محرك قاعدة البيانات أن كل شيء بداخلها إما يتم اعتماده (commit) بشكل دائم أو يتم التراجع عنه (rollback) بالكامل. لا يوجد حل وسط.

فكر في تحويل بنكي. يجب على النظام خصم مبلغ من حساب وإضافته إلى حساب آخر. إذا فشلت عملية الإضافة بعد الخصم، فإن المال لا يختفي ببساطة في الفراغ؛ بل يقوم البنك بعكس عملية الخصم. هذا العكس هو عملية الـ rollback. إذا نجحت الخطوتان، يتم اعتماد التحويل (commit)، مما يعني حفظ الأرصدة الجديدة بشكل دائم.

سلوك "الكل أو لا شيء" هذا هو ما يحافظ على اتساق بياناتك. بدون ذلك، ستؤدي الإخفاقات الجزئية إلى تشتيت سجلات مكتوبة جزئيًا عبر جداولك، وستتعفن تلك السجلات في قاعدة بياناتك لأنه لا توجد عملية مؤتمتة تعرف كيفية تنظيفها بأمان.

كيف يقوم Laravel بالربط

في SQL العادي، ستقوم بكتابة BEGIN و COMMIT و ROLLBACK بنفسك، مع تذكر التقاط كل خطأ محتمل حتى لا تترك معاملة معلقة أبدًا. يقوم Laravel بتغليف هذا الكود المتكرر (boilerplate) في دالة واحدة.

تقوم بتمرير closure إلى DB::transaction(). يبدأ Laravel المعاملة، ويشغل الكود الخاص بك، وإذا انتهى الـ closure دون إلقاء استثناء (exception)، فإنه يقوم بالاعتماد (commit) تلقائيًا. إذا فشل أي شيء، يقوم Laravel بالتقاط الاستثناء، ويتراجع عن كل شيء (rollback)، ثم يعيد إلقاء الخطأ حتى تظل عمليات تسجيل السجلات (logging) ومعالجة الأخطاء لديك تعمل كما هو متوقع.

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

إذا فشلت عملية إدخال الدفع بسبب فقدان مفتاح أجنبي (foreign key) أو انقطاع الاتصال بقاعدة البيانات، فسيتم التراجع عن الطلب، وعناصر الطلب، وتغييرات المخزون بالكامل. لن تجد نفسك أمام مخزون اختفى بلا سبب، ولن تجد طلبًا يتطلب الشحن ولم يتم دفعه أبدًا.

أين يوفر لك هذا الوقت والجهد

تعتمد بعض سير العمل (workflows) تمامًا على سلامة المعاملات. مثال عملية الدفع (checkout) هو الأكثر وضوحًا، ولكن هذا النمط يظهر في كل مكان.

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

عمليات الاستيراد الضخمة. رفع ملف CSV يقوم بإدخال خمسين سجلًا لا ينبغي أن يترك خمسة وعشرين سجلًا خلفه لأن السجل السادس والعشرين كان يحتوي على تاريخ غير صالح. تغليف الدفعة في معاملة يسمح لعملية الاستيراد بأكملها بالفشل كوحدة واحدة نظيفة. يقوم المسؤول بإصلاح الملف والمحاولة مرة أخرى، بدلاً من قضاء ساعات في البحث عن السجلات التي تسربت جزئيًا.

المخزون والمحاسبة. في أي وقت يتتبع فيه جدول واحد موردًا ماديًا وجدول آخر يتتبع الأموال أو الائتمانات، يجب أن يتحرك الاثنان معًا. فصلهما يفتح الباب لعمليات تدقيق لا تتطابق وتقارير غير دقيقة.

متى تحتاج إلى الإدارة اليدوية

يغطي نهج الـ closure معظم الحالات، ولكنك قد تحتاج أحيانًا إلى مزيد من التحكم. المنطق الشرطي المعقد داخل فئة الخدمة (service class)، أو الحاجة إلى اتخاذ قرار في وقت التشغيل (runtime) بشأن اعتماد المعاملة، قد يجعل استخدام closure واحدة أمرًا مربكًا. في تلك اللحظات، يمكنك إدارة المعاملة بنفسك:

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

لاحظ الترتيب داخل كتلة catch: قم بالتراجع (roll back) أولاً، ثم قم بإلقاء الخطأ (throw). إذا قمت بإلقاء الخطأ قبل التراجع، فستظل المعاملة مفتوحة على الاتصال. قد يؤدي ذلك إلى قفل الصفوف، أو حدوث حالة تعارض (deadlock) للاستعلامات الأخرى، أو استنفاد مجموعة الاتصالات (connection pool) لديك. المعاملات اليدوية قوية، لكنها تضع عبء التنظيف عليك.

ابقِ الآثار الجانبية خارج المعاملة

هذه هي القاعدة التي تسبب المتاعب للفرق في بيئة الإنتاج (production). يمكن للمعاملة فقط التراجع عن أعمال قاعدة البيانات. لا يمكنها إلغاء إرسال بريد إلكتروني، أو حذف ملف من التخزين السحابي، أو استرداد مبلغ عبر واجهة برمجة تطبيقات الدفع (payment API).

إذا وضعت استدعاء Mail::send() داخل الـ transaction closure، وتراجعت قاعدة البيانات بعد سطرين، فسيصل ذلك البريد الإلكتروني إلى صندوق وارد العميل على أي حال. سيحصل المستلم الآن على فاتورة لطلب غير موجود في نظامك. ينطبق الأمر نفسه على إشعارات Slack، أو رفع الملفات إلى S3، أو إرسال الـ webhooks.

التسلسل الصحيح هو:

  1. أكمل المعاملة (transaction) واحصل على أي معرفات (IDs) أو نتائج تحتاجها.
  2. بعد ذلك فقط، قم بتشغيل الآثار الجانبية الخارجية.

على سبيل المثال، قم بجدولة بريد التأكيد في الطابور بعد عملية الـ commit، وليس بداخلها:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

هناك سبب ثانٍ لإبقاء الاستدعاءات الخارجية خارج المعاملة: الوقت. تحتفظ المعاملة بالأقفال (locks) وتجعل اتصال قاعدة البيانات مشغولاً. الانتظار لمدة ثلاث ثوانٍ للحصول على استجابة من Stripe API أثناء وجودك داخل المعاملة يعني ثلاث ثوانٍ من وقت القفل غير الضروري. اجعل المعاملة موجزة وسريعة.

لماذا يختبئ هذا الخطأ

في بيئة التطوير على جهاز واحد مع مستخدم واحد وقاعدة بيانات محلية، تنجح عمليات الإدخال (inserts) المرتبطة ببعضها دائمًا تقريبًا. الشبكة مستقرة، والقرص لا يمتلئ أبدًا، ولا يوجد ضغط عمل منافس. يبدو الكود صحيحًا لأنه يعمل عادةً.

بيئة الإنتاج (Production) مختلفة. يقوم عميلان بتقديم طلبات في نفس الملي ثانية تمامًا. يعيد عامل الطابور (queue worker) تشغيل نفسه في منتصف المهمة أثناء عملية النشر (deployment). يتوقف مزود خدمة الدفع عن الاستجابة لمدة ثلاثين ثانية. بدون المعاملات (transactions)، تخلق هذه الأحداث سجلات يتيمة (orphan records) وإجماليات غير متطابقة يصعب تتبعها. والأسوأ من ذلك هو أن اختبارات الميزات القياسية نادرًا ما تكتشفها، لأن نمط الفشل يعتمد على التوقيت ومرتبط بالبنية التحتية، وليس بأخطاء منطقية بسيطة.

الحل ليس غريبًا، بل هو إجراء ميكانيكي. عندما ترى عمليتي كتابة أو أكثر مرتبطة ببعضها، قم بتغليفها (wrap them). مع مرور الوقت، يجب أن يصبح هذا الأمر تلقائيًا مثل التحقق من صحة الطلب (validating a request).

اجعلها عادة تلقائية

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