
現在我們的搶票系統已經用了 Redis 來處理庫存,速度確實提升了,但整個系統仍然有個致命問題:API 必須等待資料庫操作完成後才能回應。
這意思是:
在資料庫響應之前,那個處理請求的執行緒/程序/goroutine 就被佔用了,什麼也做不了。
這種設計在高併發下只有一個結局:崩潰。
可能會有人想說:「簡單,我在 API 裡開一個 goroutine/thread 去寫資料庫就行了。」
// 這是錯誤示範.....
func purchaseHandler(c *gin.Context) {
    // ... 扣減 Redis 庫存 ...
    // 把它丟到背景
    go func() {
        db.CreateOrder(...) // 如果這裡崩潰了呢?
    }()
    c.JSON(200, gin.H{"message": "訂單處理中"})
}
問題不只是「等待」,而是可靠性 (Reliability)。
我們需要的不是一個花俏的技巧,而是一個簡單、堅固的工程化結構:一個持久化的、支持交易的緩衝區。這就是消息佇列(Message Queue)的真正角色。
它的工作流程必須滿足以下最低要求:
如果消費者在處理過程中崩潰,或者處理超時,它就永遠不會發送 ACK。佇列在超時後,會讓這條訊息對其他(或者同一個)消費者重新可見,實現了自動重試。
這套機制保證了訊息「至少被成功處理一次 (At-Least-Once Delivery)」。
改造後的架構長這樣:
使用者請求 -> API (Producer) -> [原子操作:扣庫存 + 發訊息] -> 消息佇列 -> 立即返回 202 Accepted
                                                                     |
                                                                     v
                                                                 消費者 (Consumer) -> [讀取訊息] -> 寫入資料庫 -> ACK
這個架構有兩個關鍵點:
「扣減 Redis 庫存」和「發送訊息到佇列」這兩個動作必須是原子性的。絕不能出現庫存扣了、訊息卻沒發出去的情況。實現這一點的正確方法是使用 Redis Lua 腳本,將 DECR 和 LPUSH (或者 XADD for Streams) 綁定在一個服務端執行的事務性腳本中。別在你的應用程式程式碼裡試圖協調這兩個操作,網路延遲和應用崩潰會毀了你。
你不能再像以前一樣返回 200 OK 並附上訂單詳情。
要返回 202 Accepted,並告訴使用者「你的訂單正在處理中」。
還有必須提供一個補償方案。
最簡單務實的做法是:
order_id。order_id 隨 202 回應立即返回給使用者。GET /api/orders/{order_id},讓前端可以查詢訂單的最終狀態(PENDING, CONFIRMED, FAILED)。// 使用 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})
}
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):消費者必須可以安全地重覆執行。就算同一條訊息來了兩次,資料庫的唯一鍵約束會阻止第二筆訂單的建立,從而保證資料的正確性。
毒丸處理 (Poison Pill):對於那些永遠無法被成功處理的訊息(比如格式錯誤、違反業務規則),不能無限重試。把它們隔離到死信佇列 (DLQ),供人工檢查。那個把訊息 LPush 回佇列頭部的做法是災難性的。
引入消息佇列,讓系統從同步處理模式轉變為異步處理模式,實現了生產者與消費者的解耦,大幅提升吞吐量和穩定性。
下一篇,我們會深入比較各種消息佇列技術,幫助搶票系統來選擇最合適的解決方案。