在前幾篇中,我們用 Redis DECRBY
解決了最簡單的庫存扣減問題。
但現實世界的業務需求往往更複雜。
今天,我們要面對一個新的挑戰:如何在不破壞原子性的前提下,實現更複雜的業務邏輯?
假設我們的業務需求變了:
原本的 DECRBY
方案,我們需要這樣做:
// 錯誤的實作:整個購買行為由多個獨立的 Redis 命令組成,非原子性。
func PurchaseTicketWithUser(ctx context.Context, ticketID int64, userID int64) error {
// 「讀取與檢查」階段:步驟 A、B
// 1. 檢查用戶是否已購買(SISMEMBER)
if isPurchased, err := r.client.SIsMember(ctx, fmt.Sprintf("user:%d:purchased", userID), ticketID).Result(); err != nil {
return err
} else if isPurchased {
return ErrAlreadyPurchased
}
// 2. 檢查票券是否還有庫存(GET)
quantity, err := r.client.Get(ctx, fmt.Sprintf("ticket:%d", ticketID)).Int64()
if err != nil {
return err
}
if quantity <= 0 {
return ErrSoldOut
}
// 步驟 C、D 存在一個時間差、讀到舊的庫存
// 3. 扣減庫存(DECRBY)
newVal, err := r.client.DecrBy(ctx, fmt.Sprintf("ticket:%d", ticketID), 1).Result()
if err != nil {
return err
}
if newVal < 0 {
_ = r.client.IncrBy(ctx, fmt.Sprintf("ticket:%d", ticketID), 1).Err()
return ErrSoldOut
}
// 4. 記錄用戶購買(SADD)
if err := r.client.SAdd(ctx, fmt.Sprintf("user:%d:purchased", userID), ticketID).Err(); err != nil {
// 這裡出錯了怎麼辦?庫存已經扣減了!
// 資料不一致問題:庫存已經扣減,但用戶購買紀錄失敗!
return err
}
return nil
}
這個實作有什麼問題?
問題:為什麼不用 Redis 的 WATCH
/MULTI
來實現樂觀鎖?
答案:在高熱點場景下,樂觀鎖會導致災難性的性能問題。
# WATCH/MULTI 的典型用法
WATCH ticket:1 user:7:purchased
MULTI
SISMEMBER user:7:purchased 1
GET ticket:1
DECRBY ticket:1 1
SADD user:7:purchased 1
EXEC
為什麼這樣不好?
EXEC
時發現被監視的 key 被修改,整個事務會被回滾,需要重試WATCH/MULTI
的本質是:「如果在我準備提交事務的期間,有別人動了我正在監視的資料,那我的這次提交就失敗。」
在高併發場景下,這意味著大量的操作會因為衝突而失敗,然後應用層需要不斷重試,造成惡性循環。
Lua Script 是 Redis 提供的腳本執行功能,它可以在 Redis 服務端原子性地執行多個命令。
使用 Lua 腳本,所有操作都被打包成一個命令發送給 Redis。
Redis 會像處理 DECRBY
一樣,將整個腳本作為一個不可分割的整體來執行,從而保證了原子性。
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
// RedisTicketService 封裝了基於 Redis 的票券操作
type RedisTicketService struct {
client *redis.Client
}
// NewRedisTicketService 建立 Redis 票券服務
func NewRedisTicketService(client *redis.Client) *RedisTicketService {
return &RedisTicketService{client: client}
}
// 錯誤處理定義
var (
ErrSoldOut = errors.New("票券已售完")
ErrAlreadyPurchased = errors.New("用戶已購買此票券")
ErrInvalidQuantity = errors.New("無效的數量")
)
// PurchaseTicketWithUser 嘗試購買票券(帶用戶檢查)
// 使用 Lua Script 確保原子性
func (r *RedisTicketService) PurchaseTicketWithUser(ctx context.Context, ticketID int64, userID int64) error {
// Lua Script:原子性地完成整個購買流程
script := `
local ticketKey = KEYS[1]
local userKey = KEYS[2]
local ticketID = ARGV[1]
-- 檢查票券是否還有庫存
local quantity = tonumber(redis.call('GET', ticketKey) or '0')
if quantity <= 0 then
return 0 -- 售罄
end
-- 檢查用戶是否已購買
if redis.call('SISMEMBER', userKey, ticketID) == 1 then
return -1 -- 重複購買
end
-- 扣減庫存
redis.call('DECRBY', ticketKey, 1)
-- 記錄用戶購買
redis.call('SADD', userKey, ticketID)
return 1 -- 成功
`
// 執行 Lua Script
ticketKey := fmt.Sprintf("ticket:%d", ticketID)
userKey := fmt.Sprintf("user:%d:purchased", userID)
result, err := r.client.Eval(ctx, script, []string{ticketKey, userKey}, ticketID).Int64()
if err != nil {
return fmt.Errorf("Lua Script 執行失敗: %v", err)
}
// 根據結果返回相應的錯誤
switch result {
case 1:
return nil // 成功
case 0:
return ErrSoldOut
case -1:
return ErrAlreadyPurchased
default:
return fmt.Errorf("未知的執行結果: %d", result)
}
}
// GetTicketQuantity 獲取票券剩餘數量
func (r *RedisTicketService) GetTicketQuantity(ctx context.Context, ticketID int64) (int64, error) {
key := fmt.Sprintf("ticket:%d", ticketID)
return r.client.Get(ctx, key).Int64()
}
// GetUserPurchasedTickets 獲取用戶已購買的票券
func (r *RedisTicketService) GetUserPurchasedTickets(ctx context.Context, userID int64) ([]string, error) {
key := fmt.Sprintf("user:%d:purchased", userID)
return r.client.SMembers(ctx, key).Result()
}
// SetTicketQuantity 設定票券數量
func (r *RedisTicketService) SetTicketQuantity(ctx context.Context, ticketID int64, quantity int64) error {
key := fmt.Sprintf("ticket:%d", ticketID)
return r.client.Set(ctx, key, quantity, 0).Err()
}
func main() {
// 建立 Redis 客戶端
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
PoolSize: 200,
MinIdleConns: 20,
PoolTimeout: 2 * time.Second,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
})
defer client.Close()
// 建立票券服務
ticketService := NewRedisTicketService(client)
// 測試連接
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
log.Fatalf("Redis 連接失敗: %v", err)
}
// 設定初始票券數量
ticketID := int64(1)
userID := int64(7)
initialQuantity := int64(1000)
if err := ticketService.SetTicketQuantity(ctx, ticketID, initialQuantity); err != nil {
log.Fatalf("設定票券數量失敗: %v", err)
}
fmt.Printf("✅ 票券 %d 初始數量: %d\n", ticketID, initialQuantity)
// 測試購買邏輯
fmt.Println("\n=== 測試購買邏輯 ===")
// 第一次購買
if err := ticketService.PurchaseTicketWithUser(ctx, ticketID, userID); err != nil {
fmt.Printf("❌ 第一次購買失敗: %v\n", err)
} else {
fmt.Println("✅ 第一次購買成功")
}
// 檢查剩餘數量
remaining, err := ticketService.GetTicketQuantity(ctx, ticketID)
if err != nil {
log.Fatalf("獲取剩餘數量失敗: %v", err)
}
fmt.Printf("剩餘數量: %d\n", remaining)
// 檢查用戶已購買的票券
purchased, err := ticketService.GetUserPurchasedTickets(ctx, userID)
if err != nil {
log.Fatalf("獲取用戶購買記錄失敗: %v", err)
}
fmt.Printf("用戶已購買的票券: %v\n", purchased)
// 嘗試重複購買
if err := ticketService.PurchaseTicketWithUser(ctx, ticketID, userID); err != nil {
fmt.Printf("❌ 重複購買被阻止: %v\n", err)
} else {
fmt.Println("✅ 重複購買成功")
}
// 再次檢查剩餘數量(應該沒有變化)
remaining, err = ticketService.GetTicketQuantity(ctx, ticketID)
if err != nil {
log.Fatalf("獲取剩餘數量失敗: %v", err)
}
fmt.Printf("剩餘數量: %d (應該沒有變化)\n", remaining)
}
return 1 -- 成功
return 0 -- 售罄
return -1 -- 重複購買
為什麼這樣設計?
local ticketKey = KEYS[1] -- 票券庫存 key
local userKey = KEYS[2] -- 用戶購買記錄 key
local ticketID = ARGV[1] -- 票券 ID(用於記錄)
為什麼這樣設計?
tonumber()
確保數值轉換的正確性local quantity = tonumber(redis.call('GET', ticketKey) or '0')
為什麼這樣設計?
GET
返回 nil
,or '0'
提供預設值tonumber()
確保數值比較的正確性今天我們學會了如何用 Lua Script 來處理複雜的原子性需求。
但這只是開始。明天,我們將深入學習 Lua Script 的最佳實踐,包括腳本管理、錯誤處理和性能優化。