在 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 COMMITTEDvsREPEATABLE READ)。光看 Laravel 程式碼無法確定查詢結果,必須了解底層 DB 的隔離層級設定。
陷阱 14:將 Retry 視為解決 Deadlock 的萬靈丹
當高併發出現
Deadlock found when trying to get lock 時,直接加上 DB::transaction(..., 5) 只能算「容錯補救」,而非「根本解決」。- 架構優化清單:
- 確保所有 Transaction 存取多張資料表時的 Lock 順序完全一致。
- 縮短 Transaction 鎖定時間。
- 為
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/補償機制處理跨服務交易。
沒有留言:
張貼留言