在 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/補償機制處理跨服務交易。
當系統流量衝高、併發 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 在背景是怎麼上鎖的:
- 鎖是綁定在索引(Index)上的:如果你的
WHERE條件欄位沒有建立索引,InnoDB 就無法使用 Row Lock(行鎖),而是會直接升級為 Table Lock(表鎖) 或將整張表的所有記錄都鎖住,大幅增加死鎖機率。 - Next-Key Lock 範圍鎖:InnoDB 預設(
REPEATABLE READ隔離層級)在範圍查詢時會使用 Gap Lock(間隙鎖)與 Next-Key Lock。這意味著即使某些 ID 的紀錄還不存在,資料庫也會鎖住該「區間」,導致意料之外的鎖衝突。 - 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 系統。
沒有留言:
張貼留言