基於 go 實現 lsm tree 之主幹框架

0 導讀

去年 4 月的時候,我和大家分享了一篇文章——初探 rocksDB 之 lsm tree,和大家一起探討了 lsm tree 結構的底層實現原理. 秉着 【基於源碼支撐原理】 的一貫原則,從本期開始,咱們把源碼篇的坑填上.

接下來我將開啓一個新的專題——基於 go 語言從零到一實現 lsm tree,本專題共分爲下述四篇內容:

關於以上內容的源碼均已上傳到我的個人開源項目中,大家根據需要自取,如果代碼寫得覺得還湊合,可以留個 star,不勝感激.

開源項目 golsm 傳送門 >> https://github.com/xiaoxuxiansheng/golsm

1 lsm tree 概念

1.1 應用場景

存儲組件根據讀寫頻次的不同,可以被分爲適用於讀多寫少場景的讀密集型寫多讀少場景的寫密集型兩大類.

lsm tree 屬於後者,在是實現上通過磁盤順序寫取代隨機寫的方式,保證了寫操作的性能和穩定性;同時其又通過文件分層以及各層次間的排序歸併操作,保證了內容的有序性,進一步能夠兼顧保證不俗的讀性能.

在實際的工程應用上,比較知名的 TiDB、RocksDB、LevelDB 均是由 lsm tree 完成存儲結構的組織,因此都能很好地勝任寫密集型的應用場景.

1.2 架構概覽

下面我們簡單串一下 lsm tree 的整體架構,有關這部分內容更詳盡的細節,大家可以參見我之前發表的原理篇內容——初探 rocksDB 之 lsm tree.

lsm tree 全稱 Log Structure Merge Tree,核心知識點包括:

下面展示一下 lsm tree 整體架構示意圖.

至此,我們簡單總結了有關 lsm tree 的原理內容. 從下章開始將展開介紹我的個人項目 golsm 中,有關如何基於 go 語言從零到一實現 lsm tree 的技術細節.

2 核心數據結構

下面正式進入到喜聞樂見的手撕源碼環節,首先是關於 golsm 中幾類核心數據結構的介紹.

2.1 Conf

Conf 類聚合了有關於 lsm tree 運行所需的所有配置項,各項配置參數可以通過 option 模式進行靈活注入,對應的參數含義展示如下:

type Config struct {
    Dir      string // sst 文件存放的目錄
    MaxLevel int    // lsm tree 總共多少層


    // sst 相關
    SSTSize          uint64 // 每個 sst table 大小,默認 4M
    SSTNumPerLevel   int    // 每層多少個 sstable,默認 10 個
    SSTDataBlockSize int    // sst table 中 block 大小 默認 16KB
    SSTFooterSize    int    // sst table 中 footer 部分大小. 固定爲 32B


    Filter              filter.Filter                // 過濾器. 默認使用布隆過濾器
    MemTableConstructor memtable.MemTableConstructor // memtable 構造器,默認爲跳錶
}

2.2 Tree

Tree 類對應的就是 lsm tree 的宏觀結構:

其中完整各項成員屬性含義如下:

type Tree struct {
    conf *Config


    // 讀寫數據時使用的鎖
    dataLock sync.RWMutex


    // 每層 node 節點使用的讀寫鎖
    levelLocks []sync.RWMutex


    // 讀寫 memtable
    memTable memtable.MemTable


    // 只讀 memtable
    rOnlyMemTable []*memTableCompactItem


    // 預寫日誌寫入口
    walWriter *wal.WALWriter


    // lsm樹狀數據結構
    nodes [][]*Node


    // memtable 達到閾值時,通過該 chan 傳遞信號,進行溢寫工作
    memCompactC chan *memTableCompactItem


    // 某層 sst 文件大小達到閾值時,通過該 chan 傳遞信號,進行溢寫工作
    levelCompactC chan int


    // lsm tree 停止時通過該 chan 傳遞信號
    stopc chan struct{}


    // memtable index,需要與 wal 文件一一對應
    memTableIndex int


    // 各層 sstable 文件 seq. sstable 文件命名爲 level_seq.sst
    levelToSeq []atomic.Int32
}

2.3 Node

Node 類對應爲 lsm tree 中的一個 sstable 文件,核心字段包括:

