在高併發場景下,當多個請求試圖同時修改同一個資源時——例如搶票系統中的庫存數量——問題就會出現。
處理不當會直接導致資料不一致(超賣)和系統效能瓶頸。
處理庫存最直覺的邏輯如下:
讀取 (Read): 從資料庫查詢當前的剩餘票數。
修改 (Modify): 在應用程式的記憶體中,判斷票數是否足夠,並計算新票數。
寫入 (Write): 將新票數更新回資料庫。
在單一請求下,這個邏輯完美無缺。
但在並行環境中,從「讀取」到「寫入」之間存在一個時間視窗。
如果兩個請求在這個視窗內交錯執行,就會產生競態條件 (Race Condition)。
兩個請求 (A
和 B
) 同時讀取到剩餘 1
張票。
它們都在各自的記憶體中判斷票數足夠,都計算出新票數為 0
,然後都執行了 UPDATE
。
結果,系統賣出了 2 張票,但庫存只減少了 1。
超賣就這樣發生了。
那在並行環境下是如何發生的?
這次我們就用 GO 語言來模擬這個情境:
系統只有 1000 張票,卻有 1 萬人 同時在搶,而且每個人最多只能買 5 張。
票賣出去,還要有 交易紀錄,最最最重要是,最後賣掉的票 一定要剛好 1000 張,一張都不能多,也不能少。
我們的主要邏輯在 purchaseTicket
這個處理函式中,實現「讀取 → 修改 → 寫入」。
// purchaseTicket 演示了一個非原子性的購票操作,它會在併發下導致超賣
// 這段程式碼是故意寫錯的,用來展示問題
func purchaseTicket(c *gin.Context) {
ticketID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "無效的票券 ID"})
return
}
// 1. 讀取 (Read): 查詢當前票數
var currentQuantity int
err = db.QueryRow("SELECT quantity FROM tickets WHERE id = $1", ticketID).Scan(¤tQuantity)
if err != nil {
// ... 錯誤處理 ...
return
}
// 2. 修改 (Modify): 在應用層的記憶體中做判斷和計算
if currentQuantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "票券已售完"})
return
}
// 為了讓問題更清楚觀察,我們短暫暫停,增加「競爭條件」發生的機率
time.Sleep(50 * time.Millisecond)
// 隨機決定購買數量 (1-5張)
wantedQuantity := rand.Intn(5) + 1
purchaseQuantity := wantedQuantity
if purchaseQuantity > currentQuantity {
purchaseQuantity = currentQuantity // 確保不超過剩餘數量
}
// 計算更新後應有的票數
newQuantity := currentQuantity - purchaseQuantity
// 3. 寫入 (Write): 將計算出的新票數更新回資料庫
_, err = db.Exec("UPDATE tickets SET quantity = $1 WHERE id = $2", newQuantity, ticketID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新票券數量失敗"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("成功購買 %d 張票券,剩餘 %d 張", purchaseQuantity, newQuantity),
})
}
注意:我們在程式碼中加入了一個短暫的 time.Sleep
,這會人為地拉長「讀取」和「寫入」之間的時間視窗,讓競爭條件更容易被觸發。
我們使用一個簡單的 shell 腳本,同時發送 10 個購票請求:
for i in {1..10}; do curl -s -X POST http://localhost:8080/tickets/1/purchase & done; wait
執行後,我們查詢剩餘的票數。
curl -s http://localhost:8080/tickets
從 API 回應可以看到購買數量來計算總數
結果分析
指標 | 數值 | 說明 |
---|---|---|
初始票數 | 1000 | |
發送請求數 | 10 | |
預期剩餘票數 | 974 | 1000 - 26 = 974 |
實際剩餘票數 | 988 | (每次執行結果可能略有不同) |
資料不一致 | 超賣 14 張 | (1000 - 988) = 12 張票被扣減,但 26 個請求都收到了成功回應 |
這個結果證明
系統認為賣了 26
張,實際上只扣了 12
張。
差額是 26 - 12 = 14
。這 14
張票就是憑空產生的負債。
今天,我們精確地定義了敵人。
從明天開始,我們將學習如何擊敗它。
參考資源