在上篇文章中,使用 Apache Bench (ab) 進行高併發壓力測試,見證了併發請求導致庫存變成負數、資料嚴重大混亂的情況。
今天拿出資料庫的防禦利器——事務(Transaction) 與 悲觀鎖(Pessimistic Locking / FOR UPDATE),徹底修正這個併發漏洞。
要解決競態條件,必須達成兩個目標:
1 .原子性(Atomicity):將「查詢庫存」、「寫入借閱紀錄」與「扣減庫存」三個動作打包成一個不可分割的操作。
2. 排他鎖定(Locking):當使用者 A 在查詢某本書的庫存時,資料庫必須把該行資料「鎖住」,直到使用者 A 處理完畢並釋放鎖定前,使用者 B 只能排隊等待,不能同時讀取該行庫存。
在 MySQL 中,我們使用 START TRANSACTION 開始事務,並在 SELECT 語法末端加上 FOR UPDATE 來實現行級鎖定(Row Lock)。
borrow.php:導入 PDO Transaction 與悲觀鎖向 Claude Code 下以下 Prompt,請它協助重構借書邏輯:
請幫我重構 borrow.php 的借書邏輯。目前系統在併發測試時會產生 Race Condition 導致超賣。
請使用 PDO Transaction(beginTransaction, commit, rollBack)將檢查庫存、新增借閱紀錄與扣減庫存打包成原子操作,並在 SELECT 語法加上 FOR UPDATE 來避免併發問題。請加上完整的 try-catch 錯誤處理與清晰的註解。
修改後的borrow.php:
<?php
// ============================================================
// 【PHP】區塊:啟動 Session
// ============================================================
session_start(); // 必須在任何輸出之前呼叫,之後才能讀寫 $_SESSION(登入狀態與提示訊息都存在這裡)
// ============================================================
// 【PHP】區塊:檢查是否已登入
// ============================================================
// login.php 登入成功時會寫入 $_SESSION['member_id'],沒有這個值就代表尚未登入
if (!isset($_SESSION['member_id'])) {
header('Location: login.php'); // header() 送出 HTTP 標頭,Location 會讓瀏覽器跳轉到指定頁面
exit; // 導向後立刻結束程式,避免下方借書邏輯繼續執行
}
// ============================================================
// 【PHP】區塊:只接受 POST 請求
// ============================================================
// $_SERVER['REQUEST_METHOD'] 是這次請求的方法(GET / POST),借書會改動資料,只允許表單 POST 觸發
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: index.php');
exit;
}
// ============================================================
// 【PHP】區塊:只接受 POST 請求
// ============================================================
// $_SERVER['REQUEST_METHOD'] 是這次請求的方法(GET / POST),借書會改動資料,只允許表單 POST 觸發
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: index.php');
exit;
}
// ============================================================
// 【PHP】區塊:驗證 book_id
// ============================================================
$bookId = $_POST['book_id'] ?? null; // ?? 是 null 合併運算子:左邊不存在或為 null 時改用右邊的值
// ctype_digit() 檢查字串是否全為數字,排除負數、小數與非數字輸入
if ($bookId === null || !ctype_digit((string) $bookId) || (int) $bookId < 1) {
$_SESSION['flash'] = ['type' => 'error', 'message' => '書籍編號不正確,借閱失敗']; // 把提示訊息存進 Session,讓 in>
header('Location: index.php');
exit;
}
$bookId = (int) $bookId; // (int) 是型別轉換,把字串轉成整數
$memberId = (int) $_SESSION['member_id']; // 借閱者就是目前登入的會員
// ============================================================
// 【PHP】區塊:載入資料庫連線
// ============================================================
require __DIR__ . '/config/database.php'; // __DIR__ 是目前檔案所在的資料夾路徑,確保路徑正確
$pdo = Database::getConnection(); // :: 用來呼叫類別的 static 方法,取得 PDO 連線
// ============================================================
// 【PHP】區塊:借書流程(使用交易確保資料一致)
// ============================================================
try { // try...catch 捕捉執行過程拋出的例外(Exception)
// 開始交易:新增借閱紀錄與扣庫存必須同時成功,任一步失敗就全部復原
$pdo->beginTransaction();
// ------------------------------------------------------------
// 子區塊:查詢書籍庫存
// ------------------------------------------------------------
// FOR UPDATE 會鎖住這筆書籍資料直到交易結束,避免兩人同時借走最後一本
$stmt = $pdo->prepare('SELECT title, available FROM books WHERE id = :id FOR UPDATE'); // prepare() 建立 prepared s>
$stmt->execute([':id' => $bookId]); // 把實際值綁定到佔位符並執行查詢
$book = $stmt->fetch(); // 取出一筆資料,查無資料時回傳 false
if ($book === false) {
$pdo->rollBack(); // 復原交易並釋放鎖定
$_SESSION['flash'] = ['type' => 'error', 'message' => '找不到這本書,借閱失敗'];
header('Location: index.php');
exit;
}
// books 資料表用 available 欄位記錄目前可借數量
if ((int) $book['available'] <= 0) {
$pdo->rollBack();
$_SESSION['flash'] = ['type' => 'error', 'message' => "《{$book['title']}》目前沒有庫存,借閱失敗"]; // 雙引號[>
header('Location: index.php');
exit;
}
// ------------------------------------------------------------
// 子區塊:新增借閱紀錄
// ------------------------------------------------------------
// NOW() 是 MySQL 函式,取得目前時間;due_date 為必填欄位,預設借期 14 天
$sql = 'INSERT INTO loans (member_id, book_id, borrowed_at, due_date, status)
VALUES (:member_id, :book_id, NOW(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), :status)';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':member_id' => $memberId,
':book_id' => $bookId,
':status' => 'borrowed',
]);
// ------------------------------------------------------------
// 子區塊:可借數量減 1
// ------------------------------------------------------------
// WHERE 多加 available > 0 當第二道防線:就算日後有人拿掉 FOR UPDATE,庫存也不會被扣成負數
$stmt = $pdo->prepare('UPDATE books SET available = available - 1 WHERE id = :id AND available > 0');
$stmt->execute([':id' => $bookId]);
// rowCount() 回傳實際被更新的列數,0 代表沒扣到庫存,丟出例外讓 catch 復原剛才新增的借閱紀錄
if ($stmt->rowCount() !== 1) {
throw new RuntimeException("扣減庫存失敗,book_id={$bookId}");
}
$pdo->commit(); // 提交交易,兩個寫入動作正式生效
$_SESSION['flash'] = ['type' => 'success', 'message' => "成功借閱《{$book['title']}》,請於 14 天內歸還"];
} catch (Throwable $e) { // Throwable 涵蓋 PDOException(資料庫錯誤)、RuntimeException(扣庫存失敗)與其他執行期錯誤
// inTransaction() 判斷交易是否仍在進行中,是的話才需要 rollBack,把借閱紀錄與庫存一起復原
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// 詳細錯誤只寫進伺服器 log 方便除錯,畫面只顯示友善訊息,不把 SQL 細節暴露給使用者
error_log('[borrow.php] member_id=' . $memberId . ' book_id=' . $bookId . ' ' . get_class($e) . ': ' . $e->getMessa>
$_SESSION['flash'] = ['type' => 'error', 'message' => '借閱時發生錯誤,請稍後再試'];
}
// ============================================================
// 【PHP】區塊:處理完成導回首頁
// ============================================================
header('Location: index.php');
exit;
$pdo->beginTransaction():關閉 MySQL 的自動提交模式,接下來所有的資料庫異動都會暫存在 Transaction 中,直到執行 commit()。SELECT ... FOR UPDATE(悲觀鎖):這是修復 Race Condition 的靈魂。當第一個請求執行這行 SQL 時,MySQL 會對該筆書籍資料加上「排他鎖」。其他並行的請求如果也嘗試讀取這行資料,會被強制阻塞排隊,直到第一個事務提交或回滾釋放鎖定。WHERE available > 0 與 rowCount() 雙保險:在 UPDATE 時補上 WHERE available > 0,並檢查 $stmt->rowCount() !== 1。如果沒扣成功就拋出例外。error_log():使用 PHP 7+ 的 Throwable 能捕捉大部分程式錯誤與 Exception。若交易尚未關閉,先呼叫 inTransaction() 檢查再 rollBack(),並把系統層面詳細的錯誤記在 Log,畫面上只提示使用者友善訊息。再次設定測試環境:
ab -n 10 -c 10 -C "PHPSESSID=你的測試ID" -p post_data.txt -T "application/x-www-form-urlencoded" http://localhost/borrow.php
測試結果檢驗:
這代表交易鎖定機制成功發揮防禦作用,抵禦了併發攻擊。
今天使用 Transaction + FOR UPDATE(悲觀鎖) 解決了併發搶購產生的 Race Condition,保證了資料在庫存異動時的嚴謹與正確性。