2026年9月18日 星期五

【Laravel 資深架構師觀點】別再只會 DB::transaction()!徹底拆解 15 大資料庫交易陷阱與跨服務一致性

在 Laravel 開發中,許多工程師以為只要把程式碼包進 DB::transaction(function () { ... }) 裡面,系統就擁有了「原子性(Atomicity)與絕對的安全」。

然而在實務與高併發架構中,Transaction 只解決了「資料庫層級」的狀態寫入,完全無法保證「商業流程」的整體一致性。

本文將從 Laravel 機制、例外處理、資料庫鎖、併發控制、到分散式架構,系統性拆解 15 個最常踩爆的交易陷阱。

陷阱 1:以為 DB::transaction() 什麼都能 Rollback

資料庫交易(Transaction)只對支援 ACID 的資料庫操作有效。

PHP
DB::transaction(function () {
    $order = Order::create([...]);
    Mail::send(...);                             // 外部服務:郵件
    Storage::put('invoices/inv.pdf', ...);        // 檔案系統
    Http::post('https://payment.com/pay', ...);  // 第三方 API
    
    throw new Exception('DB Write Error');       // 觸發 Rollback
});
  • 真實結果:DB 中的 $order 確實被 Rollback 了,但信件已經寄出、檔案已經寫入、第三方 API 的扣款請求也已經發送

  • 架構原則:副作用(File, Email, External API)絕不能直接寫在 DB Transaction 內部。

陷阱 2:DB Transaction 成功 $\neq$ 商業流程成功

PHP
DB::transaction(function () {
    $order = Order::create([...]);
    Payment::create([
        'order_id' => $order->id,
        'status'   => 'pending',
    ]);
});
  • 痛點剖析:資料庫語法 100% 執行成功並 Commit,但使用者根本還沒付錢。

  • 架構原則:DB Transaction 保證的是資料庫一致性;商業流程的狀態演進(如 pending $\rightarrow$ paid)需要業務邏輯與狀態機(State Machine)來控制。

陷阱 3:在 Transaction 內 try/catch 卻把 Exception 「吃掉」

這是導致 Rollback 失效最常見的 Bug:

PHP
DB::transaction(function () {
    try {
        User::create([...]);
        throw new Exception('Something went wrong');
    } catch (Exception $e) {
        Log::error($e->getMessage());
        // ❌ 沒有再將 Exception 拋出!
    }
});
  • 痛點剖析DB::transaction() 決定是否 Rollback 的依據是 「Closure 內是否拋出未被捕獲的 Exception」。Exception 被 catch 且未重新拋出,Laravel 會認定執行成功並直接 COMMIT

  • 正確做法:在 catch 區塊記錄 Log 後,必須 throw $e;

陷阱 4:return false 不等於觸發 Rollback

PHP
DB::transaction(function () {
    $user = User::create([...]);
    
    if (!$user->isValid()) {
        return false; // ❌ 以為傳回 false 就會 Rollback
    }
});
  • 痛點剖析:再次強調,Laravel 判斷 Rollback 靠的是 Exception,而不是 Closure 的返回值。上述程式碼依然會被 COMMIT

陷阱 5:Transaction 自動 Retry $\times$ 非等冪性(Non-Idempotent)操作

Laravel 支援死結時自動重試:DB::transaction(..., 5);(最多試 5 次)。

PHP
DB::transaction(function () {
    $order = Order::create([...]);
    Http::post('https://api.stripe.com/v1/charges', [...]); // 非等冪 API
}, 3);
  • 痛點剖析:如果第一次執行到尾端發生 Deadlock,Laravel 會自動重跑 Closure。結果:DB 操作重試,但 HTTP API 被重複呼叫了第二次(重複扣款)。

  • 架構原則:會被 Retry 的 Transaction 內部,嚴禁包含任何非等冪(Non-Idempotent)的外部呼叫。

陷阱 6:孤立使用 lockForUpdate()(未放在 Transaction 內)

