使用 LSM Tree 思想實現一個 KV 資料庫

微軟技術棧發表於2022-07-19

image.png

▌目錄

設計思路

  • 記憶體表
  • WAL
  • SSTable 的結構
  • SSTable 元素和索引的結構
  • SSTable Tree
  • 記憶體中的 SSTable
  • 資料查詢過程
  • 何為 LSM-Treee
  • 參考資料
  • 整體結構

實現過程

  • 檔案壓縮測試
  • 插入測試
  • 載入測試
  • 查詢測試
  • SSTable 結構
  • SSTable 檔案結構
  • SSTable Tree 結構和管理 SSTable 檔案
  • 讀取 SSTable 檔案
  • SSTable 檔案合併
  • SSTable 查詢過程
  • 插入 SSTable 檔案過程
  • WAL 檔案恢復過程
  • 二叉排序樹結構定義
  • 插入操作
  • 查詢
  • 刪除
  • 遍歷演算法
  • Key/Value 的表示
  • 記憶體表的實現
  • WAL
  • SSTable 與 SSTable Tree
  • 簡單的使用測試

筆者前段時間在學習資料結構時,恰好聽說了 LSM Tree,於是試著通過 LSM Tree 的設計思想,自己實現一個簡單的 KV 資料庫。

程式碼已開源,程式碼倉庫地址:https://github.com/whuanle/lsm

筆者使用 Go 語言來實現 LSM Tree 資料庫,因為 LSM Tree 的實現要求對檔案進行讀寫、鎖的處理、資料查詢、檔案壓縮等,所以編碼過程中也提高了對 Go 的使用經驗,專案中也使用到了一些棧、二叉排序樹等簡單的演算法,也可以鞏固了基礎演算法能力。適當給自己設定挑戰目標,可以提升自己的技術水平。

下面,我們來了解 LSM Tree 的設計思想以及如何實現一個 KV 資料庫。

設計思路

▌何為 LSM-Treee

LSM Tree 的全稱為Log-Structured Merge Tree,是一種關於鍵值型別資料庫的資料結構。據筆者瞭解,目前 NoSQL 型別的資料庫如 Cassandra 、ScyllaDB 等使用了 LSM Tree。
LSM Tree 的核心理論依據是磁碟順序寫效能比隨機寫的速度快很多。因為無論哪種資料庫,磁碟 IO 都是對資料庫讀寫效能的最大影響因素,因此合理組織資料庫檔案和充分利用磁碟讀寫檔案的機制,可以提高資料庫程式的效能。LSM Tree 首先會在記憶體中緩衝所有寫操作,當使用的記憶體達到閾值時,便會將記憶體重新整理磁碟中,這個過程只有順序寫,不會發生隨機寫,因此 LSM 具有優越的寫入效能。

這裡筆者就不對 LSM Tree 的概念進行贅述,讀者可以參考下面列出的資料。

▌參考資料

▌整體結構

下圖是 LSM Tree 的整體結構,整體可以分為記憶體、磁碟檔案兩大部分,其中磁碟檔案除了資料庫檔案(SSTable 檔案)外,還包括了 WAL 日誌檔案。

記憶體表用於緩衝寫入操作,當 Key/Value 寫入記憶體表後,也會同時記錄到 WAL 檔案中,WAL 檔案可以作為恢復記憶體表資料的依據。程式啟動時,如果發現目錄中存在 WAL 檔案,則需要讀取 WAL 檔案,恢復程式中的記憶體表。

在磁碟檔案中,有著多層資料庫檔案, 每層都會存在多個 SSTable 檔案,SSTable 檔案用於儲存資料,即資料庫檔案。下一層的資料庫檔案,都是上一層的資料庫檔案壓縮合並後生成,因此,層數越大,資料庫檔案越大。

下面我們來了解詳細一點的 LSM Tree 不同部分的設計思路,以及進行讀寫操作時,需要經過哪些階段。

記憶體表

在 LSM Tree 的記憶體區域中,有兩個記憶體表,一個是可變記憶體表 Memory Table,一個是不可變記憶體表 Immutable Memory Table,兩者具有相同的資料結構,一般是二叉排序樹。
在剛開始時,資料庫沒有資料,此時 Memory Table 為空,即沒有任何元素,而 Immutable Memory Table 為 nil,即沒有被分配任何記憶體,此時,所有寫操作均在 Memory Table 上,寫操作包括設定 Key 的值和刪除 Key。如果寫入 Memory Table 成功,接著操作資訊會記錄到 WAL 日誌檔案中。

當然,Memory Table 中儲存的 Key/Value 也不能太多,否則會佔用太多記憶體,因此,一般當 Memory Table 中的 Key 數量達到閾值時,Memory Table 就會變成 Immutable Memory Table ,然後建立一個新的 Memory Table, Immutable Memory Table 會在合適的時機,轉換為 SSTable,儲存到磁碟檔案中。

因此, Immutable Memory Table 是一個臨時的物件,只在同步記憶體中的元素到 SSTable 時,臨時存在。

這裡還要注意的是,當記憶體表被同步到 SSTable 後,Wal 檔案是需要刪除的。使用 Wal 檔案可以恢復的資料應當與當前記憶體中的 KV 元素一致,即可以利用 WAL 檔案恢復上一次程式的執行狀態,如果當前記憶體表已經移動到 SSTable ,那麼 WAL 檔案已經沒必要保留,應當刪除並重新建立一個空的 WAL 檔案。

