網路和 Parser 搞定後,今天終於要進到重頭戲:記憶體資料庫引擎。
今天先把 Thread-safe 的記憶體儲存引擎做出來,讓後面的命令有地方可以真的讀寫資料。
熟悉 Redis 的讀者可能會問:「Redis 不是single-thread 的嗎?為什麼它的儲存引擎還需要考慮thread-safe?」
事實上:
在 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並行存取,有兩種常見的鎖機制:
sync.Mutex(寫入互斥):不論是讀還是寫,同一時間都只能有一個 Goroutine 存取資料。這會大大限制讀取效能。sync.RWMutex):
RLock)。Lock),此時其他讀與寫皆被阻塞。資料庫常見情境是讀多寫少,所以這裡先用 sync.RWMutex。讀取可以並行,寫入再互斥,算是簡單又夠用的第一版。
我在 code/db/db.go 中定義了核心結構 DB:
我使用一個名為 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 // 過期時間(為零值代表永久保存)
}
將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 雖然不是最終解,但很適合當第一版。
明天就在這個引擎上補 SET、GET 這幾個最基本的操作,大家明天見!