iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Cloud Native

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

Go 語言搶票煉金術 Day 16 - 流程解耦:為什麼你需要消息佇列

  • 分享至 

  • xImage
  •  

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

什麼是解耦?為什麼你的系統正在窒息

現在我們的搶票系統已經用了 Redis 來處理庫存,速度確實提升了,但整個系統仍然有個致命問題:API 必須等待資料庫操作完成後才能回應

這意思是:

  1. 每個 HTTP 請求都必須等待最慢的那個組件(資料庫)
  2. 一個資料庫寫入問題就能讓整個 API 層崩潰
  3. 用戶體驗糟糕,等待時間長

在資料庫響應之前,那個處理請求的執行緒/程序/goroutine 就被佔用了,什麼也做不了。
這種設計在高併發下只有一個結局:崩潰

非同步不是選項,是唯一的出路

可能會有人想說:「簡單,我在 API 裡開一個 goroutine/thread 去寫資料庫就行了。」

// 這是錯誤示範.....
func purchaseHandler(c *gin.Context) {
    // ... 扣減 Redis 庫存 ...

    // 把它丟到背景
    go func() {
        db.CreateOrder(...) // 如果這裡崩潰了呢?
    }()

    c.JSON(200, gin.H{"message": "訂單處理中"})
}

問題不只是「等待」,而是可靠性 (Reliability)

  1. 應用程式崩潰 = 資料永久遺失:你的 API 伺服器只要重啟或崩潰,記憶體裡正在執行的所有 goroutine 都會人間蒸發。那些還沒來得及寫入資料庫的訂單就永遠消失了。
  2. 沒有重試機制:如果資料庫剛好抖動了一下,寫入失敗,訂單就又丟了。你打算在 goroutine 裡寫一堆複雜的重試邏輯嗎?祝你好運。
  3. 無法擴展:處理訂單的邏輯和接收請求的邏輯硬綁在同一台機器上,你無法獨立擴展消費者。

解決方案的本質:一個可靠的緩衝區

我們需要的不是一個花俏的技巧,而是一個簡單、堅固的工程化結構:一個持久化的、支持交易的緩衝區。這就是消息佇列(Message Queue)的真正角色。

它的工作流程必須滿足以下最低要求

  1. 生產者 (Producer) 將「訂單」這個訊息 (Message) 可靠地發送到佇列。
  2. 佇列將這個訊息持久化,確保即使佇列服務本身掛了再重啟,訊息也不會丟失。
  3. 消費者 (Consumer) 從佇列中取出訊息。這一步至關重要:訊息不是立即被刪除,而是被標記為「處理中」(unacked / invisible),在約定時間內,其他消費者看不到它。
  4. 消費者成功將訂單寫入資料庫後,向佇列發送一個確認 (Acknowledgement, ACK)
  5. 佇列收到 ACK 後,才會將該訊息徹底刪除

如果消費者在處理過程中崩潰,或者處理超時,它就永遠不會發送 ACK。佇列在超時後,會讓這條訊息對其他(或者同一個)消費者重新可見,實現了自動重試。

看到了嗎?這套機制保證了訊息「至少被成功處理一次 (At-Least-Once Delivery)」。這才是工業級的解決方案。

重新設計架構:關注點分離和使用者合約

改造後的架構長這樣:

使用者請求 -> API (Producer) -> [原子操作:扣庫存 + 發訊息] -> 消息佇列 -> 立即返回 202 Accepted
                                                                     |
                                                                     v
                                                                 消費者 (Consumer) -> [讀取訊息] -> 寫入資料庫 -> ACK

這個架構有兩個關鍵點:

1. 原子性的生產者

「扣減 Redis 庫存」和「發送訊息到佇列」這兩個動作必須是原子性的。絕不能出現庫存扣了、訊息卻沒發出去的情況。實現這一點的正確方法是使用 Redis Lua 腳本,將 DECRLPUSH (或者 XADD for Streams) 綁定在一個服務端執行的事務性腳本中。別在你的應用程式程式碼裡試圖協調這兩個操作,網路延遲和應用崩潰會毀了你。

2. 清晰的使用者合約

你不能再像以前一樣返回 200 OK 並附上訂單詳情。
要返回 202 Accepted,並告訴使用者「你的訂單正在處理中」。

還有必須提供一個補償方案

最簡單務實的做法是:

  1. 在發送訊息到佇列前,先產生一個唯一的 order_id
  2. order_id202 回應立即返回給使用者。
  3. 提供一個輪詢 API:GET /api/orders/{order_id},讓前端可以查詢訂單的最終狀態(PENDING, CONFIRMED, FAILED)。

Producer 端 (API 伺服器)

// 使用 Lua 腳本確保原子性
var lua_script = `
    local stock = redis.call('DECR', KEYS[1])
    if stock < 0 then
        redis.call('INCR', KEYS[1])
        return 'OUT_OF_STOCK'
    end
    redis.call('XADD', KEYS[2], '*', 'order_data', ARGV[1])
    return 'OK'
`

func purchaseHandler(c *gin.Context) {
    order_data_json := ... // 序列化訂單數據
    order_id := ...      // 產生唯一ID

    // 執行 Lua 腳本
    result, err := redisClient.Eval(ctx, lua_script,
        []string{"ticket_stock:123", "order_stream"},
        order_data_json).Result()

    if err != nil {
        // 處理腳本執行錯誤
        c.JSON(500, gin.H{"error": "伺服器內部錯誤"})
        return
    }

    if result == "OUT_OF_STOCK" {
        c.JSON(400, gin.H{"error": "已售罄"})
        return
    }

    // 返回訂單 ID,讓客戶端可以查詢
    c.JSON(202, gin.H{"message": "訂單處理中", "order_id": order_id})
}

Consumer 端 (獨立服務)

func main() {
    for {
        // 1. 讀取訊息,但不自動 ACK
        // 使用 Redis Streams 的消費者組,可以做到這點
        message, err := queue.GetMessage(no_ack=true)
        if err != nil {
            continue
        }

        var order Order
        json.Unmarshal(message.Body, &order)

        // 2. 核心業務邏輯:寫入資料庫
        // 訂單表必須有 order_id 的唯一約束,這是實現冪等性的關鍵!
        err = db.CreateOrder(order)

        if err != nil {
            // 如果是資料庫暫時連不上,NACK 後消息會被重試
            if isTemporary(err) {
                queue.Nack(message, requeue=true)
            } else {
                // 如果是永久性錯誤 (如唯一鍵衝突),
                // 不要重試!直接送入 (Dead-Letter Queue)
                log.Errorf("永久性錯誤: %v", err)
                queue.SendToDLQ(message)
                queue.Ack(message) // 從原佇列移除
            }
        } else {
            // 3. 處理成功,發送 ACK
            log.Printf("訂單 %s 處理成功", order.ID)
            queue.Ack(message)
        }
    }
}

這個邏輯的重點:

  • 冪等性 (Idempotency):消費者必須可以安全地重覆執行。就算同一條訊息來了兩次,資料庫的唯一鍵約束會阻止第二筆訂單的建立,從而保證資料的正確性。

結論:管理複雜度

引入消息佇列,讓系統從同步處理模式轉變為異步處理模式,實現了生產者與消費者的解耦,大幅提升吞吐量和穩定性。

下一篇,我們會深入比較各種消息佇列技術,幫助搶票系統來選擇最合適的解決方案。


上一篇
Go 語言搶票煉金術 Day 15 - 提供服務:用 Gin 暴露你的搶票 API
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言