從零實現一個 k-v 存儲引擎

寫這篇文章的目的,是爲了幫助更多的人理解 rosedb,我會從零開始實現一個簡單的包含 PUT、GET、DELETE 操做的 k-v 存儲引擎,你能夠將其看作是一個簡易版本的 rosedb,就叫它 minidb 吧(mini 版本的 rosedb)。git

不管你是 Go 語言初學者,仍是想進階 Go 語言,或者是對 k-v 存儲感興趣,均可以嘗試本身動手實現一下,我相信必定會對你幫助很大的。github


說到存儲,其實解決的一個核心問題就是,怎麼存放數據,怎麼取出數據。在計算機的世界裏,這個問題會更加的多樣化。數據庫

計算機當中有內存和磁盤,內存是易失性的,掉電以後存儲的數據所有丟失,因此,若是想要系統崩潰再重啓以後依然正常使用,就不得不將數據存儲在非易失性介質當中,最多見的即是磁盤。數據結構

因此,針對一個單機版的 k-v,咱們須要設計數據在內存中應該怎麼存放,在磁盤中應該怎麼存放。app

固然,已經有不少優秀的前輩們去探究過了,而且已經有了經典的總結,主要將數據存儲的模型分爲了兩類:B+ 樹和 LSM 樹。分佈式

本文的重點不是講這兩種模型,因此只作簡單介紹。性能

B+ 樹測試

在這裏插入圖片描述

B+ 樹由二叉查找樹演化而來,經過增長每層節點的數量,來下降樹的高度,適配磁盤的頁,儘可能減小磁盤 IO 操做。優化

B+ 樹查詢性能比較穩定,在寫入或更新時,會查找並定位到磁盤中的位置並進行原地操做,注意這裏是隨機 IO,而且大量的插入或刪除還有可能觸發頁分裂和合並,寫入性能通常,所以 B+ 樹適合讀多寫少的場景。spa

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 爲 十、20、30,那麼磁盤文件中就有三條記錄:
在這裏插入圖片描述

此時 A 的最新值是 30,那麼其實前兩條記錄已是無效的了。

針對這種狀況,咱們須要按期合併數據文件,清理無效的 Entry 數據,這個過程通常叫作 merge。

merge 的思路也很簡單,須要取出原數據文件的全部 Entry,將有效的 Entry 從新寫入到一個新建的臨時文件中,最後將原數據文件刪除,臨時文件就是新的數據文件了。

在這裏插入圖片描述

這就是 minidb 底層的數據存儲模型,它的名字叫作 bitcask,固然 rosedb 採用的也是這種模型。它本質上屬於類 LSM 的模型,核心思想是利用順序 IO 來提高寫性能,只不過在實現上,比 LSM 簡單多了。


介紹完了底層的存儲模型,就能夠開始代碼實現了,我將完整的代碼實現放到了個人 Github 上面,地址:

https://github.com/roseduan/minidb

文章當中就截取部分關鍵的代碼。

首先是打開數據庫,須要先加載數據文件,而後取出文件中的 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:https://github.com/roseduan/minidb

rosedb:https://github.com/roseduan/rosedb

參考資料:

https://riak.com/assets/bitca...

https://medium.com/@arpitbhay...

相關文章
相關標籤/搜索