
在高併發場景下,當多個請求試圖同時修改同一個資源時——例如搶票系統中的庫存數量——問題就會出現。
處理不當會直接導致資料不一致(超賣)和系統效能瓶頸。
處理庫存最直覺的邏輯如下:
讀取 (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 張票就是憑空產生的負債。
今天,我們精確地定義了敵人。
從明天開始,我們將學習如何擊敗它。
參考資源