PHP
// ❌ 沒有包在 DB::transaction 內
$product = Product::lockForUpdate()->find($id);
$product->decrement('stock');
  • 痛點剖析:在 MySQL (InnoDB) 中,悲觀鎖(Row Lock)的生命週期是綁定在 Transaction 上的。如果沒有 Transaction,這行 Query 執行完的瞬間鎖就釋放了,完全無法達到防禦併發的效果。

陷阱 7:「查詢 $\rightarrow$ 判斷 $\rightarrow$ 更新」引發的 Race Condition

PHP
// ❌ 典型的 Race Condition 寫法
$product = Product::find($id);

if ($product->stock > 0) {
    $product->stock--;
    $product->save();
}
  • 痛點剖析:當兩個 Request 同時讀取到 stock = 1,兩者都會通過 if ($product->stock > 0) 判斷,最後庫存被扣成 -1(超賣)。

  • 正確做法 1 (Atomic Update)

    PHP
    $affected = Product::where('id', $id)->where('stock', '>', 0)->decrement('stock');
    if ($affected === 0) throw new Exception('庫存不足');
    
  • 正確做法 2 (Pessimistic Locking)

    PHP
    DB::transaction(function () use ($id) {
        $product = Product::where('id', $id)->lockForUpdate()->first();
        if ($product->stock <= 0) throw new Exception('庫存不足');
        $product->decrement('stock');
    });
    

陷阱 8:Transaction 範圍過大(Huge Transaction)

PHP
DB::transaction(function () {
    // 1. 撈取 5 萬筆資料
    // 2. 呼叫第三方 API 驗證
    // 3. 處理圖片上傳
    // 4. 大量寫入 DB
});
  • 痛點剖析:Transaction 持續時間越長 $\rightarrow$ 資料庫鎖持有時間越長 $\rightarrow$ 其他連線阻塞 $\rightarrow$ 大量 Connection 連線池耗盡 / Deadlock 崩潰。

  • 架構原則:Transaction 應該極簡、快速、只包裹必須具備原子性的 DB 寫入操作

陷阱 9:Queue Job 讀不到尚未 Commit 的資料

PHP
DB::transaction(function () use ($orderData) {
    $order = Order::create($orderData);
    
    // ❌ Job 迅速派發給 Worker 執行
    SendOrderEmailJob::dispatch($order); 
});
  • 痛點剖析:如果 Queue Worker 執行速度極快,當 Worker 去 DB 查詢 $order->id 時,外層的 DB Transaction 可能根本還沒 COMMIT,導致 Worker 拋出 ModelNotFoundException

  • 正確做法

    PHP
    SendOrderEmailJob::dispatch($order)->afterCommit();
    
    或者在 config/queue.php 設定 'after_commit' => true

陷阱 10:Domain Event 踩到 Transaction 邊界

PHP
DB::transaction(function () {
    $order = Order::create([...]);
    OrderCreated::dispatch($order); // 派發事件
});
  • 痛點剖析:如果 Event Listener 內處理的是同步邏輯且包含外部服務,甚至在 Listener 內拋出 Exception,會導致原本已經準備完成的 $order 一併被 Rollback。

  • 架構原則:釐清 Listener 是要在 Commit 前同步執行(屬於同個交易範疇),還是需實現 ShouldHandleEventsAfterCommit 介面於 Commit 後非同步處置。

陷阱 11:誤以為 Nested Transaction 是完全獨立的

PHP
DB::transaction(function () {
    User::create([...]);

    DB::transaction(function () {
        Order::create([...]);
        throw new Exception('Order Failed'); // 內層 Exception
    });
});
  • 痛點剖析:多數 RDBMS(如 MySQL)不支援真正的「巢狀交易」。Laravel 在處理 Nested Transaction 時,底層使用的是 SAVEPOINT(保存點)。內層拋出 Exception 若沒妥善處置,會連帶摧毀外層的 Transaction。

