昨天我們證明了用 Go 的記憶體鎖 sync.Mutex
鎖不住跨越多個服務實例的資料庫狀態,無法從根本上解決超賣問題。
這引導出一個簡單、不可違背的原則:保護資源的機制,必須和資源本身處於同一個層級。
今天,我們不寫複雜的 Go 程式碼。
我們將學習如何用一行 SQL 優雅地解決這個問題。
忘掉應用層的 if
判斷,忘掉任何抽象形式的鎖。正確的作法是將條件檢查和資料修改合併成一個單一的、不可分割的操作。
UPDATE
(99% 的場景首選)這是最簡單、最可靠、也是性能最高的方案。
除非有特殊理由,通常是預設選擇。
UPDATE tickets
SET quantity = quantity - 1
WHERE id = ? AND quantity > 0;
這是一個更複雜的方案,只在你明確知道業務場景符合「讀取和寫入間隔長、但實際衝突少」的特定模型時才考慮使用。
-- 注意:這只是操作的其中一步,它需要應用層的程式碼配合
UPDATE tickets
SET quantity = quantity - 1, version = version + 1
WHERE id = ? AND quantity > 0 AND version = ?;
任何「先讀後改」的邏輯,兩步之間的時間差就是競爭條件的溫床。資料庫天生就是處理這個問題的專家。
資料庫引擎(如 PostgreSQL)如何保證上面那句 SQL 的安全?
答案是原子性。
它將條件檢查 (WHERE quantity > 0
) 和資料修改 (SET quantity = quantity - 1
) 綁定為一個不可分割的原子操作 (Atomic Operation)。
機制如下:
原子性保證:資料庫保證這整句 UPDATE
是一個原子單元。
條件下推 (WHERE
子句):我們把應用層的業務邏輯 if currentQuantity > 0
,直接轉移到了 SQL 的 WHERE
子句裡。
quantity > 0
成立,就執行 SET quantity = quantity - 1
。quantity <= 0
,條件不滿足,這個 UPDATE
操作就什麼都不做。明確的執行結果:如何知道是否成功搶到了票?非常簡單,檢查「受影響的行數 (rows affected)」。
1
,代表有一行資料的 quantity
被成功減 1。你搶到票了。
0
,代表沒有任何行被更新。這意味著 WHERE
條件沒有滿足——通常是因為 quantity
已經等於 0。你沒搶到票,因為票賣完了。
結果乾淨、明確,沒有任何模稜兩可。
資料庫是如何做到這一點的?答案是鎖。
當一個事務執行 UPDATE ... WHERE ...
時,資料庫引擎會在那一行(或多行)將要被修改的資料上施加一個短暫的排他鎖(Exclusive Lock)。
這個鎖的作用很直白:「我正在修改這行,在我完成操作(提交或回滾)之前,任何其他想修改它的事務都必須等待。」
因為這個鎖的獲取和釋放是資料庫內部為單一語句自動完成的,速度極快,所以它非常高效。
我們可以稱之為一種隱式的、高效的悲觀鎖模式。
很多人聽說過「樂觀鎖性能比悲觀鎖好」,這是一個非常容易被誤解的說法。
在搶票這個場景下,真相可能恰恰相反。
性能:
方案 A (原子 UPDATE
):極快。資料庫用最高效的方式鎖定、修改、釋放。在高競爭下,失敗的請求會被資料庫快速拒絕,成功的請求則快速完成。
方案 B (樂觀鎖):在高衝突場景下,會導致大量 UPDATE
失敗並在應用層觸發重試。這些重試會給應用和資料庫帶來額外的 CPU 和網路負擔,性能反而可能更差。樂觀鎖更適用於「衝突概率低,但事務執行時間長」的場景(例如,使用者讀取一個表單,填寫了十分鐘後才提交更新)。
複雜性:
方案 A:極其簡單。應用層發送一條 SQL,檢查返回值即可。
方案 B:顯著增加了應用層的程式碼複雜度,你需要自己實現一個可靠的重試迴圈,並處理重試次數耗盡的最終失敗情況。
結論:永遠從方案 A 開始。
它更簡單、更可靠,而且在絕大多數高併發場景下性能表現更好。
只有當你通過壓力測試,證明方案 A 的短暫行鎖確實成為了系統瓶頸時,才值得去評估樂觀鎖帶來的額外複雜性。
重寫後的購票邏輯,比任何加鎖的版本都更短、更簡單、也更可靠。
// purchaseTicketAtomically: 使用單一原子 UPDATE 語句處理購票
func purchaseTicketAtomically(c *gin.Context) {
ticketID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "無效的票券 ID"})
return
}
// 執行單一、原子的 UPDATE 操作
result, err := db.Exec(
"UPDATE tickets SET quantity = quantity - 1 WHERE id = $1 AND quantity > 0",
ticketID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "資料庫操作失敗"})
return
}
// 檢查受影響的行數
rowsAffected, err := result.RowsAffected()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "無法獲取操作結果"})
return
}
// 根據結果給出回應
if rowsAffected == 0 {
c.JSON(http.StatusConflict, gin.H{"error": "票券已售完或不存在"}) // 409 Conflict 更符合語義
return
}
c.JSON(http.StatusOK, gin.H{"message": "成功購買 1 張票券"})
}
以下是樂觀鎖的正確實作,請注意其增加的複雜度。
// purchaseTicketWithOptimisticLock: 使用樂觀鎖和應用層重試處理購票
func purchaseTicketWithOptimisticLock(c *gin.Context) {
ticketID, _ := strconv.Atoi(c.Param("id"))
const maxRetries = 5 // 設定最大重試次數
for i := 0; i < maxRetries; i++ {
// 1. 讀取當前庫存和版本號(注意:此處不加鎖!)
var currentQuantity, currentVersion int
err := db.QueryRow("SELECT quantity, version FROM tickets WHERE id = $1", ticketID).Scan(¤tQuantity, ¤tVersion)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "找不到票券"})
return
}
// 2. 在應用層檢查業務邏輯
if currentQuantity <= 0 {
c.JSON(http.StatusConflict, gin.H{"error": "票券已售完"})
return
}
// 3. 執行帶版本條件的 UPDATE
result, err := db.Exec(
"UPDATE tickets SET quantity = quantity - 1, version = version + 1 WHERE id = $1 AND version = $2",
ticketID, currentVersion,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "資料庫操作失敗"})
return
}
rowsAffected, _ := result.RowsAffected()
// 4. 如果 rowsAffected 為 1,代表成功,跳出迴圈
if rowsAffected == 1 {
c.JSON(http.StatusOK, gin.H{"message": "成功購買 1 張票券"})
return
}
// 如果為 0,說明在你讀取和寫入之間,資料已被其他事務修改(版本號不匹配)
// 迴圈將自動進行下一次重試
}
// 如果重試次數耗盡仍然失敗
c.JSON(http.StatusConflict, gin.H{"error": "系統繁忙,請稍後再試"})
}
對比兩種方案的程式碼,方案 A 的優越性一目了然。
它將併發控制的複雜性完全交給了最擅長處理它的資料庫。
如果業務需要在扣減庫存的同時,為用戶建立訂單呢?這兩個操作必須共同成功或失敗。
這時,我們需要一個明確的資料庫事務 (Transaction)。
func purchaseAndCreateOrder(userID, ticketID int) error {
// 1. 開始事務
tx, err := db.Begin()
if err != nil {
return err
}
// defer 中檢查 tx 是否已提交,若沒有則 Rollback,防止中途 panic 導致事務洩漏
defer tx.Rollback()
// 2. 在事務中執行原子 UPDATE,這依然是關鍵!
result, err := tx.Exec(
"UPDATE tickets SET quantity = quantity - 1 WHERE id = $1 AND quantity > 0",
ticketID,
)
if err != nil {
return err // 錯誤發生,defer 中的 Rollback 會執行
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return errors.New("票券已售完") // 庫存不足,回滾事務
}
// 3. 在同一個事務中,建立訂單
_, err = tx.Exec(
"INSERT INTO orders (user_id, ticket_id) VALUES ($1, $2)",
userID, ticketID,
)
if err != nil {
return err // 插入訂單失敗,回滾事務
}
// 4. 所有操作都成功,提交事務
// 在 Read Committed 或更高的隔離級別下,Commit 時才會釋放 UPDATE 語句的行鎖
return tx.Commit()
}
即使在更複雜的事務中,UPDATE ... WHERE quantity > 0
這種原子操作依然是保證庫存安全的基礎。
事務只是將這份原子性的保護傘擴展到了更多的操作上。
原子性是根本:解決併發資源修改問題,關鍵是將「檢查」和「修改」合併成一個不可分割的原子操作。
把邏輯推給資料庫:利用資料庫的 WHERE
子句處理條件判斷,而不是在應用層做多此一舉且無效的檢查。
依賴執行結果,而不是預先查詢:透過檢查 RowsAffected
的結果來判斷操作是否成功,這是一種更可靠的模式。
理解不同策略的權衡:原子 UPDATE
(方案 A)是處理高併發減庫存最簡單高效的基礎。樂觀鎖(方案 B)是處理特定場景的複雜工具,而非普適的更優解。
防止 ABA 問題:樂觀鎖中的版本號機制確實可以防止 ABA 問題,這在某些對資料變更歷史敏感的複雜場景下是必要的。
最重要的原則只有一個:讓資料庫去做它擅長的事。
在你的資料規模大到別無選擇之前,任何關於複雜方案,都可能是一種過早的優化。
今天我們理解的,就是最簡單、最可靠、也通常是最高效的方案。
明天,我們就用壓力測試來親眼看看這個方案的極限在哪裡。
PostgreSQL 文件: Data Concurrency Control - 深入了解資料庫如何處理併發事務
PostgreSQL 文件: Transaction Isolation - 解釋 READ COMMITTED 如何保證資料一致性
ACID Properties in Database Systems - 資料庫 ACID 特性的完整說明