iT邦幫忙

2025 iThome 鐵人賽

DAY 2
2
Cloud Native

Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟系列 第 2

Go 語言搶票煉金術 Day 2 - 併發陷阱:為什麼你的搶票系統總在超賣?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250915/20124462YARMcXUyfa.png

Go 語言搶票煉金術 Day 2 - 併發陷阱:為什麼你的搶票系統總在超賣?

在高併發場景下,當多個請求試圖同時修改同一個資源時——例如搶票系統中的庫存數量——問題就會出現。
處理不當會直接導致資料不一致(超賣)和系統效能瓶頸。

失效的模式:「讀取 → 修改 → 寫入」

處理庫存最直覺的邏輯如下:

  1. 讀取 (Read): 從資料庫查詢當前的剩餘票數。

  2. 修改 (Modify): 在應用程式的記憶體中,判斷票數是否足夠,並計算新票數。

  3. 寫入 (Write): 將新票數更新回資料庫。

在單一請求下,這個邏輯完美無缺。

https://ithelp.ithome.com.tw/upload/images/20250916/20124462DneowhUndb.png
但在並行環境中,從「讀取」到「寫入」之間存在一個時間視窗。

如果兩個請求在這個視窗內交錯執行,就會產生競態條件 (Race Condition)

兩個請求 (AB) 同時讀取到剩餘 1 張票。
它們都在各自的記憶體中判斷票數足夠,都計算出新票數為 0,然後都執行了 UPDATE

結果,系統賣出了 2 張票,但庫存只減少了 1。
超賣就這樣發生了。
https://ithelp.ithome.com.tw/upload/images/20250916/2012446296nWfgB6nC.png


實作:一個注定失敗的併發程式

那在並行環境下是如何發生的?
這次我們就用 GO 語言來模擬這個情境:
系統只有 1000 張票,卻有 1 萬人 同時在搶,而且每個人最多只能買 5 張
票賣出去,還要有 交易紀錄,最最最重要是,最後賣掉的票 一定要剛好 1000 張,一張都不能多,也不能少。

https://ithelp.ithome.com.tw/upload/images/20250916/201244620zfj5aEC8k.png

我們的主要邏輯在 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(&currentQuantity)
	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

https://ithelp.ithome.com.tw/upload/images/20250916/20124462tsl936CCZm.png

從 API 回應可以看到購買數量來計算總數
https://ithelp.ithome.com.tw/upload/images/20250916/201244625aACdYxrOP.png

結果分析

指標 數值 說明
初始票數 1000
發送請求數 10
預期剩餘票數 974 1000 - 26 = 974
實際剩餘票數 988 (每次執行結果可能略有不同)
資料不一致 超賣 14 張 (1000 - 988) = 12 張票被扣減,但 26 個請求都收到了成功回應

這個結果證明
系統認為賣了 26 張,實際上只扣了 12 張。
差額是 26 - 12 = 14。這 14 張票就是憑空產生的負債。


重點摘要

  • 高併發庫存扣減的核心挑戰是原子性 Atomicity
  • 「讀取 → 修改 → 寫入」模式在併發環境下是非原子性的,它會引入競爭條件,導致嚴重的資料不一致問題。
  • 我們透過一個簡單的 Go 原型和壓力測試,成功複現並證明了這個問題的存在。

心得小結

今天,我們精確地定義了敵人。
從明天開始,我們將學習如何擊敗它。

參考資源


上一篇
Go 語言搶票煉金術 Day1 -工程師的價值,是將程式碼煉成金礦
下一篇
Go 語言搶票煉金術 Day 3 - Go 的併發工具 (一):goroutine 與 WaitGroup
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言