關於 WAL 部分的實現,有不同的做法,有的全域性只有唯一一個 WAL 檔案,有的則使用多個 WAL 檔案,具體的實現會根據場景而變化。

WAL

WAL 即 Write Ahead LOG,當進行寫入操作(插入、修改或刪除 Key)時,因為資料都在記憶體中,為了避免程式崩潰停止或主機停機等,導致記憶體資料丟失,因此需要及時將寫操作記錄到 WAL 檔案中,當下次啟動程式時,程式可以從 WAL 檔案中,讀取操作記錄,通過操作記錄恢復到程式退出前的狀態。

WAL 儲存的日誌,記錄了當前記憶體表的所有操作,使用 WAL 恢復上一次程式的記憶體表時,需要從 WAL 檔案中,讀取每一次操作資訊,重新作用於記憶體表,即重新執行各種寫入操作。因此,直接對記憶體表進行寫操作,和從 WAL 恢復資料重新對記憶體表進行寫操作,都是一樣的。

可以這樣說, WAL 記錄了操作過程,而且二叉排序樹儲存的是最終結果。

WAL 要做的是,能夠還原所有對記憶體表的寫操作,重新順序執行這些操作,使得記憶體表恢復到上一次的狀態

WAL 檔案不是記憶體表的二進位制檔案備份,WAL 檔案是對寫操作的備份,還原的也是寫操作過程,而不是記憶體資料

SSTable 的結構

SSTable 全稱是 Sorted String Table,是記憶體表的持久化檔案。
SSTable 檔案由資料區、稀疏索引區、後設資料三個部分組成,如下圖所示。

記憶體錶轉換為 SSTable 時,首先遍歷 Immutable Memory Table ,順序將每個 KV 壓縮成二進位制資料,並且建立一個對應的索引結構,記錄這個二進位制 KV 的插入位置和資料長度。然後將所有二進位制 KV 放到磁碟檔案的開頭,接著將所有的索引結構轉為二進位制,放在資料區之後。再將關於資料區和索引區的資訊,放到一個後設資料結構中,寫入到檔案末尾。

記憶體中每一個元素都會有一個 Key,在記憶體錶轉換為 SSTable 時,元素集合會根據 Key 進行排序,然後再將這些元素轉換為二進位制,儲存到檔案的開頭,即資料區中。

但是,我們怎麼從資料區中分隔出每一個元素呢?

對於不同的開發者,編碼過程中,設定的 SSTable 的結構是不一樣的,將記憶體錶轉為 SSTable 的處理方法也不一樣,因此這裡筆者只說自己在寫 LSM Tree 時的做法。

筆者的做法是在生成資料區的時候,不將元素集合一次性生成二進位制,而是一個個元素順序遍歷處理。

首先,將一個 Key/Value 元素,生成二進位制,放到檔案的開頭,然後生成一個索引,記錄這個元素二進位制資料在檔案的起始位置以及長度,然後將這個索引先放到記憶體中。

接著,不斷處理剩下的元素,在記憶體中生成對應的索引。

稀疏索引表示每一個索引執行檔案中的一個資料塊。

當所有元素處理完畢,此時 SSTable 檔案已經生成資料區。接著,我們再將所有的索引集合,生成二進位制資料,追加到檔案中。

然後,我們還需要為資料區和稀疏索引區的起始位置和長度,生成檔案後設資料,以便後續讀取檔案時可以分割資料區和稀疏索引區,將兩部分的資料單獨處理。

後設資料結構也很簡單,其主要有四個值:

// 資料區起始索引
  dataStart int64
  // 資料區長度
  dataLen int64
  // 稀疏索引區起始索引
  indexStart int64
  // 稀疏索引區長度
  indexLen int64

後設資料會被追加到檔案的末尾中,並且固定了位元組長度。

在讀取 SSTable 檔案時,我們先讀取檔案最後的幾個位元組,如 64 個位元組,然後根據每 8 個位元組還原欄位的值,生成後設資料,然後就可以對資料區和稀疏索引區進行處理了。

SSTable 元素和索引的結構

我們將一個 Key/Value 儲存在資料區,那麼這塊儲存了一個 Key/Value 元素的檔案塊,稱為 block,為了表示 Key/Value,我們可以定義一個這樣的結構:

`Key
Value
Deleted`

然後將這個結構轉換為二進位制資料,寫到檔案的資料區中。
為了定位 Key/Value 在資料區的位置,我們還需要定義一個索引,其結構如下:

`Key
Start
Length
`
每個 Key/Value 使用一個索引進行定位。

SSTable Tree

每次將記憶體錶轉換為 SSTable 時,都會生成一個 SSTable 檔案,因此我們需要管理 SSTable 檔案,以免檔案數量過多。

下面是 LSM Tree 的 SSTable 檔案組織結構。

在上圖中可以看到,資料庫由很多的 SSTable 檔案組成,而且 SSTable 被分隔在不同的層之中,為了管理不同層的 SSTable,所有 SSTable 磁碟檔案的組織也有一個樹結構,通過 SSTable Tree,管理不同層的磁碟檔案大小或者 SSTable 數量。

關於 SSTable Tree,有三個要點:

