iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

在上篇文章中,使用 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;

三、關鍵程式碼觀念拆解

  1. $pdo->beginTransaction():關閉 MySQL 的自動提交模式,接下來所有的資料庫異動都會暫存在 Transaction 中,直到執行 commit()。
  2. SELECT ... FOR UPDATE(悲觀鎖):這是修復 Race Condition 的靈魂。當第一個請求執行這行 SQL 時,MySQL 會對該筆書籍資料加上「排他鎖」。其他並行的請求如果也嘗試讀取這行資料,會被強制阻塞排隊,直到第一個事務提交或回滾釋放鎖定。
  3. WHERE available > 0 與 rowCount() 雙保險:在 UPDATE 時補上 WHERE available > 0,並檢查 $stmt->rowCount() !== 1。如果沒扣成功就拋出例外。
  4. Throwable 搭配 error_log():使用 PHP 7+ 的 Throwable 能捕捉大部分程式錯誤與 Exception。若交易尚未關閉,先呼叫 inTransaction() 檢查再 rollBack(),並把系統層面詳細的錯誤記在 Log,畫面上只提示使用者友善訊息。

四、驗證成果

再次設定測試環境:

  • 將書籍庫存 available 重設為 1。
  • 執行相同的 10 個併發請求指令
ab -n 10 -c 10 -C "PHPSESSID=你的測試ID" -p post_data.txt -T "application/x-www-form-urlencoded" http://localhost/borrow.php

測試結果檢驗:

  • books 資料表:available 庫存停留在 0,沒有出現負數庫存。
  • loans 資料表:只寫入了一筆借閱紀錄,其餘 9 個併發請求都因為被鎖定排隊,讀取到庫存為 0 而被退回,提示「目前沒有庫存,借閱失敗」。

這代表交易鎖定機制成功發揮防禦作用,抵禦了併發攻擊。


今天使用 Transaction + FOR UPDATE(悲觀鎖) 解決了併發搶購產生的 Race Condition,保證了資料在庫存異動時的嚴謹與正確性。


上一篇
Day 18 兩個人同時借同一本書會怎樣
系列文
從零打造圖書管理系統:WSL2 × MySQL × Claude Code 的整合實作 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言