type Node struct {
    conf          *Config           // 配置文件
    file          string            // sstable 對應的文件名,不含目錄路徑
    level         int               // sstable 所在 level 層級
    seq           int32             // sstable 的 seq 序列號. 對應爲文件名中的 level_seq.sst 中的 seq
    size          uint64            // sstable 的大小,單位 byte
    blockToFilter map[uint64][]byte // 各 block 對應的 filter bitmap
    index         []*Index          // 各 block 對應的索引
    startKey      []byte            // sstable 中最小的 key
    endKey        []byte            // sstable 中最大的 key
    sstReader     *SSTReader        // 讀取 sst 文件的 reader 入口
}

2.4 Block

有關 block 的內容我們在此僅作簡要概括. 本系列第三篇【基於 go 實現 lsm tree 之 sstable 結構】中,會再作詳細展開.

每個 sstable 文件均以數據塊 block 的形式進行數據分組. 後續無論是索引 index 還是過濾器 filter,都是與 block 對應存在的.

type Block struct {
    conf       *Config 
    buffer     [30]byte      // 進行數據轉移時使用的臨時緩衝區
    record     *bytes.Buffer // 記錄全量數據的緩衝區
    entriesCnt int           // kv 對數量
    prevKey    []byte        // 最晚一筆寫入的數據的 key
}

2.5 SSTable

有關 sstable 的內容僅作簡要概括. 本系列第三篇 【基於 go 實現 lsm tree 之 sstable 結構】中,再作詳細展開.

sstWriter 對應爲一個 sstable 文件的寫入口,核心屬性包括:

// 對應於 lsm tree 中的一個 sstable. 這是寫入流程的視角
type SSTWriter struct {
    conf          *Config           // 配置文件
    dest          *os.File          // sstable 對應的磁盤文件
    dataBuf       *bytes.Buffer     // 數據塊緩衝區 key -> val
    filterBuf     *bytes.Buffer     // 過濾器塊緩衝區 prev block offset -> filter bit map
    indexBuf      *bytes.Buffer     // 索引塊緩衝區 index key -> prev block offset, prev block size
    blockToFilter map[uint64][]byte // prev block offset -> filter bit map
    index         []*Index          // index key -> prev block offset, prev block size


    dataBlock     *Block   // 數據塊
    filterBlock   *Block   // 過濾器塊
    indexBlock    *Block   // 索引塊
    assistScratch [20]byte // 用於在寫索引塊時臨時使用的輔助緩衝區


    prevKey         []byte // 前一筆數據的 key
    prevBlockOffset uint64 // 前一個數據塊的起始偏移位置
    prevBlockSize   uint64 // 前一個數據塊的大小
}

sstReader 對應爲一個 sstable 文件的讀取器:

// 對應於 lsm tree 中的一個 sstable. 這是讀取流程的視角
type SSTReader struct {
    conf         *Config       // 配置文件
    src          *os.File      // 對應的文件
    reader       *bufio.Reader // 讀取文件的 reader
    filterOffset uint64        // 過濾器塊起始位置在 sstable 的 offset
    filterSize   uint64        // 過濾器塊的大小,單位 byte
    indexOffset  uint64        // 索引塊起始位置在 sstable 的 offset
    indexSize    uint64        // 索引塊的大小,單位 byte
}

2.6 Index

有關 index 的內容僅作簡要概括. 本系列第三篇 【基於 go 實現 lsm tree 之 sstable 結構】中,再作詳細展開.

索引 index 是在邏輯意義上是插入在 sst 文件各個 dataBlock 之間的記錄樁點:

// sstable 中用於快速檢索 block 的索引
type Index struct {
    Key             []byte // 索引的 key. 保證其 >= 前一個 block 最大 key; < 後一個 block 的最小 key
    PrevBlockOffset uint64 // 索引前一個 block 起始位置在 sstable 中對應的 offset
    PrevBlockSize   uint64 // 索引前一個 block 的大小,單位 byte
}

2.7 MemTable

有關 MemTable 的內容我們在此簡要概括. 本系列第二篇 【基於 go 實現 lsm tree 之 memtable 結構】中,再作詳細展開.

MemTable 是 lsm tree 中使用的內存有序表,此處聲明爲一個 interface,使用方可以根據需要實現具體的版本進行注入,默認會使用 golsm 項目下實現的跳錶結構.

// memtable 構造器
type MemTableConstructor func() MemTable


// 有序表 interface
type MemTable interface {
    Put(key, value []byte)         // 寫入數據
    Get(key []byte) ([]byte, bool) // 讀取數據,第二個 bool flag 標識數據是否存在
    All() []*KV                    // 返回所有的 kv 對數據
    Size() int                     // 有序表內數據大小,單位 byte
    EntriesCnt() int               // kv 對數量
}

2.8 Filter

有關 filter 的內容僅作簡要概括. 本系列第三篇 【基於 go 實現 lsm tree 之 sstable 結構】中再作詳細展開.

