iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Cloud Native

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

Go 語言搶票煉金術 Day 14 - Redis 的終極武器:Lua Script 的實踐

  • 分享至 

  • xImage
  •  

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

Day 14 - Redis 的終極武器:Lua Script 的實踐

昨天,我們用 client.Eval() 成功地執行了第一個 Lua 腳本,解決了多個命令之間的原子性問題。解決了最主要的業務邏輯。

今天,我們要更進一步了解 Lua Script 的最佳實踐,包括腳本管理、錯誤處理和性能優化。

但別急著寫程式碼。

先問個問題:為什麼要預載入腳本?為什麼不能每次都傳送完整的腳本內容?

問題定義:腳本管理

在上一篇中,每次調用 Eval 時都傳送完整的腳本內容,透過網路傳送給 Redis。
Redis 收到後,每一次都要重新解析、重新編譯這個腳本,然後才執行。

// 上一篇的性能隱患
result, err := r.client.Eval(ctx, script, []string{ticketKey, userKey}, ticketID).Int64()

問題點

  1. 網路開銷:每次請求都要傳送完整的腳本內容
  2. Redis 開銷:每次都要解析和編譯腳本
  3. 維護困難:腳本內容散落在程式碼中,難以統一管理
    https://ithelp.ithome.com.tw/upload/images/20250928/201244629IPZptRjkQ.png

解決方案:腳本預載入 (Script Preloading)

Redis 提供了 EVALSHA 命令,它使用腳本的 SHA1 雜湊值來執行預載入的腳本。

  1. 性能優化:避免重複傳送和解析腳本
  2. 網路效率:SHA1 雜湊值只有 40 個字元,遠小於腳本內容
  3. 快取機制:Redis 會快取腳本,提高執行效率

在服務啟動時,直接編譯並且快取腳本,執行期間就可以省去重複解析還有編譯的麻煩,用簡單的機制解決複雜的問題。
https://ithelp.ithome.com.tw/upload/images/20250928/20124462ja3w0hGtru.png

工程實踐:管理腳本和 NOSCRIPT 錯誤

那麼問題來了:
如果我們直接呼叫 EVALSHA,但 Redis 快取裡沒有這個腳本怎麼辦?(比如 Redis 重啟了,或者有人手動執行了 SCRIPT FLUSH)。

在這種情況下,Redis 會回傳一個 NOSCRIPT 錯誤。一個健壯的客戶端必須能處理這個錯誤:攔截它,然後自動用 EVAL 重新傳送一次完整的腳本,讓 Redis 再次快取。

聽起來很麻煩?
是的。但好消息是,你根本不需要自己處理這些
go-redis 函式庫已經幫你把這一切都封裝好了。
你只需要用對的物件。

忘掉昨天的 client.Eval(),我們現在用 redis.Script

重構程式碼

我們現在要把上一篇的 RedisTicketService 重構。

第一步:更新服務結構,持有腳本物件

我們不再把腳本字串當作區域變數,而是把它當作服務的一部分,在初始化時就準備好。

type RedisTicketService struct {
	client  *redis.Client
	// 我們用一個 map 來持有所有預載入的腳本
	// key 是我們給腳本取的名字,value 是 go-redis 的腳本物件
	scripts map[string]*redis.Script 
}

第二步:在服務啟動時預載入腳本

修改 NewRedisTicketService,讓它在建立服務實例時,就去載入、編譯、並快取我們的 Lua 腳本。

func NewRedisTicketService(client *redis.Client) *RedisTicketService {
	svc := &RedisTicketService{
		client:  client,
		scripts: make(map[string]*redis.Script),
	}

	// 服務啟動時就應該準備好所有依賴
	// 如果腳本載入失敗,服務就不應該啟動
	if err := svc.loadScripts(context.Background()); err != nil {
		log.Fatalf("錯誤:無法載入 Redis 腳本,服務終止: %v", err)
	}

	return svc
}

// loadScripts 是一個內部方法,專門用來處理腳本的載入
func (s *RedisTicketService) loadScripts(ctx context.Context) error {
	// 腳本內容和昨天一樣,只是現在我們把它交給 redis.Script 物件管理
	purchaseScriptContent := `if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -1 end; local quantity = tonumber(redis.call('GET', KEYS[1]) or '0'); if quantity <= 0 then return 0 end; redis.call('DECR', KEYS[1]); redis.call('SADD', KEYS[2], ARGV[1]); return 1`

	// 1. 建立一個 Script 物件
	purchaseScript := redis.NewScript(purchaseScriptContent)

	// 2. 執行 Load 命令,這會把腳本傳送給 Redis
	// go-redis 內部會拿到 SHA1 hash 並儲存在 purchaseScript 物件裡
	if err := purchaseScript.Load(ctx, s.client).Err(); err != nil {
		return fmt.Errorf("載入 'purchase' 腳本失敗: %w", err)
	}

	// 3. 將準備好的腳本物件存入我們的 map
	s.scripts["purchase"] = purchaseScript

	log.Println("✅ Redis 'purchase' 腳本已成功預載入")
	return nil
}

第三步:用正確的方式執行腳本

現在 PurchaseTicket 方法變得乾淨多了。它不再關心腳本的具體內容,只關心如何執行它。

func (s *RedisTicketService) PurchaseTicketWithUser(ctx context.Context, ticketID int64, userID int64) error {
	ticketKey := fmt.Sprintf("ticket:%d", ticketID)
	userKey := fmt.Sprintf("user:%d:purchased", userID)

	// 從 map 中取出預載入好的腳本
	purchaseScript := s.scripts["purchase"]

	// 關鍵在這裡!
	// script.Run() 會優先使用 EVALSHA。
	// 如果 Redis 回報 NOSCRIPT 錯誤,它會自動、無縫地切換到 EVAL 來重載腳本。
	// 這一切對我們是完全透明的。
	result, err := purchaseScript.Run(ctx, s.client, []string{ticketKey, userKey}, ticketID).Int64()
	if err != nil {
		return fmt.Errorf("執行 purchase 腳本時發生 Redis 錯誤: %v", err)
	}

	// 之後的邏輯和 Day 13 完全一樣
	switch result {
	case 1:
		return nil // 成功
	case 0:
		return ErrSoldOut
	case -1:
		return ErrAlreadyPurchased
	default:
		return fmt.Errorf("收到未知的腳本執行結果: %d", result)
	}
}

總結

  • client.Eval(),每次都傳送腳本。簡單、直覺,但效率低下

  • redis.Script 物件,一次載入,之後用 Run() 呼叫。健壯、高效、可維護

「終極武器」的真正含義,不是指 Lua 這個語言本身,指正確、高效地使用工具的方法

參考資源


上一篇
Go 語言搶票煉金術 Day 13 - 原子性的延伸:單一命令不夠用時,如何用 Lua 在 Redis 當中實現
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言