iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Cloud Native

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

Go 語言搶票煉金術:Day 7 - 資料庫方案:如何用原子 UPDATE 解決競爭條件

  • 分享至 

  • xImage
  •  

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

Go 語言搶票煉金術:Day 7 - 資料庫方案:如何用原子 UPDATE 解決競爭條件

昨天我們證明了用 Go 的記憶體鎖 sync.Mutex 鎖不住跨越多個服務實例的資料庫狀態,無法從根本上解決超賣問題。

這引導出一個簡單、不可違背的原則:保護資源的機制,必須和資源本身處於同一個層級。

今天,我們不寫複雜的 Go 程式碼。
我們將學習如何用一行 SQL 優雅地解決這個問題。

資料庫的解決方案:原子操作

忘掉應用層的 if 判斷,忘掉任何抽象形式的鎖。正確的作法是將條件檢查和資料修改合併成一個單一的、不可分割的操作

方案 A:利用原子 UPDATE(99% 的場景首選)

這是最簡單、最可靠、也是性能最高的方案。
除非有特殊理由,通常是預設選擇。

UPDATE tickets
SET quantity = quantity - 1
WHERE id = ? AND quantity > 0;

方案 B:帶版本號的樂觀鎖(進階方案)

這是一個更複雜的方案,只在你明確知道業務場景符合「讀取和寫入間隔長、但實際衝突少」的特定模型時才考慮使用。

-- 注意:這只是操作的其中一步,它需要應用層的程式碼配合
UPDATE tickets
SET quantity = quantity - 1, version = version + 1
WHERE id = ? AND quantity > 0 AND version = ?;

任何「先讀後改」的邏輯,兩步之間的時間差就是競爭條件的溫床。資料庫天生就是處理這個問題的專家。

方案 A 詳解:資料庫的原子性 (Atomicity)

資料庫引擎(如 PostgreSQL)如何保證上面那句 SQL 的安全?
答案是原子性
它將條件檢查 (WHERE quantity > 0) 和資料修改 (SET quantity = quantity - 1) 綁定為一個不可分割的原子操作 (Atomic Operation)

https://ithelp.ithome.com.tw/upload/images/20250921/201244622teorj9BxM.png

機制如下:

  1. 原子性保證:資料庫保證這整句 UPDATE 是一個原子單元。

    • 在它執行期間,沒有其他事務能看到「扣減了一半」的中間狀態。
    • 它要嘛完整成功,要嘛完全不做,從根本上消除了「讀取」和「寫入」之間的時間視窗。
  2. 條件下推 (WHERE 子句):我們把應用層的業務邏輯 if currentQuantity > 0,直接轉移到了 SQL 的 WHERE 子句裡。

    • 資料庫在執行更新前,會在內部原子地檢查這個條件。
    • 如果 quantity > 0 成立,就執行 SET quantity = quantity - 1
    • 如果 quantity <= 0,條件不滿足,這個 UPDATE 操作就什麼都不做。
  3. 明確的執行結果:如何知道是否成功搶到了票?非常簡單,檢查「受影響的行數 (rows affected)」。

    • 返回 1,代表有一行資料的 quantity 被成功減 1。你搶到票了。
    • 返回 0,代表沒有任何行被更新。這意味著 WHERE 條件沒有滿足——通常是因為 quantity 已經等於 0。你沒搶到票,因為票賣完了。

結果乾淨、明確,沒有任何模稜兩可。

方案 A 的內部機制:隱式的行級鎖 (Implicit Row-level Lock)

資料庫是如何做到這一點的?答案是

當一個事務執行 UPDATE ... WHERE ... 時,資料庫引擎會在那一行(或多行)將要被修改的資料上施加一個短暫的排他鎖(Exclusive Lock)

這個鎖的作用很直白:「我正在修改這行,在我完成操作(提交或回滾)之前,任何其他想修改它的事務都必須等待。」
因為這個鎖的獲取和釋放是資料庫內部為單一語句自動完成的,速度極快,所以它非常高效。
我們可以稱之為一種隱式的、高效的悲觀鎖模式。

方案 A vs 方案 B:一個關鍵的權衡

很多人聽說過「樂觀鎖性能比悲觀鎖好」,這是一個非常容易被誤解的說法。
在搶票這個場景下,真相可能恰恰相反。

  1. 性能

    • 方案 A (原子 UPDATE):極快。資料庫用最高效的方式鎖定、修改、釋放。在高競爭下,失敗的請求會被資料庫快速拒絕,成功的請求則快速完成。

    • 方案 B (樂觀鎖):在高衝突場景下,會導致大量 UPDATE 失敗並在應用層觸發重試。這些重試會給應用和資料庫帶來額外的 CPU 和網路負擔,性能反而可能更差。樂觀鎖更適用於「衝突概率低,但事務執行時間長」的場景(例如,使用者讀取一個表單,填寫了十分鐘後才提交更新)。

  2. 複雜性

    • 方案 A:極其簡單。應用層發送一條 SQL,檢查返回值即可。

    • 方案 B顯著增加了應用層的程式碼複雜度,你需要自己實現一個可靠的重試迴圈,並處理重試次數耗盡的最終失敗情況。

結論:永遠從方案 A 開始。
它更簡單、更可靠,而且在絕大多數高併發場景下性能表現更好。
只有當你通過壓力測試,證明方案 A 的短暫行鎖確實成為了系統瓶頸時,才值得去評估樂觀鎖帶來的額外複雜性。

Go 程式碼實現

方案 A:原子性 UPDATE (最推薦)

重寫後的購票邏輯,比任何加鎖的版本都更短、更簡單、也更可靠。

// 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 張票券"})
}

https://ithelp.ithome.com.tw/upload/images/20250921/20124462gfDbrzYJI2.png

方案 B:帶重試的樂觀鎖 (正確的實作)

以下是樂觀鎖的正確實作,請注意其增加的複雜度。

// 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(&currentQuantity, &currentVersion)
        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 問題,這在某些對資料變更歷史敏感的複雜場景下是必要的。

心得小結

最重要的原則只有一個:讓資料庫去做它擅長的事。

在你的資料規模大到別無選擇之前,任何關於複雜方案,都可能是一種過早的優化。
今天我們理解的,就是最簡單、最可靠、也通常是最高效的方案。

明天,我們就用壓力測試來親眼看看這個方案的極限在哪裡。

參考資源


上一篇
Go 語言搶票煉金術 Day 6 - 常見陷阱:為什麼不能用 Go 的鎖來解決資料庫的併發問題
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言