現在我們的搶票系統已經用了 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)
}
}
}
這個邏輯的重點:
引入消息佇列,讓系統從同步處理模式轉變為異步處理模式,實現了生產者與消費者的解耦,大幅提升吞吐量和穩定性。
下一篇,我們會深入比較各種消息佇列技術,幫助搶票系統來選擇最合適的解決方案。