陷阱 12:DB::transaction() 無法防禦跨資料庫(Cross-Database)失控

PHP
DB::connection('mysql_user')->transaction(function () {
    User::create([...]); // DB A (MySQL)

    // DB B (PostgreSQL)
    DB::connection('pgsql_order')->table('orders')->insert([...]); 
});
  • 痛點剖析DB::transaction() 預設只綁定在一組 Connection 上。當 pgsql_order 寫入失敗時,mysql_user 的 COMMIT 可能已經送出。

  • 架構原則:Laravel 不原生支援兩階段提交(2PC)分散式交易。跨 DB 一致性應採用 Transactional Outbox PatternSaga Pattern

陷阱 13:忽略 Database Isolation Level 帶來的不可重複讀與幻讀

PHP
DB::transaction(function () use ($id) {
    $v1 = Product::find($id)->stock;
    
    // 期間有另一個 Transaction 修改並 COMMIT 了 stock
    
    $v2 = Product::find($id)->stock; 
    // $v1 是否等於 $v2?
});
  • 痛點剖析:這取決於資料庫的 Transaction Isolation Level(如 READ COMMITTED vs REPEATABLE READ)。光看 Laravel 程式碼無法確定查詢結果,必須了解底層 DB 的隔離層級設定。

陷阱 14:將 Retry 視為解決 Deadlock 的萬靈丹

當高併發出現 Deadlock found when trying to get lock 時,直接加上 DB::transaction(..., 5) 只能算「容錯補救」,而非「根本解決」。

  • 架構優化清單

    1. 確保所有 Transaction 存取多張資料表時的 Lock 順序完全一致

    2. 縮短 Transaction 鎖定時間。

    3. WHERE 條件欄位加上適當索引,避免 Next-Key Lock 升級為 Table Lock

陷阱 15:隱蔽的「假交易」巨坑

PHP
DB::transaction(function () {
    $order = Order::create([...]);

    try {
        PaymentGateway::charge($order); // 呼叫刷卡扣款 API
        $order->update(['status' => 'paid']);
    } catch (Exception $e) {
        Log::error('付款失敗:' . $e->getMessage());
        // ❌ Exception 被吸收掉了!
    }
});
  • 致命結果:刷卡 API 失敗拋出 Exception $\rightarrow$ 被內層 catch 吞掉 $\rightarrow$ 外層 Transaction 認為一切正常 $\rightarrow$ COMMIT

  • 最終狀態:DB 中留下了 status = pending 的訂單,看似 Transaction 成功,但商業邏輯已完全破產。

總結:Laravel 交易設計的 4 大層級

要評估一位後端工程師是「只會寫語法」還是「懂系統架構」,可以看他處理交易時落在哪個層級:

層級核心考點必備思維
L1. 基礎語法DB::transaction(), commit, rollback了解 ACID 基本特性
L2. Laravel 機制Exception 傳播, Retry, Nested, afterCommit掌握框架生命週期與佇列邊界
L3. DB 底層併發lockForUpdate, Deadlock, Isolation Level具備併發控制與資料庫鎖定知識
L4. 分散式架構Queue, Event, Outbox Pattern, Saga, 等冪性解決跨服務與外部 API 的最終一致性

核心記憶口訣:

Transaction 只解決 DB 原子性;Lock 解決部分併發問題;Idempotency 解決重複執行;Outbox 解決 DB 與事件發布的一致性;Saga/補償機制處理跨服務交易。

沒有留言:

張貼留言

【後端架構選型】Laravel vs EasySwoole vs Python:從執行模型、併發瓶頸到 AI 時代的後端演進哲學

在現代後端開發中,技術選型常常陷入一種迷思:「哪種框架 Benchmark 跑分最高,我們就用哪種。」 然而,當我們將目光從單純的 PHP 生態(如 Laravel 與 EasySwoole )放大到橫跨多領域的 Python 生態時,會發現真正影響系統命運的,從來就不是「誰...