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 Pattern 或 Saga 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/補償機制處理跨服務交易。


當系統流量衝高、併發 Request 驟增時,許多團隊常在 Error Log 中看到 Deadlock found when trying to get lock; try restarting transaction。許多工程師的第一反應是加上 DB::transaction(fn() => ..., 5) 讓系統自動 Retry 5 次,但這只是把問題蓋過去——隨著併發量持續上升,連 Retry 都會開始 Timeout,最終導致資料庫 Connection Pool 耗盡、系統全面崩潰。

死鎖(Deadlock)不是資料庫的 Bug,而是資料庫引擎(如 MySQL InnoDB)為了保護資料一致性而採取的自我保護機制。

本文將從 InnoDB 鎖機制、死鎖發生的底層原因、常見爆發情境 到 實戰級重構方案,帶你徹底掌握如何在 Laravel 中設計出「高併發且零死鎖」的強健架構。

觀念篇:死鎖是如何發生的?

發生死鎖的本質,完全符合作業系統的「循環等待(Circular Wait)」條件:兩個或以上的交易(Transactions)互相持有對方需要的鎖,且同時在等待對方釋放鎖。

死鎖的經典發生過程

  [Transaction A]                        [Transaction B]
  ----------------                       ----------------
1. Lock Row 1 (成功)                    
2.                                       Lock Row 2 (成功)
3. 嘗試 Lock Row 2 (等待 B 釋放...)       
4.                                       嘗試 Lock Row 1 (等待 A 釋放...)
                  
                  💥 [InnoDB 偵測到死鎖]
                  強制 Rollback 其中一個交易

你必須知道的 InnoDB 鎖特性

要防範死鎖,必須先釐清 MySQL 在背景是怎麼上鎖的:

  1. 鎖是綁定在索引(Index)上的:如果你的 WHERE 條件欄位沒有建立索引,InnoDB 就無法使用 Row Lock(行鎖),而是會直接升級為 Table Lock(表鎖) 或將整張表的所有記錄都鎖住,大幅增加死鎖機率。

  2. Next-Key Lock 範圍鎖:InnoDB 預設(REPEATABLE READ 隔離層級)在範圍查詢時會使用 Gap Lock(間隙鎖)與 Next-Key Lock。這意味著即使某些 ID 的紀錄還不存在,資料庫也會鎖住該「區間」,導致意料之外的鎖衝突。

  3. Implicit Upgrades(鎖升級):先執行 SELECT ... FOR SHARE(共享鎖),後續再執行 UPDATE(排他鎖)時,非常容易引發多個 Connection 同時擠壓鎖升級,進而爆發死鎖。

陷阱篇:Laravel 中最容易踩爆死鎖的 4 大情境

1. 異步/併發存取多張表,但「上鎖順序不一致」

這是最常見的死鎖來源。當兩段不同的業務邏輯(例如:訂單扣款與退款流程)涉及相同的多張資料表時,若沒有嚴格約束存取順序,併發時就會鎖死。

  • 情境 A(建立訂單):鎖定 users (扣餘額) $\rightarrow$ 鎖定 products (扣庫存)

  • 情境 B(庫存補貨/盤點):鎖定 products (更新庫存) $\rightarrow$ 鎖定 users (更新供應商紀錄)

當情境 A 與情境 B 同時高併發執行時,死鎖必然發生。

2. Batch Update / Mass Delete(批次處理順序隨機)

在 Laravel 中使用 whereIn 進行批次更新:

PHP
// 批次扣減多個商品的庫存
Product::whereIn('id', [5, 1, 3])->lockForUpdate()->get();
whereIn 語句在 SQL 執行時,若沒有明確指定排序,MySQL 掃描與鎖定索引頁面的順序可能是不固定的。如果另一個 Request 剛好執行 whereIn('id', [1, 3, 5]),兩者在上鎖過程中就會發生交錯撞鎖。

3. 先讀取(Share Lock)再更新(Exclusive Lock)