1,第 0 層的 SSTable 檔案,都是記憶體錶轉換的。
2,除第 0 層,下一層的 SSTable 檔案,只能由上一層的 SSTable 檔案通過壓縮合並生成,而一層的 SSTable 檔案在總檔案大小或數量達到閾值時,才能進行合併,生成一個新的 SSTable 插入到下一層。
3,每一層的 SSTable 都有一個順序,根據生成時間來排序。這個特點用於從所有的 SSTable 中查詢資料。

由於每次持久化記憶體表,都會建立一個 SSTable 檔案,因此 SSTable 檔案數量會越來越多了,檔案多了之後,需要儲存較多的檔案控制程式碼,而且在多個檔案中讀取資料時,速度也會變慢。如果不進行控制,那麼過多的檔案會導致讀效能變差以及佔用空間過於膨脹,這一現象被稱為空間放大和讀放大

由於 SSTable 是不能更改的,那麼如果要刪除一個 Key,或者修改一個 Key 的值,只能在新的 SSTable 中標記,而不能修改,這樣會導致不同的 SSTable 存在相同的 Key,檔案比較臃腫。

因此,還需要對小的 SSTable 檔案進行壓縮,合併成一個大的 SSTable 檔案,放到下一層中,以便提高讀取效能。

當一層的 SSTable 檔案總大小大於閾值時,或者 SSTable 檔案的數量太多時,就需要觸發合併動作,生成新的 SSTable 檔案,放入下一層中,再將原先的 SSTable 檔案刪除,下圖演示了這一過程。

雖然對 SSTable 進行合併壓縮,可以抑制空間放大和讀放大問題,但是對多個 SSTable 合併為一個 SSTable 時,需要載入每個 SSTable 檔案,在記憶體讀取檔案的內容,建立一個新的 SSTable 檔案,並且刪除掉舊的檔案,這樣會消耗大量的 CPU 時間和磁碟 IO。這種現象被稱為寫放大。

下圖演示了合併前後的儲存空間變化。

記憶體中的 SSTable

當程式啟動後,會載入每個 SSTable 的後設資料和稀疏索引區到記憶體中,也就是 SSTable 在記憶體中快取了 Key 列表,需要在 SSTable 中查詢 Key 時,首先在記憶體的稀疏索引區查詢,如果找到 Key,則根據 索引的 Start 和 Length,從磁碟檔案中讀取 Key/Value 的二進位制資料。接著將二進位制資料轉換為 Key/Value 結構。

因此,要確定一個 SSTable 是否存在某個 Key 時,是在記憶體中查詢的,這個過程很快,只有當需要讀取 Key 的值時,才需要從檔案中讀出。

可是,當 Key 數量太多時,全部快取在記憶體中會消耗很多的記憶體,並且逐個查詢也需要耗費一定的時間,還可以通過使用布隆過濾器(BloomFilter)來更快地判斷一個 Key 是否存在。

資料查詢過程

首先根據要查詢的 Key,從 Memory Table 中查詢。

如果 Memory Table 中,找不到對應的 Key,則從 Immutable Memory Table 中查詢。

筆者所寫的 LSM Tree 資料庫中,只有 Memory Table,沒有 Immutable Memory Table。

如果在兩個記憶體表中都查詢不到 Key,那麼就要從 SSTable 列表中查詢。

首先查詢第 0 層的 SSTable 表,從該層最新的 SSTable 表開始查詢,如果沒有找到,便查詢同一層的其他 SSTable,如果還是沒有,則接著查下一層。

當查詢到 Key 時,無論 Key 狀態如何(有效或已被刪除),都會停止查詢,返回此 Key 的值和刪除標誌。

實現過程

在本節中,筆者將會說明自己實現 LSM Tree 大體的實現思路,從中給出一部分程式碼示例,但是完整的程式碼需要在倉庫中檢視,這裡只給出實現相關的程式碼定義,不列出具體的程式碼細節。

下圖是 LSM Tree 主要關注的物件:

對於記憶體表,我們要實現增刪查改、遍歷;
對於 WAL,需要將操作資訊寫到檔案中,並且能夠從 WAL 檔案恢復記憶體表;
對於 SSTable,能夠載入檔案資訊,從中查詢對應的資料;
對應 SSTable Tree,負責管理所有 SSTable,進行檔案合併等。

▌Key/Value 的表示

作為 Key/Value 資料庫,我們需要能夠儲存任何型別的值。雖說 GO 1.18 增加了泛型,但是泛型結構體並不能任意儲存任何值,解決存放各種型別的 Value 的問題,因此筆者不使用泛型結構體。而且,無論儲存的是什麼資料,對資料庫來說是不重要,資料庫也完全不必知道 Value 的含義,這個值的型別和含義,只對使用者有用,因此我們可以直接將值轉為二進位制儲存,在使用者取資料時,再將二進位制轉換為對應型別。

定義一個結構體,用於儲存任何型別的值:

// Value 表示一個 KV
type Value struct {
  Key     string
  Value   []byte
  Deleted bool
}

Value 結構體引用路徑是 kv.Value。

如果有一個這樣的結構體:

type TestValue struct {
  A int64
  B int64
  C int64
  D string
}

那麼可以將結構體序列化後的二進位制資料放到 Value 欄位裡。

data,_ := json.Marshal(value)

v := Value{
    Key: "test",
    Value: data,
    Deleted: false,
}

Key/Value 通過 json 序列化值,轉為二進位制再儲存到記憶體中。

