iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

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

Day 06:設計與實作thread-safe 的記憶體儲存引擎

  • 分享至 

  • xImage
  •  

網路和 Parser 搞定後,今天終於要進到重頭戲:記憶體資料庫引擎。

今天先把 Thread-safe 的記憶體儲存引擎做出來,讓後面的命令有地方可以真的讀寫資料。


為什麼需要thread-safe?

熟悉 Redis 的讀者可能會問:「Redis 不是single-thread 的嗎?為什麼它的儲存引擎還需要考慮thread-safe?」

事實上:

  1. Redis 官方實作:Redis 的核心命令處理確實是由一個single-thread 事件迴圈(Event Loop)來執行的,這使得它天然地避免了multi-thread 的競爭問題。
  2. Go 的併發模型:在 Go 語言中,我在 Day 2 設計的 TCP 伺服器使用了goroutine來併發處理多個客戶端連線。這意味著多個客戶端可能會同時讀寫記憶體資料庫。

在 Go 語言中,原生的 map 資料結構是不支援並行讀寫的。如果多個 Goroutine 同時寫入、或者一個讀一個寫同一個 map,Go 執行期(Runtime)會直接拋出嚴重錯誤(Panic):

fatal error: concurrent map read and map write

第一次寫這段的時候想說 Redis 是single-thread,乾脆不加鎖,結果 go test -race 一跑滿江紅,被慘電了一頓。

所以這版儲存引擎不能只包一個 map 就結束,至少要先把併發控制放進去。


sync.RWMutex的選擇

為了實現安全的多goroutine並行存取,有兩種常見的鎖機制:

  1. sync.Mutex(寫入互斥):不論是讀還是寫,同一時間都只能有一個 Goroutine 存取資料。這會大大限制讀取效能。
  2. RWMutex(sync.RWMutex
    • 允許多個 Goroutine 同時進行讀取操作(Read Lock, RLock)。
    • 僅允許一個 Goroutine 進行寫入操作(Write Lock, Lock),此時其他讀與寫皆被阻塞。

資料庫常見情境是讀多寫少,所以這裡先用 sync.RWMutex。讀取可以並行,寫入再互斥,算是簡單又夠用的第一版。


儲存引擎架構實作

我在 code/db/db.go 中定義了核心結構 DB

1. 資料結構與metadata結構

我使用一個名為 entry 的結構體來包裹儲存的實際數值與其metadata(Metadata,例如資料類型與過期時間):

type DataType int

const (
	TypeString DataType = iota
	TypeList
	TypeHash
	TypeSet
	TypeZSet
)

type entry struct {
	dataType DataType      // 標註資料型態
	val      interface{}   // 實際的值,可以是 string/[]byte、雙向鏈結、map 等
	expireAt time.Time     // 過期時間(為零值代表永久保存)
}

2. DB 結構體與初始化

將RWMutex與原生 map 封裝在一起:

type DB struct {
	mu   sync.RWMutex
	data map[string]*entry
}

func NewDB() *DB {
	return &DB{
		data: make(map[string]*entry),
	}
}

鎖的競爭優化思考

雖然使用 sync.RWMutex 解決了並行存取的安全性問題,但在高併發寫入時,單一的全域鎖(Global Lock)會成為效能瓶頸。所有寫入請求都需要競爭同一把鎖,這會導致goroutine阻塞與 CPU 上下文切換。

在後續的「進階特性與效能調優」階段中,我將會引進 分段鎖(Sharded Map) 技術。
其原理是將一把全域鎖拆分成多個獨立的桶(Bucket),每個桶擁有自己獨立的RWMutex。寫入時根據 Key 的雜湊值(Hash)定位到對應的桶。這樣可以將鎖的粒度降低,將鎖競爭減少到原來的幾十分之一。

目前先維持全域 RWMutex,讓邏輯保持簡單。等功能穩了,再來處理分段鎖這種優化會比較安全。


跑起來看看

今天的資料庫核心寫好了,我們可以寫個小測試,或者在 main.go 裡面稍微戳戳看:

$ go test -v ./db/...
# 預期輸出:
# === RUN   TestDatabase_SetGet
# --- PASS: TestDatabase_SetGet (0.00s)
# PASS
# ok      redis-clone/db    0.123s

看到綠色的 PASS,代表我們這個簡單的 Map 加上鎖,已經確實可以把資料存進去又拿出來了。這邊我第一版直接用 map 沒加鎖,go test -race 一跑就炸了,才意識到要加 RWMutex。multi-thread 的測試不能少啊!

總結

今天先把 DB 的骨架搭好,重點不是功能多,而是不要在多個 Goroutine 同時讀寫時炸掉。sync.RWMutex 雖然不是最終解,但很適合當第一版。

明天就在這個引擎上補 SETGET 這幾個最基本的操作,大家明天見!


上一篇
Day 05:實作 RESP Parser - 解析 Bulk Strings 與 Arrays,完成基礎的 Parser 單元測試
下一篇
Day 07:實作記憶體引擎基礎命令:SET, GET, DEL 與 EXISTS
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言