PHP
DB::transaction(function () {
    // 兩個人同時讀取這行(都拿到 S-Lock 共享鎖)
    $wallet = Wallet::where('user_id', 1)->sharedLock()->first();

    // 兩個人都嘗試更新,升級為 X-Lock(排他鎖)
    // 兩者都在等待對方釋放 S-Lock ➡️ 死鎖爆發!
    $wallet->decrement('balance', 100);
});

4. 外鍵 constraint(Foreign Key)引起的隱式上鎖

當你寫入一張帶有外鍵關聯的子表時(例如 order_items 關聯 products),MySQL 會自動且隱式地去給父表 products 對應的紀錄加上 Shared Lock(共享鎖)。如果你同時又有其他交易在對 products 做排他性的 UPDATE/lockForUpdate(),就會在不知不覺中觸發死鎖。

解法篇:防止死鎖的 6 大實戰策略

要解決死鎖,核心思維只有一個:破除「循環等待」的條件,並縮短鎖的持有時間。

策略 1:強制全局固定的「鎖定順序(Lock Ordering)」

在整個專案中,只要涉及多表更新,必須嚴格規範存取的先後順序。例如:永遠先鎖 users,再鎖 orders,最後鎖 products。

如果是更新同張表的多筆資料,必須在查詢前進行排序(ORDER BY):

PHP
// ❌ 危險:傳入的 ID 順序不可控
$ids = [5, 1, 3];
Product::whereIn('id', $ids)->lockForUpdate()->get();

// ✅ 安全:強制按主鍵 ASC 排序後再上鎖
$ids = collect([5, 1, 3])->sort()->values()->toArray(); // [1, 3, 5]

DB::transaction(function () use ($ids) {
    // 產生按 id 順序上鎖的 SQL: SELECT * FROM products WHERE id IN (1, 3, 5) ORDER BY id ASC FOR UPDATE
    $products = Product::whereIn('id', $ids)
        ->orderBy('id', 'asc')
        ->lockForUpdate()
        ->get();

    // 業務邏輯...
});

策略 2:改用「無鎖」原子更新(Atomic Update)

多數時候我們根本不需要 lockForUpdate()。利用資料庫本身的單行操作原子性,可以在完全不安裝悲觀鎖的情況下安全扣減資料:

PHP
// ❌ 悲觀鎖寫法(開啟交易、持有鎖時間長、易死鎖)
DB::transaction(function () use ($productId, $qty) {
    $product = Product::where('id', $productId)->lockForUpdate()->first();
    if ($product->stock >= $qty) {
        $product->decrement('stock', $qty);
    }
});

// ✅ 無鎖原子寫法(利用資料庫單列操作的原子特性,極速完成)
$affectedRows = Product::where('id', $productId)
    ->where('stock', '>=', $qty) // 利用 WHERE 條件做防禦
    ->decrement('stock', $qty);

if ($affectedRows === 0) {
    throw new OutOfStockException('庫存不足');
}
優勢:SQL 語法極簡,鎖的持有時間僅僅是該條 UPDATE 執行的微秒級時間,幾乎完全杜絕了死鎖空間。

策略 3:極致縮短 Transaction 範圍(Minimal Scope)

交易包裹的程式碼越多,持有鎖的時間就越長,併發交錯的機率呈指數級上升。

PHP
// ❌ 壞習慣:把耗時操作全都包在交易內
DB::transaction(function () use ($request) {
    $user = Auth::user();
    
    // 1. 呼叫第三方支付 API(耗時 1~2 秒)
    $paymentResult = PaymentGateway::charge($request->amount);
    
    // 2. 處理圖片與 PDF 產生(耗時 500ms)
    $pdf = PDF::generateInvoice(...);

    // 3. 寫入資料庫
    Order::create([...]);
});

// ✅ 好習慣:只將必要的 DB 寫入包在交易中
// Step 1: 在 Transaction 外處理耗時運算與 API 呼叫
$paymentResult = PaymentGateway::charge($request->amount);
$pdfPath = Storage::put('invoices/inv.pdf', ...);

// Step 2: 集中在毫秒級內完成 DB 交易
DB::transaction(function () use ($paymentResult, $pdfPath) {
    Order::create([
        'payment_id' => $paymentResult->id,
        'pdf_path'   => $pdfPath,
    ]);
});

策略 4:確定所有查詢都有索引支撐(Avoid Table Locks & Gap Locks)

