iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

在上篇文章中,完成了基礎的借書功能,測試起來也都運作正常。

但是,網頁系統通常是多人同時使用的。今天要來討論的問題是:併發(Concurrency)與競態條件(Race Condition)。

如果今天有一本書的庫存只剩 1 本,而有兩位會員在「同一時間」同時點擊了借書按鈕,系統會發生什麼事?

一、什麼是競態條件(Race Condition)?

借書邏輯的程式碼是這樣寫的:

// Step 1: 查詢庫存
\(stmt =\)pdo->prepare('SELECT quantity FROM books WHERE id = :id');
\(stmt->execute([':id' =>\)bookId]);
\(book =\)stmt->fetch();

if ($book['quantity'] > 0) {
    // Step 2: 新增借閱紀錄
    // INSERT INTO loans ...
    
    // Step 3: 扣減庫存
    // UPDATE books SET quantity = quantity - 1 WHERE id = :id ...
}

當只有一個人在操作時,順序是 SELECT -> 驗證 -> INSERT -> UPDATE,完全沒有問題。

但當使用者 A 與使用者 B 同時發送請求時,時序(Timeline)可能會變成這樣:

時間點 使用者 A 的請求 使用者 B 的請求 資料庫庫存
T1 執行 SELECT,查到庫存 = 1 — 1
T2 — 執行 SELECT,也查到庫存 = 1 1
T3 驗證 1 > 0 通過,準備借書 — 1
T4 — 驗證 1 > 0 通過,準備借書 1
T5 執行 INSERT 並將庫存 -1 — — 0
T6 — 執行 INSERT 並將庫存 -1 -1

明明庫存只有 1 本,兩個人卻都借成功了,資料庫甚至出現了負數庫存(-1),或是產生了兩筆借閱紀錄卻只有扣 1 本庫存的資料不一致狀況。這就是典型的 Race Condition。

二、使用 Apache Bench (ab) 模擬高併發攻擊

WSL2 裡面的 Linux 提供了一個非常強大的壓力測試工具:Apache Bench (ab)。

為什麼測試時不是點網頁表單,而是建立 post_data.txt?
在真實瀏覽器中,會員是在前端頁面點擊按鈕,由瀏覽器將表單資料(如 book_id=1)打包成 POST 請求發送給後端。
但由於 ab 是命令列工具,無法手動點擊網頁表單,因此需要建立 post_data.txt 檔案來預先準備好 POST 的 Payload 內容,讓 ab 能夠精準模擬多個使用者同時按下表單送出按鈕的行為。

  1. 準備 POST 請求資料
    在專案目錄下建立 post_data.txt 檔案:
    # 建立帶有請求參數的內容
    echo "book_id=1" > post_data.txt
    
  2. 執行壓力測試指令
    接著發送 10 次 POST 請求(-n 10),並設定併發數為 10(-c 10):
    # 安裝 apache2-utils(若尚未安裝)
    sudo apt update && sudo apt install -y apache2-utils
    
    
    # 模擬 10 個 concurrent 請求同時搶借 book_id = 1 的書籍
    ab -n 10 -c 10 -p post_data.txt -T "application/x-www-form-urlencoded" http://localhost/borrow.php
    

為什麼執行完 ab 後資料庫庫存完全沒變?
第一次執行 ab 後,進 MySQL 檢查庫存若發現一點都沒減少,請檢查 ab 輸出的報告:

Complete requests: 10
Failed requests:   0
Non-2xx responses: 10

注意到 Non-2xx responses: 10。因為 ab 預設不會攜帶瀏覽器的 Session Cookie (PHPSESSID)。請求送到 borrow.php 時,被程式開頭的 if (!isset($_SESSION['member_id'])) 攔截,全部以 302 Redirect 重新導向至 login.php,根本沒有執行到借書邏輯。
解決方式(擇一即可):

  1. 帶上 Session Cookie:在瀏覽器登入後按 F12 複製 PHPSESSID 的值,並在 ab 指令中加上 -C "PHPSESSID=你的ID"。
  2. 測試時暫時註解:測試期間先將 borrow.php 中的 Session 登入檢查暫時註解,並在程式中先寫死 $memberId = 1。

三、壓力測試下的數據異變與防線失守

確保 ab 能順利通過登入驗證並執行借書邏輯後,再次發送 10 個併發請求。

這次回到 MySQL 檢視資料表,終於成功重現了併發產生的災難:

  1. books 資料表:書籍可借庫存 available 為負數(例如從原本的 1 本直接變成 -3 或更低)。
  2. loans 資料表:系統成功寫入了多筆借閱紀錄,遠超出實際可借出的書籍總量。

這項測試結果揭露了當前架構的致命缺陷:在 SELECT 檢查與 UPDATE 寫入之間存在著不可忽視的時間差,且未引入任何鎖定機制(Locking)。這使得前端傳入的多個請求能同時通過庫存檢查,最終導致後端防線全面失守。

四、常見的直覺誤區:用 quantity >= 0 就夠了嗎?

「那把 SQL 改成 UPDATE books SET available = available - 1 WHERE id = :id AND available > 0 不就好了嗎?」

這樣寫確實能防止庫存變成負數,但它沒有解決邏輯混亂的問題:

  • 第二個使用者的 INSERT INTO loans 可能已經執行了,導致 loans 表有一筆借閱紀錄,但 books 表的庫存卻沒有扣到。
  • 兩個步驟沒有被綁定成一個不可分割的「原子操作(Atomic Operation)」。

今天透過 ab 壓力測試工具,知道併發帶來的問題:超借。要徹底解決這個問題,需要引入資料庫的核心技術:Transaction(事務)。


上一篇
Day 17 借書功能 — 檢查庫存、紀錄借閱與書籍數量的邏輯
下一篇
Day 19 用 transaction 解決時序問題
系列文
從零打造圖書管理系統:WSL2 × MySQL × Claude Code 的整合實作 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言