因為在 LSM Tree 中,即使一個 Key 被刪除了,也不會清理掉這個元素,只是將該元素標記為刪除狀態,所以為了確定查詢結果,我們需要定義一個列舉,用於判斷查詢到此 Key 後,此 Key 是否有效。

// SearchResult 查詢結果
type SearchResult int

const (
  // None 沒有查詢到
  None SearchResult = iota
  // Deleted 已經被刪除
  Deleted
  // Success 查詢成功
  Success
)

關於程式碼部分,讀者可以參考:
https://github.com/whuanle/ls...

▌記憶體表的實現

LSM Tree 中的記憶體表是一個二叉排序樹,關於二叉排序樹的操作,主要有設定值、插入、查詢、遍歷,詳細的程式碼讀者可以參考:

下面來簡單說明二叉排序樹的實現。

假設我們要插入的 Key 列表為 [30,45,25,23,17,24,26,28],那麼插入後,記憶體表的結構如下所示:

筆者在寫二叉排序樹時,發現幾個容易出錯的地方,因此這裡列舉一下。

首先,我們要記住:節點插入之後,位置不再變化,不能被移除,也不能被更換位置。
第一點,新插入的節點,只能作為葉子。

下面是一個正確的插入操作:

如圖所示,本身已經存在了 23、17、24,那麼插入 18 時,需要在 17 的右孩插入。
下面是一個錯誤的插入操作:

進行插入操作時,不能移動舊節點的位置,不能改變左孩右孩的關係。

第二點,刪除節點時,只能標記刪除,不能真正刪除節點。

二叉排序樹結構定義

二叉排序樹的結構體和方法定義如下:

// treeNode 有序樹節點
type treeNode struct {
  KV    kv.Value
  Left  *treeNode
  Right *treeNode
}

// Tree 有序樹
type Tree struct {
  root   *treeNode
  count  int
  rWLock *sync.RWMutex
}


// Search 查詢 Key 的值
func (tree *Tree) Search(key string) (kv.Value, kv.SearchResult) {
}

// Set 設定 Key 的值並返回舊值
func (tree *Tree) Set(key string, value []byte) (oldValue kv.Value, hasOld bool) {
}

// Delete 刪除 key 並返回舊值
func (tree *Tree) Delete(key string) (oldValue kv.Value, hasOld bool) {
}

具體的程式碼實現請參考:
https://github.com/whuanle/ls...

因為 Go 語言的 string 型別是值型別,因此能夠直接比較大小的,因此在插入 Key/BValue 時,可以簡化不少程式碼。

插入操作

因為樹是有序的,插入 Key/Value 時,需要在樹的根節點從上到下對比 Key 的大小,然後以葉子節點的形式插入到樹中。

插入過程,可以分為多種情況。

第一種,不存在相關的 Key 時,直接作為葉子節點插入,作為上一層元素的左孩或右孩。

if key < current.KV.Key {
      // 左孩為空,直接插入左邊
      if current.Left == nil {
        current.Left = newNode
                // ... ...
      }
      // 繼續對比下一層
      current = current.Left
    } else {
      // 右孩為空,直接插入右邊
      if current.Right == nil {
        current.Right = newNode
                // ... ...
            }
      current = current.Right
        }

第二種,當 Key 已經存在,該節點可能是有效的,我們需要替換 Value 即可;該節點有可能是被標準刪除了,需要替換 Value ,並且將 Deleted 標記改成 false。

      node.KV.Value = value
      isDeleted := node.KV.Deleted
      node.KV.Deleted = false

那麼,當向二叉排序樹插入一個 Key/Value 時,時間複雜度如何?

如果二叉排序樹是比較平衡的,即左右比較對稱,那麼進行插入操作時,其時間複雜度為 O(logn)。

如下圖所示,樹中有 7 個節點,只有三層,那麼插入操作時,最多需要對比三次。

如果二叉排序樹不平衡,最壞的情況是所有節點都在左邊或右邊,此時插入的時間複雜度為 O(n)。
如下圖所示,樹中有四個節點,也有四層,那麼進行插入操作時,最多需要對比四次。

插入節點的程式碼請參考:
https://github.com/whuanle/ls...

查詢

在二叉排序樹中查詢 Key 時,根據 Key 的大小來選擇左孩或右孩進行下一層查詢,查詢程式碼示例如下:

  currentNode := tree.root
  // 有序查詢
  for currentNode != nil {
    if key == currentNode.KV.Key {
      if currentNode.KV.Deleted == false {
        return currentNode.KV, kv.Success
      } else {
        return kv.Value{}, kv.Deleted
      }
    }
    if key < currentNode.KV.Key {
      // 繼續對比下一層
      currentNode = currentNode.Left
    } else {
      // 繼續對比下一層
      currentNode = currentNode.Right
    }
  }

其時間複雜度與插入一致。
查詢程式碼請參考:https://github.com/whuanle/ls...

刪除

刪除操作時,只需要查詢到對應的節點,將 Value 清空,然後設定刪除標記即可,該節點是不能被刪除的。

        currentNode.KV.Value = nil
        currentNode.KV.Deleted = true

其時間複雜度與插入一致。

刪除程式碼請參考:https://github.com/whuanle/ls...

遍歷演算法

參考程式碼:https://github.com/whuanle/ls...
為了將二叉排序樹的節點順序遍歷出來,遞迴演算法是最簡單的,但是當樹的層次很高時,遞迴會導致消耗很多記憶體空間,因此我們需要使用棧演算法,來對樹進行遍歷,順序拿到所有節點。

