在寫 SQL 時大家都聽過 ACID,但在 Redis 裡,事務(Transaction)其實就是把多個命令打包,然後一次性、按順序跑完不被打斷。
今天來實作這套機制,把 MULTI、EXEC 與 DISCARD 補上去。
Redis 的事務跟 SQL 裡熟悉的那套不太一樣,有幾個地方要先記住:
EXEC 被呼叫),該事務中的所有命令都會被集中且不被打斷地執行,中途不會插入其他客戶端的命令。MULTI 後,後續發送的命令不會被立即執行,而是回傳 QUEUED 並排隊存入客戶端的快取隊列中。直到發送 EXEC 時,才一次性重播執行。我把事務狀態與排隊隊列保存在每個連線獨立的 Client 上下文中。
這邊我一開始傻傻地直接把指令全部丟去跑,忘了如果中途有指令出錯,Redis 其實是不會回滾(Rollback)的。習慣了 MySQL 的事務,這點一開始還真是不太習慣。
我在 code/command/client.go 中定義了事務狀態:
type Client struct {
mu sync.Mutex
Conn net.Conn
Authenticated bool
InMulti bool // 標記是否開啟事務
MultiQueue []resp.Value // 儲存排隊的 RESP 命令包
}
我在 code/command/dispatcher.go 裡,在執行常規命令前進行了事務攔截:
// 1. MULTI:開啟事務
if cmdName == "MULTI" {
client.mu.Lock()
defer client.mu.Unlock()
if client.InMulti { return resp.NewError("ERR MULTI calls can not be nested") }
client.InMulti = true
client.MultiQueue = nil
return resp.NewSimpleString("OK")
}
// 2. DISCARD:取消事務,清除隊列
if cmdName == "DISCARD" {
client.mu.Lock()
defer client.mu.Unlock()
if !client.InMulti { return resp.NewError("ERR DISCARD without MULTI") }
client.InMulti = false
client.MultiQueue = nil
return resp.NewSimpleString("OK")
}
// 3. EXEC:一次性執行所有排隊的命令
if cmdName == "EXEC" {
client.mu.Lock()
inMulti := client.InMulti
queue := client.MultiQueue
client.InMulti = false
client.MultiQueue = nil
client.mu.Unlock()
if !inMulti { return resp.NewError("ERR EXEC without MULTI") }
if len(queue) == 0 { return resp.NewArray([]resp.Value{}) }
// 循序執行隊列中的每一條命令
replies := make([]resp.Value, 0, len(queue))
for _, cmdVal := range queue {
// 直接遞迴呼叫 Dispatch 執行(此時 InMulti 已經設為 false,所以會被直接執行)
res := d.Dispatch(client, cmdVal)
replies = append(replies, res)
}
return resp.NewArray(replies) // 將所有命令的回覆作為一個大陣列一次性寫回客戶端
}
// 4. 若處於事務中且非事務控制指令,則將命令排隊並回傳 QUEUED
if client.InMulti {
client.mu.Lock()
client.MultiQueue = append(client.MultiQueue, val)
client.mu.Unlock()
return resp.NewSimpleString("QUEUED")
}
這裡我選擇讓 EXEC 裡的命令再走一次 Dispatch。執行 EXEC 時,先短暫鎖定並複製隊列,接著清空狀態、把 InMulti 設回 false,再逐條分發。這樣排隊命令可以沿用原本的命令處理流程,也不用再另外寫一套執行器。
先把 Server 跑起來(假設我們在程式或設定檔裡把 maxkeys 設為 3):
go run ./code/main.go
來測試一下 LRU 淘汰機制:
# 塞入前三個 key
printf "*3\r\n\$3\r\nSET\r\n\$1\r\nA\r\n\$1\r\n1\r\n" | nc localhost 6379
printf "*3\r\n\$3\r\nSET\r\n\$1\r\nB\r\n\$1\r\n2\r\n" | nc localhost 6379
printf "*3\r\n\$3\r\nSET\r\n\$1\r\nC\r\n\$1\r\n3\r\n" | nc localhost 6379
# 此時記憶體有 A, B, C
# 現在 SET 第 4 個 key,應該會觸發淘汰最舊的 A
printf "*3\r\n\$3\r\nSET\r\n\$1\r\nD\r\n\$1\r\n4\r\n" | nc localhost 6379
# 驗證 A 被淘汰了 (GET A 預期回傳 nil)
printf "*2\r\n\$3\r\nGET\r\n\$1\r\nA\r\n" | nc localhost 6379
# 預期回覆:$-1 (Null Bulk String)
今天把 Redis 事務的排隊和執行流程補起來,MULTI、EXEC、DISCARD 都先跑通了。這段實作我一開始沒弄好鎖的粒度,結果執行 EXEC 的時候整個 server 鎖死卡住,debug 了半天才發現是重複上鎖,真的不能隨便 copy paste 啊。
明天來寫 Redis 的廣播發布系統 Pub/Sub,又要用到 Go 的 channel 了,準備迎接並行轟炸,明天見!