Filter 爲 sstable 文件中針對每個 block 使用的過濾器,用於輔助快速判斷一個 key 是否可能存在於某個 block 塊中.

此處將 filter 聲明爲一個 interface,使用方可以根據需要實現具體的版本進行注入,默認使用 golsm 項目下實現的布隆過濾器.

// 過濾器. 用於輔助 sstable 快速判定一個 key 是否存在於某個 block 中
type Filter interface {
    Add(key []byte)                // 添加 key 到過濾器
    Exist(bitmap, key []byte) bool // 是否存在 key
    Hash() []byte                  // 生成過濾器對應的 bitmap
    Reset()                        // 重置過濾器
    KeyLen() int                   // 存在多少個 key
}

3 啓動流程

接下來我們啓動一棵 lsm tree 的主流程.

3.1 構造配置

在啓動 lsm tree 之前,需要完成各個配置項的設置,此處採用的是 option 模式,實現配置項的靈活注入,同時通過 repair 方法保證一些核心參數在缺失的情況下,能有一個合適的屬性值進行兜底.

// 配置文件構造器.
func NewConfig(dir string, opts ...ConfigOption) (*Config, error) {
    c := Config{
        Dir:           dir, // sstable 文件所在的目錄路徑
        SSTFooterSize: 32,  // 對應 4 個 uint64,共 32 byte
    }


    // 加載配置項
    for _, opt := range opts {
        opt(&c)
    }


    // 兜底修復
    repaire(&c)


    return &c, c.check() // 校驗一下配置是否合法,主要是 check 存放 sst 文件和 wal 文件的目錄,如果有缺失則進行目錄創建
}

構造配置文件時,還需要對使用到的目錄進行校驗,包括用於存放一系列 sst 文件的 conf.Dir 以及存放 wal 文件的 {conf.Dir}/walfile,如果對應目錄不存在,則需要提前完成目錄的創建.

// 校驗一下配置是否合法,主要是 check 存放 sst 文件和 wal 文件的目錄,如果有缺失則進行目錄創建
func (c *Config) check() error {
    // sstable 文件目錄確保存在
    if _, err := os.ReadDir(c.Dir); err != nil {
        _, ok := err.(*fs.PathError)
        if !ok || !strings.HasSuffix(err.Error()"no such file or directory") {
            return err
        }
        if err = os.Mkdir(c.Dir, os.ModePerm); err != nil {
            return err
        }
    }


    // wal 文件目錄確保存在
    walDir := path.Join(c.Dir, "walfile")
    if _, err := os.ReadDir(walDir); err != nil {
        _, ok := err.(*fs.PathError)
        if !ok || !strings.HasSuffix(err.Error()"no such file or directory") {
            return err
        }
        if err = os.Mkdir(walDir, os.ModePerm); err != nil {
            return err
        }
    }


    return nil
}

此處涉及到的完整配置參數展示如下:

// 配置項
type ConfigOption func(*Config)


// lsm tree 最大層數. 默認爲 7 層.
func WithMaxLevel(maxLevel int) ConfigOption {
    return func(c *Config) {
        c.MaxLevel = maxLevel
    }
}


// level0層每個 sstable 文件的大小,單位 byte. 默認爲 1 MB.
// 且每加深一層,sstable 文件大小限制閾值放大 10 倍.
func WithSSTSize(sstSize uint64) ConfigOption {
    return func(c *Config) {
        c.SSTSize = sstSize
    }
}


// sstable 中每個 block 塊的大小限制. 默認爲 16KB.
func WithSSTDataBlockSize(sstDataBlockSize int) ConfigOption {
    return func(c *Config) {
        c.SSTDataBlockSize = sstDataBlockSize
    }
}


// 每個 level 層預期最多存放的 sstable 文件個數. 默認爲 10 個.
func WithSSTNumPerLevel(sstNumPerLevel int) ConfigOption {
    return func(c *Config) {
        c.SSTNumPerLevel = sstNumPerLevel
    }
}


// 注入過濾器的具體實現. 默認使用本項目下實現的布隆過濾器 bloom filter.
func WithFilter(filter filter.Filter) ConfigOption {
    return func(c *Config) {
        c.Filter = filter
    }
}


// 注入有序表構造器. 默認使用本項目下實現的跳錶 skiplist.
func WithMemtableConstructor(memtableConstructor memtable.MemTableConstructor) ConfigOption {
    return func(c *Config) {
        c.MemTableConstructor = memtableConstructor
    }
}