Go 語言中,利用切片實現棧:
https://github.com/whuanle/ls...

二叉排序樹的順序遍歷,實際上就是前序遍歷,按照前序遍歷,遍歷完成後,獲得的節點集合,其 Key 一定是順序的。
參考程式碼如下:

// 使用棧,而非遞迴,棧使用了切片,可以自動擴充套件大小,不必擔心棧滿
  stack := InitStack(tree.count / 2)
  values := make([]kv.Value, 0)

  tree.rWLock.RLock()
  defer tree.rWLock.RUnlock()

  // 從小到大獲取樹的元素
  currentNode := tree.root
  for {
    if currentNode != nil {
      stack.Push(currentNode)
      currentNode = currentNode.Left
    } else {
      popNode, success := stack.Pop()
      if success == false {
        break
      }
      values = append(values, popNode.KV)
      currentNode = popNode.Right
    }
  }

遍歷程式碼:
https://github.com/whuanle/ls...

棧大小預設分配為樹節點數量的一半,如果此樹是平衡的,則數量大小比較合適。並且也不是將所有節點都推送到棧之後才能進行讀取,只要沒有左孩,即可從棧中取出元素讀取。

如果樹不是平衡的,那麼實際需要的棧空間可能更大,但是這個棧使用了切片,如果棧空間不足,會自動擴充套件的。

遍歷過程如下動圖所示:

動圖製作不易~
可以看到,需要多少棧空間,與二叉樹的高度有關。

▌WAL

WAL 的結構體定義如下:

type Wal struct {
  f    *os.File
  path string
  lock sync.Locker
}

WAL 需要具備兩種能力:
1,程式啟動時,能夠讀取 WAL 檔案的內容,恢復為記憶體表(二叉排序樹)。
2,程式啟動後,寫入、刪除操作記憶體表時,操作要寫入到 WAL 檔案中。
參考程式碼:
https://github.com/whuanle/ls...

下面來講解筆者的 WAL 實現過程。

下面是寫入 WAL 檔案的簡化程式碼:

// 記錄日誌
func (w *Wal) Write(value kv.Value) {
  data, _ := json.Marshal(value)
  err := binary.Write(w.f, binary.LittleEndian, int64(len(data)))
  err = binary.Write(w.f, binary.LittleEndian, data)
}

可以看到,先寫入一個 8 位元組,再將 Key/Value 序列化寫入。

為了能夠在程式啟動時,正確從 WAL 檔案恢復資料,那麼必然需要對 WAL 檔案做好正確的分隔,以便能夠正確讀取每一個元素操作。
因此,每一個被寫入 WAL 的元素,都需要記錄其長度,其長度使用 int64 型別表示,int64 佔 8 個位元組。

WAL 檔案恢復過程

在上一小節中,寫入 WAL 檔案的一個元素,由元素資料及其長度組成。那麼 WAL 的檔案結構可以這樣看待:

因此,在使用 WAL 檔案恢復資料時,首先讀取檔案開頭的 8 個位元組,確定第一個元素的位元組數量 n,然後將 8 ~ (8+n) 範圍中的二進位制資料載入到記憶體中,然後通過 json.Unmarshal() 將二進位制資料反序列化為 kv.Value 型別。

接著,讀取 (8+n) ~ (8+n)+8 位置的 8 個位元組,以便確定下一個元素的資料長度,這樣一點點把整個 WAL 檔案讀取完畢。

一般 WAL 檔案不會很大,因此在程式啟動時,資料恢復過程,可以將 WAL 檔案全部載入到記憶體中,然後逐個讀取和反序列化,識別操作是 Set 還是 Delete,然後呼叫二叉排序樹的 Set 或 Deleted 方法,將元素都新增到節點中。
參考程式碼如下:

程式碼位置:
https://github.com/whuanle/ls...

▌SSTable 與 SSTable Tree

SSTable 涉及的程式碼比較多,可以根據儲存 SSTable 檔案 、 從檔案解析 SSTable 和搜尋 Key 三部分進行劃分。

筆者所寫的所有 SSTable 程式碼檔案列表如下:

SSTable 結構
SSTable 的結構體定義如下:

// SSTable 表,儲存在磁碟檔案中
type SSTable struct {
  // 檔案控制程式碼
  f        *os.File
  filePath string
  // 後設資料
  tableMetaInfo MetaInfo
  // 檔案的稀疏索引列表
  sparseIndex map[string]Position
  // 排序後的 key 列表
  sortIndex []string
  lock sync.Locker
}

sortIndex 中的元素是有序的,並且元素記憶體位置相連,便於 CPU 快取,提高查詢效能,還可以使用布隆過濾器,快速確定該 SSTable 中是否存在此 Key。

當確定該 SSTable 之後,便從 sparseIndex 中查詢此元素的索引,從而可以在檔案中定位。

其中後設資料和稀疏索引的結構體定義如下:

type MetaInfo struct {
  // 版本號
  version int64
  // 資料區起始索引
  dataStart int64
  // 資料區長度
  dataLen int64
  // 稀疏索引區起始索引
  indexStart int64
  // 稀疏索引區長度
  indexLen int64
}
// Position 元素定位,儲存在稀疏索引區中,表示一個元素的起始位置和長度
type Position struct {
  // 起始索引
  Start int64
  // 長度
  Len int64
  // Key 已經被刪除
  Deleted bool
}