如果你的 UPDATE 或 SELECT ... FOR UPDATE 沒命中索引,InnoDB 會直接鎖定整張表或極大的 Gap 區間:

PHP
// ❌ 如果 user_code 沒有建立索引,這行會導致全表紀錄被鎖住!
User::where('user_code', 'US-8899')->lockForUpdate()->first();
  • 檢測工具:在開發環境中使用 EXPLAIN 分析 SQL,確保 type 為 ref 或 const,且 key 欄位有正確使用索引。

  • 避免過大的範圍鎖:盡量使用明確的主鍵或 Unique Index 進行點對點(Point Lookup)鎖定,避免使用 WHERE id > 100 這種大規模範圍上鎖。

策略 5:分散式鎖(Redis Lock)作為第一線防禦

高併發情境下(如搶購、秒殺),直接讓成千上萬個 Request 衝入資料庫競爭 lockForUpdate() 是非常危險的。合理的架構應該在 應用層(Application Level) 就完成排隊,只讓拿到鎖的 Request 進到 DB。

Laravel 提供極為優雅的 Redis 鎖 API:

PHP
use Illuminate\Support\Facades\Redis;

// 使用 Redis 針對特定商品上鎖
$lock = Redis::lock('product-decrement:' . $productId, 10); // 10 秒自動釋放防死鎖

if ($lock->get()) {
    try {
        // 只有拿到 Redis 鎖的人才能進入 DB 操作
        DB::transaction(function () use ($productId, $qty) {
            // 執行扣庫存...
        });
    } finally {
        $lock->release(); // 處理完畢立即釋放
    }
} else {
    throw new BusyException('系統繁忙,請稍後再試');
}

策略 6:正確地設計「重試機制(Retry)」

雖然重試不是解決死鎖的根本方法,但在高併發系統中,將重試作為最後一道保險(Safety Net)是完全合理的。

Laravel 的 DB::transaction(..., $attempts) 支援死鎖自動重試,但必須確保交易閉包內具備「等冪性(Idempotency)」:

PHP
// ❌ 錯誤:自動 Retry 會導致重複呼叫外部 API
DB::transaction(function () {
    $order = Order::create([...]);
    Http::post('https://api.stripe.com/v1/charges', [...]); // 重試會造成重複扣款!
}, 3);

// ✅ 正確:交易內只保留單純、可安全重試的 DB 操作
DB::transaction(function () use ($orderData) {
    $order = Order::create($orderData);
    $order->items()->createMany(...);
}, 3); // 遇到 Deadlock 時,Laravel 會自動乾淨地 Rollback 並重試最多 3 次

總結:高併發併發控制檢查表(Cheatsheet)

當你準備撰寫包含資料庫交易的業務邏輯時,請對照以下清單檢視:

檢查項目說明 / 規範
1. 索引檢查WHERE 條件欄位是否都有建立索引?(避免鎖升級)
2. 鎖定順序涉及多張表或多筆資料時,是否已按固定規則(如 ID ASC)排序?
3. 範圍縮小是否把 HTTP API 呼叫、檔案處理等耗時動作移出了交易外部?
4. 原子替代能不能用 $model->decrement() 替代 lockForUpdate()?
5. 併發前置能否先用 Redis::lock() 在應用層擋掉 90% 的無效競爭?
6. 自動重試被 DB::transaction(..., 3) 包裹的程式碼是否符合等冪性?

架構心法:資料庫交易是保障「資料一致性」的最後底線,而不是拿來處理「高併發流量控管」的工具。透過應用層 Redis 防鎖 + 資料庫固定順序/無鎖更新 + 輕量化交易範圍,就能建構出兼具資料正確性與極致吞吐量的 Laravel 系統。 

沒有留言:

張貼留言

【2026 最新】VS Code 導入 pstack AI Skills 完整教學:用 Copilot 打造嚴謹的 Agent 工作流

在 VS Code 使用 AI Skills 打造嚴謹工程工作流:從 pstack 到原生 AGENTS.md 實戰指南 「If you want to go fast, go deep first.」 — Lauren Tan(@poteto) 進入 2026 年, AI Co...