各項配置參數如果出現缺省,則會使用默認值進行兜底:

func repaire(c *Config) {
    // lsm tree 默認爲 7 層.
    if c.MaxLevel <= 1 {
        c.MaxLevel = 7
    }


    // level0 層每個 sstable 文件默認大小限制爲 1MB.
    // 且每加深一層,sstable 文件大小限制閾值放大 10 倍.
    if c.SSTSize <= 0 {
        c.SSTSize = 1024 * 1024
    }


    // sstable 中每個 block 塊的大小限制. 默認爲 16KB.
    if c.SSTDataBlockSize <= 0 {
        c.SSTDataBlockSize = 16 * 1024 // 16KB
    }


    // 每個 level 層預期最多存放的 sstable 文件個數. 默認爲 10 個.
    if c.SSTNumPerLevel <= 0 {
        c.SSTNumPerLevel = 10
    }


    // 注入過濾器的具體實現. 默認使用本項目下實現的布隆過濾器 bloom filter.
    if c.Filter == nil {
        c.Filter, _ = filter.NewBloomFilter(1024)
    }


    // 注入有序表構造器. 默認使用本項目下實現的跳錶 skiplist.
    if c.MemTableConstructor == nil {
        c.MemTableConstructor = memtable.NewSkiplist
    }
}

3.2 啓動 lsm tree

下面梳理啓動一棵 lsm tree 實例的流程,以構造器方法 NewTree 作爲入口,核心步驟包括:

// 構建出一棵 lsm tree
func NewTree(conf *Config) (*Tree, error) {
    // 1 構造 lsm tree 實例
    t := Tree{
        conf:          conf,
        memCompactC:   make(chan *memTableCompactItem),
        levelCompactC: make(chan int),
        stopc:         make(chan struct{}),
        levelToSeq:    make([]atomic.Int32, conf.MaxLevel),
        nodes:         make([][]*Node, conf.MaxLevel),
        levelLocks:    make([]sync.RWMutex, conf.MaxLevel),
    }


    // 2 讀取 sst 文件,還原出整棵樹
    if err := t.constructTree(); err != nil {
        return nil, err
    }


    // 3 運行 lsm tree 壓縮調整協程
    go t.compact()


    // 4 讀取 wal 還原出 memtable
    if err := t.constructMemtable(); err != nil {
        return nil, err
    }


    // 5 返回 lsm tree 實例
    return &t, nil
}

constructTree 方法用於讀取已有的 sst 文件,在內存中還原出一系列 lsm node 節點

// 讀取 sst 文件,還原出整棵樹
func (t *Tree) constructTree() error {
    // 讀取 sst 文件目錄下的 sst 文件列表
    sstEntries, err := t.getSortedSSTEntries()
    if err != nil {
        return err
    }


    // 遍歷每個 sst 文件,將其加載爲 node 添加 lsm tree 的 nodes 內存切片中
    for _, sstEntry := range sstEntries {
        if err = t.loadNode(sstEntry); err != nil {
            return err
        }
    }


    return nil
}

constructMemTable 方法用於讀取已有 wal 文件,在內存中還原出一系列只讀 memtable 和讀寫 memtable

// 讀取 wal 還原出 memtable
func (t *Tree) constructMemtable() error {
    // 1 讀 wal 目錄,獲取所有的 wal 文件
    wals, _ := os.ReadDir(path.Join(t.conf.Dir, "walfile"))


    // 2 倘若 wal 目錄不存在或者 wal 文件不存在,則構造一個新的 memtable
    if len(wals) == 0 {
        t.newMemTable()
        return nil
    }


    // 3 依次還原 memtable. 最晚一個 memtable 作爲讀寫 memtable
    // 前置 memtable 作爲只讀 memtable,分別添加到內存 slice 和 channel 中.
    return t.restoreMemTable(wals)
}

3.3 compact 協程

compact 協程是異步持續運行的,通過 for + select 實現一個自旋多路複用的模型,持續監聽 chan,處理 memtable 的溢寫流程以及某個 level 層 sstable 的 compact 流程.

// 運行 compact 協程.
func (t *Tree) compact() {
    for {
        select {
        // 接收到 lsm tree 終止信號,退出協程.
        case <-t.stopc:
            // log
            return
            // 接收到 read-only memtable,需要將其溢寫到磁盤成爲 level0 層 sstable 文件.
        case memCompactItem := <-t.memCompactC:
            t.compactMemTable(memCompactItem)
            // 接收到 level 層 compact 指令,需要執行 level~level+1 之間的 level sorted merge 流程.
        case level := <-t.levelCompactC:
            t.compactLevel(level)
        }
    }
}