可以看到,一個 SSTable 結構體除了需要指向磁碟檔案外,還需要在記憶體中快取一些東西,不過不同開發者的做法不一樣。就比如說筆者的做法,在一開始時,便固定了這種模式,需要在記憶體中快取 Keys 列表,然後使用字典快取元素定位。

// 檔案的稀疏索引列表
  sparseIndex map[string]Position
  // 排序後的 key 列表
  sortIndex []string

但實際上,只保留 sparseIndex map[string]Position也可以完成所有查詢操作,sortIndex []string 不是必須的。

SSTable 檔案結構

SSTable 的檔案,分為資料區,稀疏索引區,後設資料/檔案索引,三個部分。儲存的內容與開發者定義的資料結構有關。如下圖所示:

資料區是 序列化後的 Value 結構體列表,而稀疏索引區是序列化後的 Position 列表。不過兩個區域的序列化處理方式不一樣。

稀疏索引區,是 map[string]Position 型別序列化為二進位制儲存的,那麼我們可以讀取檔案時,可以直接將稀疏索引區整個反序列化為 map[string]Position。

資料區,是一個個 kv.Value 序列化後追加的,因此是不能將整個資料區反序列化為 []kv.Value ,只能通過 Position 將資料區的每一個 block 逐步讀取,然後反序列化為 kv.Value。

SSTable Tree 結構和管理 SSTable 檔案

為了組織大量的 SSTable 檔案,我們還需要一個結構體,以層次結構,去管理所有的磁碟檔案。
我們需要定義一個 TableTree 結構體,其定義如下:

// TableTree 樹
type TableTree struct {
  levels []*tableNode  // 這部分是一個連結串列陣列
  // 用於避免進行插入或壓縮、刪除 SSTable 時發生衝突
  lock *sync.RWMutex
}

// 連結串列,表示每一層的 SSTable
type tableNode struct {
  index int
  table *SSTable
  next  *tableNode
}

為了方便對 SSTable 進行分層和標記插入順序,需要制定 SSTable 檔案的命名規定。
如下檔案所示:

├── 0.0.db
├── 1.0.db
├── 2.0.db
├── 3.0.db
├── 3.1.db
├── 3.2.db

SSTable 檔案由 {level}.{index}.db 組成,第一個數字代表檔案所在的 SSTable 層,第二個數字,表示在該層中的索引。

其中,索引越大,表示其檔案越新。

插入 SSTable 檔案過程

當從記憶體錶轉換為 SSTable 時,每個被轉換的 SSTable ,都是插入到 Level 0 的最後面。

每一層的 SSTable 使用一個連結串列進行管理:

type tableNode struct {
  index int
  table *SSTable
  next  *tableNode
}

因此,在插入 SSTable 時,沿著往下查詢,放到連結串列的最後面。
連結串列插入節點的程式碼部分示例如下:

for node != nil {
      if node.next == nil {
        newNode.index = node.index + 1
        node.next = newNode
        break
      } else {
        node = node.next
      }
    }

從記憶體錶轉換為 SSTable 時,會涉及比較多的操作,讀者請參考程式碼:https://github.com/whuanle/ls...

讀取 SSTable 檔案

當程式啟動時,需要讀取目錄中所有的 SSTable 檔案到 TableTree 中,接著載入每一個 SSTable 的稀疏索引區和後設資料。
筆者的 LSM Tree 處理過程如圖所示:

筆者的 LSM Tree 載入這些檔案,一共耗時 19.4259983s 。

載入過程的程式碼在:
https://github.com/whuanle/ls...

下面筆者說一下大概的載入過程。

首先讀取目錄中的所有 .db 檔案:

  infos, err := ioutil.ReadDir(dir)
  if err != nil {
    log.Println("Failed to read the database file")
    panic(err)
  }
  for _, info := range infos {
    // 如果是 SSTable 檔案
    if path.Ext(info.Name()) == ".db" {
      tree.loadDbFile(path.Join(dir, info.Name()))
    }
  }

然後建立一個 SSTable 物件,載入檔案的後設資料和稀疏索引區:

// 載入檔案控制程式碼的同時,載入表的後設資料
  table.loadMetaInfo()
    // 載入稀疏索引區
  table.loadSparseIndex()

最後根據 .db 的檔名稱,插入到 TableTree 中指定的位置:

SSTable 檔案合併

當一層的 SSTable 檔案太多時,或者檔案太大時,需要將該層的 SSTable 檔案,合併起來,生成一個新的、沒有重複元素的 SSTable,放到新的一層中。

因此,筆者的做法是在程式啟動後,使用一個新的執行緒,檢查記憶體表是否需要被轉換為 SSTable、是否需要壓縮 SSTable 層。檢查時, 從 Level 0 開始,檢查兩個條件閾值,第一個是 SSTable 數量,另一個是該層 SSTable 的檔案總大小。

SSTable 檔案合併閾值,在程式啟動的時候,需要設定。

  lsm.Start(config.Config{
    DataDir:    `E:\專案\lsm資料測試目錄`,
    Level0Size: 1,    // 第0層所有 SSTable 檔案大小之和的閾值
    PartSize:   4,    // 每一層 SSTable 數量閾值
    Threshold:  500,    // 記憶體表元素閾值
        CheckInterval: 3, // 壓縮時間間隔
  })

