iT邦幫忙

2025 iThome 鐵人賽

DAY 13
1
Cloud Native

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

Go 語言搶票煉金術 Day 13 - 原子性的延伸:單一命令不夠用時,如何用 Lua 在 Redis 當中實現

  • 分享至 

  • xImage
  •  

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

Go 語言搶票煉金術:Day 13 - 原子性的延伸:單一命令不夠用時,如何用 Lua 在 Redis 當中實現

在前幾篇中,我們用 Redis DECRBY 解決了最簡單的庫存扣減問題。

但現實世界的業務需求往往更複雜。
今天,我們要面對一個新的挑戰:如何在不破壞原子性的前提下,實現更複雜的業務邏輯?

問題定義:複雜業務邏輯的原子性

假設我們的業務需求變了:

  1. 防重複購買:同一用戶不能重複購買同一場次的票
  2. 狀態檢查:扣減前要檢查票券是否還有庫存
  3. 記錄購買:扣減成功後要把用戶加入購買集合

原本的 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
}

這個實作有什麼問題?

  1. 非原子性:四個步驟之間存在時間視窗,可能被其他請求插入
  2. 資料不一致:如果步驟 4 失敗,庫存已經扣減但用戶沒有被記錄
  3. 競爭條件:兩個用戶可能同時通過步驟 1 和 2 的檢查

https://ithelp.ithome.com.tw/upload/images/20250927/201244628wIArZxAX5.png

為什麼不能用 WATCH/MULTI?

問題:為什麼不用 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

為什麼這樣不好?

  1. 衝突重試:如果 EXEC 時發現被監視的 key 被修改,整個事務會被回滾,需要重試
  2. 尾延遲爆炸:在高併發下,重試次數會呈指數增長
  3. 複雜度增加:需要處理重試邏輯和衝突檢測

WATCH/MULTI 的本質是:「如果在我準備提交事務的期間,有別人動了我正在監視的資料,那我的這次提交就失敗。」

在高併發場景下,這意味著大量的操作會因為衝突而失敗,然後應用層需要不斷重試,造成惡性循環。

https://ithelp.ithome.com.tw/upload/images/20250927/20124462HlHViJGgAk.png

解決方案:Lua Script

Lua Script 是 Redis 提供的腳本執行功能,它可以在 Redis 服務端原子性地執行多個命令。

為什麼是 Lua Script ?

  1. 天然原子性:整個腳本在 Redis 的單執行緒中執行,不會被中斷。
  2. 無鎖設計:不需要 WATCH/MULTI 樂觀鎖機制,沒有重試的煩惱。
  3. 語意清晰:業務邏輯集中在一個腳本中,易於理解和維護。
  4. 性能優異:避免了客戶端與服務端之間的網絡來回。

使用 Lua 腳本,所有操作都被打包成一個命令發送給 Redis。
Redis 會像處理 DECRBY 一樣,將整個腳本作為一個不可分割的整體來執行,從而保證了原子性。
https://ithelp.ithome.com.tw/upload/images/20250927/20124462QIeNXYbms7.png

實作:一個 Lua Script


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)
}

Lua Script 的設計

1. 返回值設計

return 1   -- 成功
return 0   -- 售罄
return -1  -- 重複購買

為什麼這樣設計?

  • 語意清晰:正數表示成功,零和負數表示不同的失敗原因
  • 易於處理:Go 程式碼可以用簡單的 switch 語句處理
  • 擴展性好:未來可以輕鬆添加新的錯誤類型

2. 參數設計

local ticketKey = KEYS[1]    -- 票券庫存 key
local userKey = KEYS[2]      -- 用戶購買記錄 key
local ticketID = ARGV[1]     -- 票券 ID(用於記錄)

為什麼這樣設計?

  • KEYS vs ARGV:KEYS 用於需要被監視的 key,ARGV 用於純參數
  • 命名清晰:變數名稱直接反映其用途
  • 類型安全:使用 tonumber() 確保數值轉換的正確性

3. 錯誤處理

local quantity = tonumber(redis.call('GET', ticketKey) or '0')

為什麼這樣設計?

  • 容錯性:如果 key 不存在,GET 返回 nilor '0' 提供預設值
  • 類型安全tonumber() 確保數值比較的正確性

重點摘要

  • 單一命令不夠用:複雜業務邏輯需要多個 Redis 命令的組合
  • WATCH/MULTI 不適合高熱點:樂觀鎖在高併發下會導致性能問題
  • Lua Script :天然原子性、無鎖設計、語意清晰
  • 設計要考慮擴展性:返回值、參數、錯誤處理都要為未來做準備

心得小結

今天我們學會了如何用 Lua Script 來處理複雜的原子性需求。

但這只是開始。明天,我們將深入學習 Lua Script 的最佳實踐,包括腳本管理、錯誤處理和性能優化。

參考資源


上一篇
Go 語言搶票煉金術 Day 12 - 建立連接:在 Go 中與 Redis 對話
下一篇
Go 語言搶票煉金術 Day 14 - Redis 的終極武器:Lua Script 的實踐
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言