4 讀寫流程

下面梳理一下通過 lsm tree 實現讀寫操作的主流程.

4.1 寫流程

寫流程是比較簡單的,以 Put 方法作爲入口:

// 寫入一組 kv 對到 lsm tree. 會直接寫入到讀寫 memtable 中.
func (t *Tree) Put(key, value []byte) error {
    // 1 加寫鎖
    t.dataLock.Lock()
    defer t.dataLock.Unlock()


    // 2 數據預寫入預寫日誌中,防止因宕機引起 memtable 數據丟失.
    if err := t.walWriter.Write(key, value); err != nil {
        return err
    }


    // 3 數據寫入讀寫跳錶
    t.memTable.Put(key, value)


    // 4 倘若讀寫跳錶的大小未達到 level0 層 sstable 的大小閾值,則直接返回.
    // 考慮到溢寫成 sstable 後,需要有一些輔助的元數據,預估容量放大爲 5/4 倍
    if uint64(t.memTable.Size()*5/4) <= t.conf.SSTSize {
        return nil
    }


    // 5 倘若讀寫跳錶數據量達到上限,則需要切換跳錶
    t.refreshMemTableLocked()
    return nil
}

值得一提的是,在寫流程結束前,需要校驗一下讀寫 memtable 中的數據量大小是否達到溢寫落盤的閾值,是的話,需要通過 refreshMemTableLocked 方法完成 memtable 的模式切換

// 切換讀寫跳錶爲只讀跳錶,並構建新的讀寫跳錶
func (t *Tree) refreshMemTableLocked() {
    // 辭舊
    // 將讀寫跳錶切換爲只讀跳錶,追加到 slice 中,並通過 chan 發送給 compact 協程,由其負責進行溢寫成爲 level0 層 sst 文件的操作.
    oldItem := memTableCompactItem{
        walFile:  t.walFile(),
        memTable: t.memTable,
    }
    t.rOnlyMemTable = append(t.rOnlyMemTable, &oldItem)
    t.walWriter.Close()
    go func() {
        t.memCompactC <- &oldItem
    }()


    // 迎新
    // 構造一個新的讀寫 memtable,並構造與之相應的 wal 文件.
    t.memTableIndex++
    t.newMemTable()
}

4.2 讀流程

lsm 的讀流程分爲一系列步驟,需要嚴格按照順序執行:

// 根據 key 讀取數據.
func (t *Tree) Get(key []byte) ([]byte, bool, error) {
    t.dataLock.RLock()
    // 1 首先讀 active memtable.
    value, ok := t.memTable.Get(key)
    if ok {
        t.dataLock.RUnlock()
        return value, true, nil
    }


    // 2 讀 readOnly memtable
    for i := len(t.rOnlyMemTable) - 1; i >= 0; i-- {
        value, ok = t.rOnlyMemTable[i].memTable.Get(key)
        if ok {
            t.dataLock.RUnlock()
            return value, true, nil
        }
    }
    t.dataLock.RUnlock()


    // 3 讀 sstable level0 層.
    var err error
    t.levelLocks[0].RLock()
    for i := len(t.nodes[0]) - 1; i >= 0; i-- {
        if value, ok, err = t.nodes[0][i].Get(key); err != nil {
            t.levelLocks[0].RUnlock()
            return nil, false, err
        }
        if ok {
            t.levelLocks[0].RUnlock()
            return value, true, nil
        }
    }
    t.levelLocks[0].RUnlock()


    // 4 依次讀 sstable level 1 ~ i 層.
    for level := 1; level < len(t.nodes); level++ {
        t.levelLocks[level].RLock()
        node, ok := t.levelBinarySearch(level, key, 0, len(t.nodes[level])-1)
        if !ok {
            t.levelLocks[level].RUnlock()
            continue
        }
        if value, ok, err = node.Get(key); err != nil {
            t.levelLocks[level].RUnlock()
            return nil, false, err
        }
        if ok {
            t.levelLocks[level].RUnlock()
            return value, true, nil
        }
        t.levelLocks[level].RUnlock()
    }


    // 5 至此都沒有讀到數據,則返回 key 不存在.
    return nil, false, nil
}

5 小結

本期作爲整個 【基於 go 實現 lsm tree】專題的開篇之作,和大家簡單回顧了 lsm tree 的底層原理,並和大家一起走進了我的開源項目 golsm 當中,梳理了實現 lsm tree 的大體框架.

在此展望未來幾周繼續和大家展開討論的內容:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KpkiBQNycLoDskUr00WeEg