寫這篇文章的目的,是為了幫助更多的人理解 rosedb,我會從零開始實現一個簡單的包含 PUT、GET、DELETE 操作的 k-v 儲存引擎,你可以將其看做是一個簡易版本的 rosedb,就叫它 minidb 吧(mini 版本的 rosedb)。
無論你是 Go 語言初學者,還是想進階 Go 語言,或者是對 k-v 儲存感興趣,都可以嘗試自己動手實現一下,我相信一定會對你幫助很大的。
說到儲存,其實解決的一個核心問題就是,怎麼存放資料,怎麼取出資料。在計算機的世界裡,這個問題會更加的多樣化。
計算機當中有記憶體和磁碟,記憶體是易失性的,掉電之後儲存的資料全部丟失,所以,如果想要系統崩潰再重啟之後依然正常使用,就不得不將資料儲存在非易失性介質當中,最常見的便是磁碟。
所以,針對一個單機版的 k-v,我們需要設計資料在記憶體中應該怎麼存放,在磁碟中應該怎麼存放。
當然,已經有很多優秀的前輩們去探究過了,並且已經有了經典的總結,主要將資料儲存的模型分為了兩類:B+ 樹和 LSM 樹。
本文的重點不是講這兩種模型,所以只做簡單介紹。
B+ 樹
B+ 樹由二叉查詢樹演化而來,通過增加每層節點的數量,來降低樹的高度,適配磁碟的頁,儘量減少磁碟 IO 操作。
B+ 樹查詢效能比較穩定,在寫入或更新時,會查詢並定位到磁碟中的位置並進行原地操作,注意這裡是隨機 IO,並且大量的插入或刪除還有可能觸發頁分裂和合並,寫入效能一般,因此 B+ 樹適合讀多寫少的場景。
LSM 樹
LSM Tree(Log Structured Merge Tree,日誌結構合併樹)其實並不是一種具體的樹型別的資料結構,而只是一種資料儲存的模型,它的核心思想基於一個事實:順序 IO 遠快於隨機 IO。
和 B+ 樹不同,在 LSM 中,資料的插入、更新、刪除都會被記錄成一條日誌,然後追加寫入到磁碟檔案當中,這樣所有的操作都是順序 IO。
LSM 比較適用於寫多讀少的場景。
看了前面的兩種基礎儲存模型,相信你已經對如何存取資料有了基本的瞭解,而 minidb 基於一種更加簡單的儲存結構,總體上它和 LSM 比較類似。
我先不直接乾巴巴的講這個模型的概念,而是通過一個簡單的例子來看一下 minidb 當中資料 PUT、GET、DELETE 的流程,藉此讓你理解這個簡單的儲存模型。
PUT
我們需要儲存一條資料,分別是 key 和 value,首先,為預防資料丟失,我們會將這個 key 和 value 封裝成一條記錄(這裡把這條記錄叫做 Entry),追加到磁碟檔案當中。Entry 的裡面的內容,大致是 key、value、key 的大小、value 的大小、寫入的時間。
所以磁碟檔案的結構非常簡單,就是多個 Entry 的集合。
磁碟更新完了,再更新記憶體,記憶體當中可以選擇一個簡單的資料結構,比如雜湊表。雜湊表的 key 對應存放的是 Entry 在磁碟中的位置,便於查詢時進行獲取。
這樣,在 minidb 當中,一次資料儲存的流程就完了,只有兩個步驟:一次磁碟記錄的追加,一次記憶體當中的索引更新。
GET
再來看 GET 獲取資料,首先在記憶體當中的雜湊表查詢到 key 對應的索引資訊,這其中包含了 value 儲存在磁碟檔案當中的位置,然後直接根據這個位置,到磁碟當中去取出 value 就可以了。
DEL
然後是刪除操作,這裡並不會定位到原記錄進行刪除,而還是將刪除的操作封裝成 Entry,追加到磁碟檔案當中,只是這裡需要標識一下 Entry 的型別是刪除。
然後在記憶體當中的雜湊表刪除對應的 key 的索引資訊,這樣刪除操作便完成了。
可以看到,不管是插入、查詢、刪除,都只有兩個步驟:一次記憶體中的索引更新,一次磁碟檔案的記錄追加。所以無論資料規模如何, minidb 的寫入效能十分穩定。
Merge
最後再來看一個比較重要的操作,前面說到,磁碟檔案的記錄是一直在追加寫入的,這樣會導致檔案容量也一直在增加。並且對於同一個 key,可能會在檔案中存在多條 Entry(回想一下,更新或刪除 key 內容也會追加記錄),那麼在資料檔案當中,其實存在冗餘的 Entry 資料。
舉一個簡單的例子,比如針對 key A, 先後設定其 value 為 10、20、30,那麼磁碟檔案中就有三條記錄:
此時 A 的最新值是 30,那麼其實前兩條記錄已經是無效的了。
針對這種情況,我們需要定期合併資料檔案,清理無效的 Entry 資料,這個過程一般叫做 merge。
merge 的思路也很簡單,需要取出原資料檔案的所有 Entry,將有效的 Entry 重新寫入到一個新建的臨時檔案中,最後將原資料檔案刪除,臨時檔案就是新的資料檔案了。
這就是 minidb 底層的資料儲存模型,它的名字叫做 bitcask,當然 rosedb 採用的也是這種模型。它本質上屬於類 LSM 的模型,核心思想是利用順序 IO 來提升寫效能,只不過在實現上,比 LSM 簡單多了。
介紹完了底層的儲存模型,就可以開始程式碼實現了,我將完整的程式碼實現放到了我的 Github 上面,地址:
文章當中就擷取部分關鍵的程式碼。
首先是開啟資料庫,需要先載入資料檔案,然後取出檔案中的 Entry 資料,還原索引狀態,關鍵部分程式碼如下:
func Open(dirPath string) (*MiniDB, error) {
// 如果資料庫目錄不存在,則新建一個
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
if err := os.MkdirAll(dirPath, os.ModePerm); err != nil {
return nil, err
}
}
// 載入資料檔案
dbFile, err := NewDBFile(dirPath)
if err != nil {
return nil, err
}
db := &MiniDB{
dbFile: dbFile,
indexes: make(map[string]int64),
dirPath: dirPath,
}
// 載入索引
db.loadIndexesFromFile(dbFile)
return db, nil
}
再來看看 PUT 方法,流程和上面的描述一樣,先更新磁碟,寫入一條記錄,再更新記憶體:
func (db *MiniDB) Put(key []byte, value []byte) (err error) {
offset := db.dbFile.Offset
// 封裝成 Entry
entry := NewEntry(key, value, PUT)
// 追加到資料檔案當中
err = db.dbFile.Write(entry)
// 寫到記憶體
db.indexes[string(key)] = offset
return
}
GET 方法需要先從記憶體中取出索引資訊,判斷是否存在,不存在直接返回,存在的話從磁碟當中取出資料。
func (db *MiniDB) Get(key []byte) (val []byte, err error) {
// 從記憶體當中取出索引資訊
offset, ok := db.indexes[string(key)]
// key 不存在
if !ok {
return
}
// 從磁碟中讀取資料
var e *Entry
e, err = db.dbFile.Read(offset)
if err != nil && err != io.EOF {
return
}
if e != nil {
val = e.Value
}
return
}
DEL 方法和 PUT 方法類似,只是 Entry 被標識為了 DEL
,然後封裝成 Entry 寫到檔案當中:
func (db *MiniDB) Del(key []byte) (err error) {
// 從記憶體當中取出索引資訊
_, ok := db.indexes[string(key)]
// key 不存在,忽略
if !ok {
return
}
// 封裝成 Entry 並寫入
e := NewEntry(key, nil, DEL)
err = db.dbFile.Write(e)
if err != nil {
return
}
// 刪除記憶體中的 key
delete(db.indexes, string(key))
return
}
最後是重要的合併資料檔案操作,流程和上面的描述一樣,關鍵程式碼如下:
func (db *MiniDB) Merge() error {
// 讀取原資料檔案中的 Entry
for {
e, err := db.dbFile.Read(offset)
if err != nil {
if err == io.EOF {
break
}
return err
}
// 記憶體中的索引狀態是最新的,直接對比過濾出有效的 Entry
if off, ok := db.indexes[string(e.Key)]; ok && off == offset {
validEntries = append(validEntries, e)
}
offset += e.GetSize()
}
if len(validEntries) > 0 {
// 新建臨時檔案
mergeDBFile, err := NewMergeDBFile(db.dirPath)
if err != nil {
return err
}
defer os.Remove(mergeDBFile.File.Name())
// 重新寫入有效的 entry
for _, entry := range validEntries {
writeOff := mergeDBFile.Offset
err := mergeDBFile.Write(entry)
if err != nil {
return err
}
// 更新索引
db.indexes[string(entry.Key)] = writeOff
}
// 刪除舊的資料檔案
os.Remove(db.dbFile.File.Name())
// 臨時檔案變更為新的資料檔案
os.Rename(mergeDBFile.File.Name(), db.dirPath+string(os.PathSeparator)+FileName)
db.dbFile = mergeDBFile
}
return nil
}
除去測試檔案,minidb 的核心程式碼只有 300 行,麻雀雖小,五臟俱全,它已經包含了 bitcask 這個儲存模型的主要思想,並且也是 rosedb 的底層基礎。
理解了 minidb 之後,基本上就能夠完全掌握 bitcask 這種儲存模型,多花點時間,相信對 rosedb 也能夠遊刃有餘了。
進一步,如果你對 k-v 儲存這方面感興趣,可以更加深入的去研究更多相關的知識,bitcask 雖然簡潔易懂,但是問題也不少,rosedb 在實踐的過程當中,對其進行了一些優化,但目前還是有不少的問題存在。
有的人可能比較疑惑,bitcask 這種模型簡單,是否只是一個玩具,在實際的生產環境中有應用嗎?答案是肯定的。
bitcask 最初源於 Riak 這個專案的底層儲存模型,而 Riak 是一個分散式 k-v 儲存,在 NoSQL 的排名中也名列前茅:
豆瓣所使用的的分散式 k-v 儲存,其實也是基於 bitcask 模型,並對其進行了很多優化。目前純粹基於 bitcask 模型的 k-v 並不是很多,所以你可以多去看看 rosedb 的程式碼,可以提出自己的意見建議,一起完善這個專案。
最後,附上相關專案地址:
minidb:github.com/roseduan/minidb
rosedb:github.com/roseduan/rosedb
參考資料:
riak.com/assets/bitcask-intro.pdf
medium.com/@arpitbhayani/bitcask-a...
本作品採用《CC 協議》,轉載必須註明作者和本文連結