問題重現:競態條件
// 典型錯誤流程
$stock = $db->query("SELECT stock FROM products WHERE id=1")->fetchColumn();
if ($stock > 0) {
$db->exec("UPDATE products SET stock = stock - 1 WHERE id=1");
// 建立訂單...
}
// 併發時:兩請求同時讀到 stock=1,皆通過判斷,最終 stock=-1
方案對比總覽
| 方案 | 核心機制 | 優點 | 缺點 | 適用場景 |
| 1. 隊列單消費者 | 請求入隊,單進程序列化處理 | 邏輯簡單、絕對不超賣 | 延遲高、需額外 Worker、擴展難 | 非即時業務、後台異步處理 |
| 2. 資料庫樂觀鎖 | 版本號/條件更新 + 重試 | 無鎖開銷、資料庫原生支援 | 高衝突時重試多、應用層複雜 | 中低衝突、已有版本欄位 |
| 3. 條件更新(推薦) | UPDATE ... WHERE stock > 0 | 原子操作、無鎖、效能最佳、程式碼最少 | 需確認 affected_rows | 高併發扣減庫存、計數器 |
| 4. 檔案鎖 flock | 進程級獨佔鎖 | 跨資料庫、簡單部署 | 單機限制、效能瓶頸、Nfs 不支援 | 單機部署、簡單專案、無 Redis |
| 5. Redis Lua 腳本 | 原子 DEC + 判斷 | 極高效能、分散式支援、功能豐富 | 引入 Redis 依賴、需維護同步 | 高併發、分散式、需限流 |
方案三:條件更新(最推薦用於關係型資料庫)
function deductStock(PDO $pdo, int $productId, int $qty = 1): bool
{
$sql = "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$qty, $productId, $qty]);
return $stmt->rowCount() > 0; // 受影響行數 > 0 表示扣減成功
}
// 使用
$pdo->beginTransaction();
try {
if (deductStock($pdo, 123, 1)) {
// 建立訂單...
$pdo->commit();
return ['success' => true, 'msg' => '下單成功'];
} else {
$pdo->rollBack();
return ['success' => false, 'msg' => '庫存不足'];
}
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
方案四:檔案鎖 flock 完整實作
function acquireLock(string $lockName, int $timeout = 10): ?resource
{
$lockFile = sys_get_temp_dir() . "/lock_{$lockName}.lock";
$fp = fopen($lockFile, 'c+'); // 開啟/建立,指標在開頭
if (!$fp) return null;
$start = microtime(true);
while (microtime(true) - $start < $timeout) {
// LOCK_EX 獨佔鎖 | LOCK_NB 非阻塞
if (flock($fp, LOCK_EX | LOCK_NB)) {
return $fp; // 取得鎖,呼叫端負責釋放
}
usleep(10000); // 10ms 重試
}
fclose($fp);
return null; // 逾時
}
function processOrder(int $productId, int $userId): array
{
$lock = acquireLock("product_{$productId}", 5);
if (!$lock) {
return ['success' => false, 'msg' => '系統繁忙,請稍後再試'];
}
try {
// 這裡執行資料庫操作(已序列化)
$pdo->beginTransaction();
$stmt = $pdo->prepare("SELECT stock FROM products WHERE id=? FOR UPDATE");
$stmt->execute([$productId]);
$stock = $stmt->fetchColumn();
if ($stock <= 0) {
throw new Exception('庫存不足');
}
$pdo->exec("UPDATE products SET stock = stock - 1 WHERE id = $productId");
// 建立訂單...
$pdo->commit();
return ['success' => true];
} catch (Exception $e) {
$pdo->rollBack();
return ['success' => false, 'msg' => $e->getMessage()];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
方案五:Redis Lua 腳本(分散式高併發首選)
// Lua 腳本:原子檢查並扣減
$luaScript = <<script('LOAD', $luaScript);
function deductStockRedis(int $productId, int $qty = 1): array
{
global $redis, $sha;
$key = "stock:product:$productId";
// 初始化庫存(僅首次)
$redis->setnx($key, 100); // 假設初始 100
$result = $redis->evalSha($sha, 1, $key, $qty);
if ($result === -1) return ['success' => false, 'msg' => '商品不存在'];
if ($result === 0) return ['success' => false, 'msg' => '庫存不足'];
return ['success' => true, 'remaining' => $result];
}
// 同步回資料庫(異步寫入)
// 可用排程任務定期將 Redis 庫存同步至 MySQL
綜合建議:分層防護架構
// 第 1 層:前端防重(按鈕置灰、Token 防重複提交)
// 第 2 層:接入層限流(Nginx limit_req、Redis 計數器限流)
// 第 3 層:業務層原子扣減(Redis Lua / 條件 UPDATE)
// 第 4 層:資料庫最終一致性(異步同步、對帳任務)
// 典型組合:
// 高併發秒殺 → Redis Lua 扣減 + 異步寫入 MySQL + 訂單隊列
// 一般電商 → 條件 UPDATE + 事務 + 適度鎖
// 簡單內部系統 → flock + 事務
避坑檢查清單
- [ ] 資料庫隔離級別:READ COMMITTED 即可(條件 UPDATE 不需 Serializable)
- [ ] 明確檢查
rowCount() / affected_rows 而非再次 SELECT
- [ ] 事務範圍最小化:只包含扣減與訂單建立
- [ ] 死鎖預防:固定順序存取多表、適當索引
- [ ] Redis 同步策略:定時任務 / 變更通知 / 雙寫
- [ ] 壓測驗證:模擬真實併發、監控 CPU/IO/鎖等待