شما سفارش را ذخیره کردید، اما اقلام سفارش هرگز به پایگاه داده نرسیدند. یا شاید شمارنده موجودی کاهش یافت، درگاه پرداخت با خطا (timeout) مواجه شد و حالا مشتری مبلغی را از کارت خود پرداخت کرده اما هیچ سوابق سفارشی وجود ندارد. این سناریوها تا زمانی که واقعاً رخ ندهند، شبیه موارد استثنایی (edge cases) به نظر می‌رسند. در یک اپلیکیشن شلوغ، این‌ها به سردرگمی‌های روزمره تبدیل می‌شوند.

علت اصلی تقریباً همیشه یکسان است: مجموعه‌ای از عملیات نوشتن در پایگاه داده که به عنوان رویدادهای جداگانه و ایزوله در نظر گرفته شده‌اند. وقتی یکی شکست می‌خورد، بقیه باقی می‌مانند. راه حل، یک تراکنش پایگاه داده (database transaction) است و در Laravel، ابزار آن DB::transaction() است.

تراکنش در واقع چه چیزی را تضمین می‌کند

یک تراکنش پایگاه داده، چندین عملیات را در قالب یک واحد کاری واحد به هم پیوند می‌دهد. موتور پایگاه داده تضمین می‌کند که همه موارد داخل آن یا به طور دائمی ثبت (commit) شوند یا به طور کامل بازگشت (rollback) داده شوند. هیچ حالت میانی وجود ندارد.

یک انتقال بانکی را در نظر بگیرید. سیستم باید از یک حساب کسر کند و به حساب دیگری واریز کند. اگر واریز پس از کسر با خطا مواجه شود، پول به سادگی در خلاء ناپدید نمی‌شود. بانک عملیات کسر را معکوس می‌کند. این معکوس‌سازی همان rollback است. اگر هر دو مرحله با موفقیت انجام شوند، انتقال commit می‌شود، به این معنی که موجودی‌های جدید به طور پایدار ذخیره شده‌اند.

این رفتار «همه یا هیچ» همان چیزی است که ثبات داده‌های شما را حفظ می‌کند. بدون آن، شکست‌های جزئی باعث پراکندگی رکوردهای نیمه‌کاره در جداول شما می‌شود و آن رکوردها در پایگاه داده شما فاسد می‌شوند، زیرا هیچ فرآیند خودکاری نمی‌داند چگونه آن‌ها را به طور ایمن پاکسازی کند.

لاراول چگونه این کار را انجام می‌دهد

در SQL معمولی، شما باید خودتان BEGIN ،COMMIT و ROLLBACK را بنویسید و به یاد داشته باشید که هر خطای احتمالی را مدیریت کنید تا تراکنش را معلق رها نکنید. Laravel تمام این کدهای تکراری (boilerplate) را در یک متد واحد بسته‌بندی کرده است.

شما یک closure به DB::transaction() پاس می‌دهید. Laravel تراکنش را شروع می‌کند، کد شما را اجرا می‌کند و اگر closure بدون پرتاب کردن یک exception تمام شود، به طور خودکار commit انجام می‌شود. اگر هر چیزی با شکست مواجه شود، Laravel آن exception را می‌گیرد، همه چیز را rollback می‌کند و خطا را دوباره پرتاب (rethrow) می‌کند تا سیستم ثبت لاگ و مدیریت خطای شما همچنان طبق انتظار عمل کند.

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) با شکست مواجه شود، کاربری بدون پروفایل به یک حساب شبح (ghost account) تبدیل می‌شود. هر صفحه‌ای که فرض کند هر کاربر پروفایلی دارد، کرش می‌کند یا رابط کاربری (UI) ناقصی را نشان می‌دهد.

وارد کردن انبوه (Bulk imports). یک آپلود CSV که پنجاه رکورد را درج می‌کند، نباید به دلیل اینکه ردیف بیست و ششم دارای یک تاریخ نامعتبر بوده، بیست و پنج رکورد را رها کند. قرار دادن این دسته در یک تراکنش اجازه می‌دهد تا کل فرآیند وارد کردن به عنوان یک واحد تمیز با شکست مواجه شود. مدیر سیستم به جای اینکه ساعت‌ها وقت صرف جستجو برای پیدا کردن ردیف‌هایی که به صورت ناقص وارد شده‌اند کند، فایل را اصلاح کرده و دوباره تلاش می‌کند.

موجودی و حسابداری. هر زمان که یک جدول یک منبع فیزیکی را ردیابی می‌کند و جدول دیگری پول یا اعتبار را، این دو باید با هم حرکت کنند. جدا کردن آن‌ها باعث ایجاد حسابرسی‌های نامتقارن و گزارش‌های نادرست می‌شود.

چه زمانی نیاز به مدیریت دستی دارید

