從零實現 k-v 儲存引擎

roseduan發表於2021-07-05

寫這篇文章的目的,是為了幫助更多的人理解 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 上面,地址:

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:github.com/roseduan/minidb

rosedb:github.com/roseduan/rosedb

參考資料:

riak.com/assets/bitcask-intro.pdf

medium.com/@arpitbhayani/bitcask-a...

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章