iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Software Development

手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫系列 第 16

Day 16:Redis 事務(Transaction)原理與 MULTI, EXEC, DISCARD 實作

  • 分享至 

  • xImage
  •  

在寫 SQL 時大家都聽過 ACID,但在 Redis 裡,事務(Transaction)其實就是把多個命令打包,然後一次性、按順序跑完不被打斷。

今天來實作這套機制,把 MULTIEXECDISCARD 補上去。


Redis 事務的特點與設計哲學

Redis 的事務跟 SQL 裡熟悉的那套不太一樣,有幾個地方要先記住:

  1. 無回滾(No Rollback):如果事務中的某個命令執行失敗(例如對 String 執行 HSET 產生型別錯誤),Redis 會繼續執行後續命令,而不會將已執行的命令撤銷。它比較相信命令錯誤應該在開發階段先修掉,而不是靠執行期回滾補救。
  2. 隔離性與原子性:因為 Redis 核心是single-thread 執行的,所以一旦事務開始執行(EXEC 被呼叫),該事務中的所有命令都會被集中且不被打斷地執行,中途不會插入其他客戶端的命令。
  3. 命令排隊:當客戶端發送 MULTI 後,後續發送的命令不會被立即執行,而是回傳 QUEUED 並排隊存入客戶端的快取隊列中。直到發送 EXEC 時,才一次性重播執行。

程式碼實作:Client 狀態與 Dispatcher 攔截

我把事務狀態與排隊隊列保存在每個連線獨立的 Client 上下文中。

1. 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 命令包
}

2. Dispatcher 中事務命令的攔截與執行

我在 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 事務的排隊和執行流程補起來,MULTIEXECDISCARD 都先跑通了。這段實作我一開始沒弄好鎖的粒度,結果執行 EXEC 的時候整個 server 鎖死卡住,debug 了半天才發現是重複上鎖,真的不能隨便 copy paste 啊。

明天來寫 Redis 的廣播發布系統 Pub/Sub,又要用到 Go 的 channel 了,準備迎接並行轟炸,明天見!


上一篇
Day 15:實作命令處理 - 整合 List、Hash、Set 與 ZSet 命令
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言