在上篇文章中,完成了基礎的借書功能,測試起來也都運作正常。
但是,網頁系統通常是多人同時使用的。今天要來討論的問題是:併發(Concurrency)與競態條件(Race Condition)。
如果今天有一本書的庫存只剩 1 本,而有兩位會員在「同一時間」同時點擊了借書按鈕,系統會發生什麼事?
借書邏輯的程式碼是這樣寫的:
// 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。
WSL2 裡面的 Linux 提供了一個非常強大的壓力測試工具:Apache Bench (ab)。
為什麼測試時不是點網頁表單,而是建立
post_data.txt?
在真實瀏覽器中,會員是在前端頁面點擊按鈕,由瀏覽器將表單資料(如 book_id=1)打包成 POST 請求發送給後端。
但由於 ab 是命令列工具,無法手動點擊網頁表單,因此需要建立post_data.txt檔案來預先準備好 POST 的 Payload 內容,讓 ab 能夠精準模擬多個使用者同時按下表單送出按鈕的行為。
# 建立帶有請求參數的內容
echo "book_id=1" > post_data.txt
# 安裝 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,根本沒有執行到借書邏輯。
解決方式(擇一即可):
- 帶上 Session Cookie:在瀏覽器登入後按 F12 複製 PHPSESSID 的值,並在 ab 指令中加上
-C "PHPSESSID=你的ID"。- 測試時暫時註解:測試期間先將
borrow.php中的 Session 登入檢查暫時註解,並在程式中先寫死$memberId = 1。
確保 ab 能順利通過登入驗證並執行借書邏輯後,再次發送 10 個併發請求。
這次回到 MySQL 檢視資料表,終於成功重現了併發產生的災難:
這項測試結果揭露了當前架構的致命缺陷:在 SELECT 檢查與 UPDATE 寫入之間存在著不可忽視的時間差,且未引入任何鎖定機制(Locking)。這使得前端傳入的多個請求能同時通過庫存檢查,最終導致後端防線全面失守。
quantity >= 0 就夠了嗎?「那把 SQL 改成 UPDATE books SET available = available - 1 WHERE id = :id AND available > 0 不就好了嗎?」
這樣寫確實能防止庫存變成負數,但它沒有解決邏輯混亂的問題:
今天透過 ab 壓力測試工具,知道併發帶來的問題:超借。要徹底解決這個問題,需要引入資料庫的核心技術:Transaction(事務)。