昨天,我們用 client.Eval()
成功地執行了第一個 Lua 腳本,解決了多個命令之間的原子性問題。解決了最主要的業務邏輯。
今天,我們要更進一步了解 Lua Script 的最佳實踐,包括腳本管理、錯誤處理和性能優化。
但別急著寫程式碼。
先問個問題:為什麼要預載入腳本?為什麼不能每次都傳送完整的腳本內容?
在上一篇中,每次調用 Eval
時都傳送完整的腳本內容,透過網路傳送給 Redis。
Redis 收到後,每一次都要重新解析、重新編譯這個腳本,然後才執行。
// 上一篇的性能隱患
result, err := r.client.Eval(ctx, script, []string{ticketKey, userKey}, ticketID).Int64()
Redis 提供了 EVALSHA
命令,它使用腳本的 SHA1 雜湊值來執行預載入的腳本。
在服務啟動時,直接編譯並且快取腳本,執行期間就可以省去重複解析還有編譯的麻煩,用簡單的機制解決複雜的問題。
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 這個語言本身,指正確、高效地使用工具的方法。