رویکرد closure اکثر موارد را پوشش می‌دهد، اما گاهی اوقات به کنترل بیشتری نیاز دارید. منطق شرطی پیچیده در یک service class، یا نیاز به تصمیم‌گیری در زمان اجرا (runtime) برای commit کردن، می‌تواند استفاده از یک 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 دقت کنید: ابتدا rollback کنید، سپس throw کنید. اگر قبل از rollback کردن، خطا را throw کنید، تراکنش در اتصال (connection) باز می‌ماند. این کار می‌تواند باعث قفل شدن ردیف‌ها (lock rows)، ایجاد بن‌بست (deadlock) در سایر کوئری‌ها یا اتمام ظرفیت اتصال (connection pool) شما شود. تراکنش‌های دستی قدرتمند هستند، اما بار پاکسازی را بر عهده شما می‌گذارند.

اثرات جانبی (Side Effects) را از تراکنش دور نگه دارید

این همان قانونی است که تیم‌ها را در محیط عملیاتی (production) به دردسر می‌اندازد. یک تراکنش فقط می‌تواند کارهای پایگاه داده را لغو کند. این تراکنش نمی‌تواند یک ایمیل ارسال شده را پس بگیرد، فایلی را از فضای ذخیره‌سازی ابری حذف کند یا مبلغی را از طریق یک API پرداخت بازگرداند.

اگر یک فراخوانی Mail::send() را داخل closure تراکنش قرار دهید و پایگاه داده دو خط بعد rollback شود، آن ایمیل همچنان به صندوق ورودی مشتری ارسال می‌شود. گیرنده اکنون فاکتوری برای سفارشی دارد که در سیستم شما وجود ندارد. همین موضوع در مورد اعلان‌های Slack، آپلود فایل در S3 یا ارسال webhookها نیز صدق می‌کند.

ترتیب صحیح به این صورت است:

۱. تراکنش را کامل کنید و هرگونه شناسه (ID) یا نتیجه‌ای که نیاز دارید را ذخیره کنید. ۲. تنها پس از آن، اثرات جانبی خارجی (external side effects) را اجرا کنید.

برای مثال، ایمیل تأیید را پس از commit در صف قرار دهید، نه داخل آن:

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

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

دلیل دوم برای نگه داشتن فراخوانی‌های خارجی خارج از تراکنش، زمان است. یک تراکنش قفل‌ها (locks) را نگه می‌دارد و اتصال پایگاه داده را مشغول نگه می‌دارد. انتظار برای پاسخ API شرکت Stripe به مدت سه ثانیه در حالی که داخل یک تراکنش هستید، یعنی سه ثانیه زمان اضافی برای نگه داشتن قفل‌ها. تراکنش را کوتاه و سریع نگه دارید.

چرا این باگ پنهان می‌ماند

در یک ماشین توسعه با یک کاربر واحد و یک پایگاه داده محلی، درج‌های (inserts) مرتبط تقریباً همیشه با موفقیت انجام می‌شوند. شبکه پایدار است، دیسک هرگز پر نمی‌شود و بار (load) رقابتی وجود ندارد. کد درست به نظر می‌رسد زیرا معمولاً کار می‌کند.

محیط عملیاتی (Production) متفاوت است. دو مشتری دقیقاً در یک میلی‌ثانیه سفارش خود را ثبت می‌کنند. یک کارگر صف (queue worker) در میانه انجام کار، هنگام استقرار (deployment) مجدداً راه‌اندازی می‌شود. یک ارائه‌دهنده پرداخت به مدت سی ثانیه با خطا (timeout) مواجه می‌شود. بدون تراکنش‌ها، این رویدادها باعث ایجاد رکوردهای یتیم (orphan records) و مجموع‌های ناهماهنگ می‌شوند که ردیابی آن‌ها بسیار دشوار است. بدترین بخش این است که تست‌های ویژگی (feature tests) استاندارد به ندرت آن‌ها را شناسایی می‌کنند، زیرا حالت خطا به زمان‌بندی وابسته است و به زیرساخت مربوط می‌شود، نه به خطاهای منطقی ساده.

راه حل عجیب و غریبی نیست؛ بلکه مکانیکی است. هر جا دو یا چند عملیات نوشتن (write) مرتبط دیدید، آن‌ها را در یک تراکنش قرار دهید (wrap). با گذشت زمان، این کار باید به اندازه اعتبارسنجی یک درخواست (request validation) خودکار شود.

آن را به یک واکنش ناخودآگاه تبدیل کنید

DB::transaction() پیچیدگی اضافه نمی‌کند. بلکه پیچیدگی پنهانِ تلاش برای پاکسازی داده‌های ناقص پس از وقوع مشکل را از بین می‌برد. اگر مجموعه‌ای از عملیات‌های پایگاه داده به هم مربوط هستند، از همان ابتدا با آن‌ها به همین صورت برخورد کنید. اپلیکیشن شما تحت فشار (load) قابل اعتماد باقی می‌ماند، لاگ‌های خطای شما تمیز می‌مانند و پایگاه داده شما به گورستانی از رکوردهای نیمه‌تمام تبدیل نخواهد شد.