每一層的 SSTable 檔案大小之和,是根據第 0 層生成的,例如,當你設定第 0 層為 1MB 時,第 1 層則為 10MB,第 2 層則為 100 MB,使用者只需要設定第 0 層的檔案總大小閾值即可。

下面來說明 SSTable 檔案合併過程。
壓縮合並的完整程式碼請參考:https://github.com/whuanle/ls...
下面是初始的檔案樹:

首先建立一個二叉排序樹物件:
memoryTree := &sortTree.Tree{}
然後在 Level 0 中,從索引最小的 SSTable 開始,讀取檔案資料區中的每一個 block,反序列化後,進行插入操作或刪除操作。

for k, position := range table.sparseIndex {
      if position.Deleted == false {
        value, err := kv.Decode(newSlice[position.Start:(position.Start + position.Len)])
        if err != nil {
          log.Fatal(err)
        }
        memoryTree.Set(k, value.Value)
      } else {
        memoryTree.Delete(k)
      }
    }

將 Level 0 的所有 SSTable 載入到二叉排序樹中,即合併所有元素。

然後將二叉排序樹轉換為 SSTable,插入到 Level 1 中。

接著,刪除 Level 0 的所有 SSTable 檔案。

注,由於筆者的壓縮方式會將檔案載入到記憶體中,使用切片儲存檔案資料,因此可能會出現容量過大的錯誤。

這是一個值得關注的地方。

SSTable 查詢過程

完整的程式碼請參考:
https://github.com/whuanle/ls...

當需要查詢一個元素時,首先在記憶體表中查詢,查詢不到時,需要在 TableTree 中,逐個查詢 SSTable。

// 遍歷每一層的 SSTable
  for _, node := range tree.levels {
    // 整理 SSTable 列表
    tables := make([]*SSTable, 0)
    for node != nil {
      tables = append(tables, node.table)
      node = node.next
    }
    // 查詢的時候要從最後一個 SSTable 開始查詢
    for i := len(tables) - 1; i >= 0; i-- {
      value, searchResult := tables[i].Search(key)
      // 未找到,則查詢下一個 SSTable 表
      if searchResult == kv.None {
        continue
      } else { // 如果找到或已被刪除,則返回結果
        return value, searchResult
      }
    }
  }

在 SSTable 內部查詢時,使用了二分查詢法:

// 元素定位
  var position Position = Position{
    Start: -1,
  }
  l := 0
  r := len(table.sortIndex) - 1

  // 二分查詢法,查詢 key 是否存在
  for l <= r {
    mid := int((l + r) / 2)
    if table.sortIndex[mid] == key {
      // 獲取元素定位
      position = table.sparseIndex[key]
      // 如果元素已被刪除,則返回
      if position.Deleted {
        return kv.Value{}, kv.Deleted
      }
      break
    } else if table.sortIndex[mid] < key {
      l = mid + 1
    } else if table.sortIndex[mid] > key {
      r = mid - 1
    }
  }

  if position.Start == -1 {
    return kv.Value{}, kv.None
  }

關於 LSM Tree 資料庫的編寫,就到這裡完畢了,下面瞭解筆者的資料庫效能和使用方法。

▌簡單的使用測試

示例程式碼位置:
https://gist.github.com/whuan...
首先下載依賴包:

go get -u github.com/whuanle/lsm@v1.0.0

然後使用 lsm.Start() 初始化資料庫,再增刪查改 Key,示例程式碼如下:

package main

import (
  "fmt"
  "github.com/whuanle/lsm"
  "github.com/whuanle/lsm/config"
)

type TestValue struct {
  A int64
  B int64
  C int64
  D string
}

func main() {
  lsm.Start(config.Config{
    DataDir:    `E:\專案\lsm資料測試目錄`,
    Level0Size: 1,
    PartSize:   4,
    Threshold:  500,
        CheckInterval: 3, // 壓縮時間間隔
  })
  // 64 個位元組
  testV := TestValue{
    A: 1,
    B: 1,
    C: 3,
    D: "00000000000000000000000000000000000000",
  }

  lsm.Set("aaa", testV)

  value, success := lsm.Get[TestValue]("aaa")
  if success {
    fmt.Println(value)
  }

  lsm.Delete("aaa")
}

testV 是 64 位元組,而 kv.Value 儲存了 testV 的值,kv.Value 位元組大小為 131。

檔案壓縮測試

我們可以寫一個從 26 個字母中取任意 6 字母組成 Key,插入到資料庫中,從中觀察檔案壓縮合並,和插入速度等。

不同迴圈層次插入的元素數量:
image.png

生成的測試檔案列表:


檔案壓縮合並動圖過程的如下(約20秒):

插入測試

下面是一些不嚴謹的測試結果。

設定啟動資料庫時的配置:

  lsm.Start(config.Config{
    DataDir:    `E:\專案\lsm資料測試目錄`,
    Level0Size: 10,  // 0 層 SSTable 檔案大小
    PartSize:   4,   // 每層檔案數量
    Threshold:  3000, // 記憶體表閾值
        CheckInterval: 3, // 壓縮時間間隔
  })

  lsm.Start(config.Config{
    DataDir:    `E:\專案\lsm資料測試目錄`,
    Level0Size: 100,
    PartSize:   4,
    Threshold:  20000,
        CheckInterval: 3,
  })

插入資料:

func insert() {

  // 64 個位元組
  testV := TestValue{
    A: 1,
    B: 1,
    C: 3,
    D: "00000000000000000000000000000000000000",
  }

  count := 0
  start := time.Now()
  key := []byte{'a', 'a', 'a', 'a', 'a', 'a'}
  lsm.Set(string(key), testV)
  for a := 0; a < 1; a++ {
    for b := 0; b < 1; b++ {
      for c := 0; c < 26; c++ {
        for d := 0; d < 26; d++ {
          for e := 0; e < 26; e++ {
            for f := 0; f < 26; f++ {
              key[0] = 'a' + byte(a)
              key[1] = 'a' + byte(b)
              key[2] = 'a' + byte(c)
              key[3] = 'a' + byte(d)
              key[4] = 'a' + byte(e)
              key[5] = 'a' + byte(f)
              lsm.Set(string(key), testV)
              count++
            }
          }
        }
      }
    }
  }

  elapse := time.Since(start)
  fmt.Println("插入完成,資料量:", count, ",消耗時間:", elapse)
}

兩次測試,生成的 SSTable 總檔案大小都是約 82MB。
兩次測試消耗的時間:

插入完成,資料量:456976 ,消耗時間:1m43.4541747s

插入完成,資料量:456976 ,消耗時間:1m42.7098146s

因此,每個元素 131 個位元組,這個資料庫 100s 可以插入 約 45w 條資料,即每秒插入 4500 條資料。

如果將 kv.Value 的值比較大,測試在 3231 位元組時,插入 456976 條資料,檔案約 1.5GB,消耗時間 2m10.8385817s,即每秒插入 3500條。

插入較大值的 kv.Value,程式碼示例:https://gist.github.com/whuan...

載入測試

下面是每個元素 3231 位元組時,插入 45 萬條資料後的 SSTable 檔案列表,程式啟動時,我們需要載入這些檔案。

2022/05/21 21:59:30 Loading wal.log...
2022/05/21 21:59:32 Loaded wal.log,Consumption of time :  1.8237905s
2022/05/21 21:59:32 Loading database...
2022/05/21 21:59:32 The SSTable list are being loaded
2022/05/21 21:59:32 Loading the  E:\專案\lsm資料測試目錄/1.0.db
2022/05/21 21:59:32 Loading the  E:\專案\lsm資料測試目錄/1.0.db ,Consumption of time :  92.9994ms
2022/05/21 21:59:32 Loading the  E:\專案\lsm資料測試目錄/1.1.db
2022/05/21 21:59:32 Loading the  E:\專案\lsm資料測試目錄/1.1.db ,Consumption of time :  65.9812ms
2022/05/21 21:59:32 Loading the  E:\專案\lsm資料測試目錄/2.0.db
2022/05/21 21:59:32 Loading the  E:\專案\lsm資料測試目錄/2.0.db ,Consumption of time :  331.6327ms
2022/05/21 21:59:32 The SSTable list are being loaded,consumption of time :  490.6133ms

可以看到,除 WAL 載入比較耗時(因為要逐個插入記憶體中),SSTable 檔案的載入還是比較快的。

查詢測試

如果元素都在記憶體中時,即使有 45 萬條資料,查詢速度也是非常快的,例如查詢 aaaaaa(Key最小)和 aazzzz(Key最大)的資料,耗時都很低。

下面使用每條元素 3kb 的資料庫檔案進行測試。

查詢程式碼:

  start := time.Now()
  elapse := time.Since(start)
  v, _ := lsm.Get[TestValue]("aaaaaa") // 或者 aazzzz
  fmt.Println("查詢完成,消耗時間:", elapse)
  fmt.Println(v)


如果在 SSTable 中查詢,因為 aaaaaa 是首先被寫入的,因此必定會在最底層的 SSTable 檔案的末尾,需要消耗的時間比較多。
SSTable 檔案列表:

├── 1.0.db      116MB
├── 2.0.db    643MB
├── 2.1.db    707MB

約 1.5GB

aaaaaa 在 2.0db 中,查詢時會以 1.0.db、2.1.db、2.0.db 的順序載入。
查詢速度測試:

2022/05/22 08:25:43 Get aaaaaa
查詢 aaaaaa 完成,消耗時間:19.4338ms

2022/05/22 08:25:43 Get aazzzz
查詢 aazzzz 完成,消耗時間:0s


關於筆者的 LSM Tree 資料庫,就介紹到這裡,詳細的實現程式碼,請參考 Github 倉庫。


微軟最有價值專家(MVP

微軟最有價值專家是微軟公司授予第三方技術專業人士的一個全球獎項。29年來,世界各地的技術社群領導者,因其線上上和線下的技術社群中分享專業知識和經驗而獲得此獎項。
MVP是經過嚴格挑選的專家團隊,他們代表著技術最精湛且最具智慧的人,是對社群投入極大的熱情並樂於助人的專家。MVP致力於通過演講、論壇問答、建立網站、撰寫部落格、分享視訊、開源專案、組織會議等方式來幫助他人,並最大程度地幫助微軟技術社群使用者使用 Microsoft 技術。
更多詳情請登入官方網站:
https://mvp.microsoft.com/zh-cn


長按識別二維碼
關注微軟中國MSDN

點選申請加入微軟最有價值專家專案

相關文章