从 0 到 1 实现 LSM-Tree 存储引擎

259 阅读26分钟

前言

本文将从认识 LSM-Tree(Log-Structured Merge-Tree)开始,了解 LSM-Tree 中涉及到的的概念,结构等,并带领你从 0 到 1 实现一个自己的基于 LSM-Tree 的存储引擎。

什么是 LSM-Tree

基本概念

LSM-Tree(Log-Structured Merge Tree 日志结构合并树) 是一种适合高吞吐量写操作的数据结构,常用于数据库和存储系统中,例如 Cassandra、RocksDB 和 LevelDB。

其核心思想是将写操作先写入内存中的数据结构(通常是有序数据结构,例如跳表或 AVL 树),然后再以批量的方式顺序写入磁盘保存为 SSTable,避免随机 IO。

基本结构

image-20241227211217986.png

LSM-Tree 的整体结构分为两个主要部分:

  • 内存存储
    • 内存存储中的核心结构为 Memtable

    • 所有的写入操作(set,delete)都会先到达 Memtable,Memtable 会将这些操作插入进一个有序的数据结构中(图中以有序树为例);

    • 当 Memtable 达到一定的大小阈值后,会以 SSTable 的形式持久化到磁盘(顺序写入);

    • 新的写入操作会在一个新的 Memtable 上继续进行;

  • 磁盘存储
    • 磁盘存储涉及 WALSSTable 文件;
    • WAL 的作用是为了处理在数据库崩溃时最近的写入(在 Memtable 中,但尚未写入硬盘)丢失的问题。所有在 Memtable 上写入都会被追加到 WAL 上,在数据库重启后只需按照顺序应用 WAL 中的写入条目就可以恢复崩溃前的 Memtable;
    • SSTable(Sorted String Table)是一种数据存储格式,其保存了一系列有序的键值对。
    • Memtable 在达到大小阈值后会生成一个新的 SSTable 并持久化到磁盘,由于 Memtable 依靠内存中的有序数据结构对键值对进行了排序,所以在构建 SSTable 时不需要另外的排序。
    • SSTable 在磁盘中被分为多个层级进行存储,刚从 Memtable 写入磁盘的新 SSTable 会处于 Level 0,在后续的压缩阶段会将 L0 中的 SSTable 压缩至 L1 以及更高的层级。
    • 在层级的大小达到阈值时会触发 SSTable 压缩合并过程,当前层级的 SSTable 会被压缩合并至更高的层级,形成更大的有序文件,减少碎片和查询时的开销。

通常 SSTable 的结构除了包含一系列有序的键值对(data blocks)外还有 index block,metadata block 等部分,我们将在实现部分着重讲解。

写入数据

写入数据指的是添加一个新的键值对,对已有键值对的更新也会通过这种方式来进行,旧的键值对会在压缩过程中删除。

写入的数据会先到达 Memtable,Memtable 会将键值对添加到有序数据结构中,同时会将这个写操作记录到 WAL 中并持久化到磁盘,防止数据库崩溃导致的内存中 Memtable 的数据丢失。

Memtable 会被设置一个阈值(通常是根据大小),如果内存中 Memtable 的大小超过了这个阈值,则当前 Memtable 会被转换为只读模式,然后转变为一个新的 SSTable 并持久化到 Level 0 中。

当这个 Memtable 被持久化为 SSTable 后就可以删除其对应的 WAL 文件,之后的写入数据将会在一个新的 Memtable (以及 WAL)上进行。

删除数据

LSM-Tree 的删除数据并不会直接将数据删除,而是通过一种叫 “墓碑” (tombstone)的方式来进行(有点类似于软删除)。LSM-Tree 会写入一个新的具有 “墓碑” 标记的键值对,对应要进行删除的键值对,而真正的删除会在压缩过程中进行。

LSM-Tree 使用这种删除方式的目的还是为了避免磁盘随机 IO,通过 “墓碑” 来进行删除保证了仅追加的特性,顺序写入磁盘。

查询数据

查询数据的过程将从对 Memtable 的查询开始,如果找到了对应的键值对则直接返回给 client(如果找到了有墓碑标记的键值对则说明要查找的数据已经被删除,同样直接返回),没有找到则从 Level 0 到 Level N 的范围内进行查找。

LSM-Tree 查询数据的过程由于可能需要查找多个 SSTable 文件,涉及到磁盘随机 IO,所以一般来说 LSM-Tree 更适合写多读少的场景。

对查询数据过程的优化的一个方式是引入布隆过滤器(bloom filter),布隆过滤器可以快速判断一个键值对是否在一个 SSTable 中从而避免不必要的磁盘 IO。另外 SSTable 有序的特性可以让我们利用二分查找等算法进行高效的查询。

压缩数据

我们这里介绍的是 Leveled 压缩策略,也是 LevelDB 和 RocksDB 所使用的压缩策略。

另一个常见的策略为 Size-Tiered 压缩策略,在 Size-Tiered 压缩策略中,较新和较小的 SSTables 相继被合并到较旧的和较大的 SSTable 中。

之前的介绍中,我们已经知道了一个 SSTable 存储了一系列按键排序的条目,在 Leveled 压缩策略中,SSTable 被组织划分至多个层级(Level 0 - Level N)。

其中 Level 0 为从 Memtable 刚刚刷新到磁盘中的 SSTable 所对应的层级,并且 Level 0 中各个 SSTable 之间可以包含重叠的键,而在 Level 1 - Level N 中同一层的 SSTable 之间不允许存在重叠的键,但是层与层之间的 SSTable 可能存在键范围重叠。

一个不太准确的示意图如下所示,可以看到在 Level 0 的第一个和第二个 SSTable 的键范围是存在重叠部分的,而在 Level 1 和 Level 2 中同一层的 SSTable 之间的键范围都不重叠,Level 0,Level 1 和 Level 2 层与层之间的 SSTable 存在键范围重叠的。

image-20241228161919532.png

接下来我们来看一下 Leveled 压缩策略是如何维护这种组织结构的。

由于 Level 0 比较特殊,所以我们将压缩策略的讨论分为两部分:

  • Level 0 - Level 1

    由于 L0 允许 SSTable 存在键重叠,在开始压缩时我们会选择从 L0 中选择一个 SSTable,以及 L0 中所有与这个 SSTable 存在键范围重叠的 SSTable,然后在 L1 中选择与 L0 这些 SSTable 有键范围重叠的 SSTable,最后将所有选择的 SSTable 进行合并压缩为一个 SSTable,合并后的 SSTable 会作为一个新的 SSTable 插入 L1,同时所有合并时选择的旧的 SSTable 都会进行删除。

  • Level N - Level N+1 (N > 0)

    从 L1 开始,同一层的 SSTable 就不允许存在键范围重叠了,在开始压缩后我们会从 LN 中选择一个 SSTable,并从 LN+1 中选择所有与这个 SSTable 存在键范围重叠的 SSTable,最后将所有选择的 SSTable 进行合并压缩并插入 LN+1,删除旧的的 SSTable。

可以看到 L0 - L1 和 LN - LN+1 (N > 0)的压缩的主要区别就在低层次(L0, LN)的 SSTable 的选择上。

多个 SSTable 压缩合并的过程如下图所示,即保留每个键最新的值,如果最新的值存在 “墓碑” 标记,则将这个键删除,在实现部分我们将利用 k-way merge 算法来实现这个过程。

image-20241228172639285.png

需要注意的是上述的压缩数据的过程只是提供了一个概述,在实际的实现中还有很多的细节问题需要进行考虑。

e.g. LevelDB 在压缩构建 LN+1 的新 SSTable 过程中如果发现与超过 10 个 LN+2 的 SSTable 存在键重叠则会切换另一个新 SSTable 继续构建,这样可以对单次压缩的数据量进行控制。

实现

通过上述对 LSM-Tree 的概述我相信你已经对 LSM-Tree 有了一个基本的了解,并且有了自己的实现思路,接下来我们将从 0 到 1 实现一个基于 LSM-Tree 的存储引擎,下面将只对核心代码进行介绍,完整代码的请参考 github.com/B1NARY-GR0U…

我们将 LSM-Tree 的实现拆分为下面这些核心组件并一一进行实现:

  • Skip List
  • WAL
  • Memtable
  • SSTable
  • K-Way Merge
  • Bloom Filter
  • Leveled Compaction

Skip List

我们在介绍写入数据的过程中提到了 LSM-Tree 会先将数据写入一个内存中的有序数据结构,一些有序的数据结构以及其各个操作的时间复杂度如下所示:

数据结构插入删除查找遍历
跳表O(log⁡n)O(log⁡n)O(log⁡n)O(n)
AVL 树O(log⁡n)O(log⁡n)O(log⁡n)O(n)
红黑树O(log⁡n)O(log⁡n)O(log⁡n)O(n)

我们这里选择跳表,一方面是由于跳表的实现更加简单,易于维护(KISS),另一方面跳表底层使用的链表让我们可以更方便的进行顺序遍历,将内存中的数据持久化到磁盘。

核心结构体

跳表的完整实现在 github.com/B1NARY-GR0U…

跳表由底层的链表,链表之上的多层索引组成,在数据量大的情况下,索引层可以大大缩短查找元素的路径。

在我们的实现中,跳表的核心结构体如下:

type SkipList struct {
	maxLevel int
	p        float64
	level    int
	rand     *rand.Rand
	size     int
	head     *Element
}
  • maxLevel:跳表的最大层级(底层链表的层级为 1);
  • level:跳表的当前层级;
  • p:每一层节点被提升到更高一层的概率,例如在 p 为 0.5 的情况下,底层链表具有 10 个节点,那么链表上层的索引将大概会有 5 个节点;
  • rand:用于取随机数并与 p 比较;
  • size:跳表所存储的键值对的大小,用于判断 Memtable 是否超出阈值;
  • head:头节点,保存每一层的首节点的引用;

跳表存储的元素的结构体定义如下:

type Element struct {
	types.Entry
	next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
	Key       string
	Value     []byte
	Tombstone bool  
}
  • types.Entry 是我们在存储引擎内部使用的键值对的表示,包含 Key,Value 以及用于删除的 Tombstone;

  • next:保存了这个元素在每一层指向的下一个元素;

这里可能比较抽象,你可以通过下面这个例子来理解:

Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]

我们有一个三层的跳表,头结点的 next 保存每一层的首节点的引用,元素 3 和元素 6 都保存了其每一层的下一个节点。

例如如果我们想要知道元素 19 在 Level 2 的下一个节点是什么,则使用 e19.next[2-1]

Set

func (s *SkipList) Set(entry types.Entry)

LSM-Tree 使用了墓碑来进行删除,所以在跳表的实现中,我们也不需要 Delete 方法,如果要删除一个元素,将 entry 的 Tombstone 设置为 true 即可。所以这里 Set 方法完成了插入新键值对,更新现有键值对和删除现有键值对的作用。

接下来我们来看 Set 方法的具体实现,通过从最高层开始遍历各个层的节点,将每一层小于要设置的 key 的最后一个元素保存在 update 切片中。

curr := s.head
update := make([]*Element, s.maxLevel)

for i := s.maxLevel - 1; i >= 0; i-- {
    for curr.next[i] != nil && curr.next[i].Key < entry.Key {
        curr = curr.next[i]
    }
    update[i] = curr
}

在上述的遍历最后,curr 已经指向底层链表中小于要设置的 key 的最后一个元素,所以我们只需要判断这个元素的下一个元素是否等于我们要设置的 key 即可,如果相等,则说明这个元素已经进行了插入,我们更新现有元素并返回。

// update entry
if curr.next[0] != nil && curr.next[0].Key == entry.Key {
    s.size += len(entry.Value) - len(curr.next[0].Value)

    // update value and tombstone
    curr.next[0].Value = entry.Value
    curr.next[0].Tombstone = entry.Tombstone
    return
}

没有找到现有的元素则此元素作为新元素插入,通过 randomLevel 计算出这个元素的索引层数,如果高于当前跳表的层数则将头结点加入 update 切片中,并更新当前跳表的层数 s.level

// add entry
level := s.randomLevel()

if level > s.level {
    for i := s.level; i < level; i++ {
        update[i] = s.head
    }
    s.level = level
}

接着构建出要进行插入的元素,更新各个层级的 next 指针指向完成插入操作。

e := &Element{
    Entry: types.Entry{
        Key:       entry.Key,
        Value:     entry.Value,
        Tombstone: entry.Tombstone,
    },
    next: make([]*Element, level),
}

for i := range level {
    e.next[i] = update[i].next[i]
    update[i].next[i] = e
}
s.size += len(entry.Key) + len(entry.Value) + int(unsafe.Sizeof(entry.Tombstone)) + len(e.next)*int(unsafe.Sizeof((*Element)(nil)))

Get

跳表的可以依靠多层索引进行快速的查找操作,方法实现中的双重 for 循环即为使用索引的查找操作,如果最终在底层链表中找到了对应的元素则进行返回。

func (s *SkipList) Get(key types.Key) (types.Entry, bool) {
	curr := s.head

	for i := s.maxLevel - 1; i >= 0; i-- {
		for curr.next[i] != nil && curr.next[i].Key < key {
			curr = curr.next[i]
		}
	}

	curr = curr.next[0]

	if curr != nil && curr.Key == key {
		return types.Entry{
			Key:       curr.Key,
			Value:     curr.Value,
			Tombstone: curr.Tombstone,
		}, true
	}
	return types.Entry{}, false
}

All

我们选择跳表的一个原因是其方便的顺序遍历,这得益于我们只需要对底层链表进行遍历即可。

func (s *SkipList) All() []types.Entry {
	var all []types.Entry

	for curr := s.head.next[0]; curr != nil; curr = curr.next[0] {
		all = append(all, types.Entry{
			Key:       curr.Key,
			Value:     curr.Value,
			Tombstone: curr.Tombstone,
		})
	}

	return all
}

WAL

WAL 的完整实现在 github.com/B1NARY-GR0U…

我们前面提到 WAL(Write-Ahead Logging)的作用是为了防止数据库崩溃导致的内存中的 Memtable 的数据丢失,所以 WAL 需要可以对 Memtable 的操作进行记录,并且在数据库重启时根据 WAL 文件恢复 Memtable。

核心结构体

WAL 的核心结构体如下,fd 保存了 WAL 文件的文件描述符:

type WAL struct {
	mu      sync.Mutex
	logger  logger.Logger
	fd      *os.File
	dir     string
	path    string
	version string
}

Write

由于我们需要对 Memtable 的操作进行记录,本质上就是将每次写操作(Set,Delete)的 Entry 记录到 WAL 中,所以 Write 方法的定义如下:

func (w *WAL) Write(entries ...types.Entry) error

在将这些 entries 写入文件时,我们需要统一一下 WAL 的文件格式,我们这里使用的格式为 长度+数据,先将 Entry 序列化,然后计算序列化后数据的长度,最后依次将长度和序列化数据写入 WAL 文件。

核心代码如下:

data, err := utils.TMarshal(&entry)
if err != nil {
    return err
}

// data length
n := int64(len(data))
err = binary.Write(buf, binary.LittleEndian, n)
if err != nil {
    return err
}
// data body
err = binary.Write(buf, binary.LittleEndian, data)
if err != nil {
    return err
}

Read

由于我们使用 WAL 文件格式为 长度+数据,所以在读取时我们先读取 8 个字节(int64)获取数据的长度,然后根据长度读取数据并反序列化即可得到 Entry

核心代码如下:

var entries []types.Entry
reader := bytes.NewReader(buf.Bytes())
for reader.Len() > 0 {
    // data length
    var n int64
    if err = binary.Read(reader, binary.LittleEndian, &n); err != nil {
        return nil, err
    }

    // data body
    data := make([]byte, n)
    if err = binary.Read(reader, binary.LittleEndian, &data); err != nil {
        return nil, err
    }

    var entry types.Entry
    if err = utils.TUnmarshal(data, &entry); err != nil {
        return nil, err
    }
    entries = append(entries, entry)
}

Memtable

Memtable 的完整实现在 github.com/B1NARY-GR0U…

Memtable 需要负责将客户端的写入操作写入跳表并记录 WAL,同时可以在数据库启动时根据 WAL 进行恢复。

核心结构体

Memtable 的核心结构体如下,包含了 skiplistwal 两个核心结构:

type memtable struct {
	mu       sync.RWMutex
	logger   logger.Logger
	skiplist *skiplist.SkipList
	wal      *wal.WAL
	dir      string
	readOnly bool
}

Set

在 Set 时需要同时更新跳表和 WAL。

func (mt *memtable) set(entry types.Entry) {
	mt.mu.Lock()
	defer mt.mu.Unlock()

	mt.skiplist.Set(entry)
	if err := mt.wal.Write(entry); err != nil {
		mt.logger.Panicf("write wal failed: %v", err)
	}
	mt.logger.Infof("memtable set [key: %v] [value: %v] [tombstone: %v]", entry.Key, string(entry.Value), entry.Tombstone)
}

Get

获取值时直接返回跳表 Get 的结果即可。

func (mt *memtable) get(key types.Key) (types.Entry, bool) {
	mt.mu.RLock()
	defer mt.mu.RUnlock()

	return mt.skiplist.Get(key)
}

Recover

从 WAL 文件恢复 Memtable 需要先读取 WAL 文件,然后将 WAL 文件中的 Entry 依次应用到 Memtable,最后将恢复后的 WAL 文件删除即可。

获取 WAL 文件列表:

files, err := os.ReadDir(mt.dir)
if err != nil {
    mt.logger.Panicf("read dir %v failed: %v", mt.dir, err)
}

var walFiles []string
for _, file := range files {
    if !file.IsDir() && path.Ext(file.Name()) == ".log" && wal.CompareVersion(wal.ParseVersion(file.Name()), mt.wal.Version()) < 0 {
        walFiles = append(walFiles, path.Join(mt.dir, file.Name()))
    }
}

读取 WAL 并恢复 Memtable:

for _, file := range walFiles {
    l, err := wal.Open(file)
    if err != nil {
        mt.logger.Panicf("open wal %v failed: %v", file, err)
    }
    entries, err := l.Read()
    if err != nil {
        mt.logger.Panicf("read wal %v failed: %v", file, err)
    }
    for _, entry := range entries {
        mt.skiplist.Set(entry)
        if err = mt.wal.Write(entry); err != nil {
            mt.logger.Panicf("write wal failed: %v", err)
        }
    }
    if err = l.Delete(); err != nil {
        mt.logger.Panicf("delete wal %v failed: %v", file, err)
    }
}

SSTable

LevelDB SSTable

在前面的介绍中我们只提到 ”SSTable(Sorted String Table)是一种数据存储格式,其保存了一系列有序的键值对“,这里我们对 SSTable 的结构进行进一步的介绍。

在 LevelDB 中,SSTable 由多个具有不同作用的块组成,一个示意图如下所示:

image-20250102110509524.png

  • Data Block:存储了有序的键值对序列;
  • Meta Block:具有 filter 和 stats 两种类型,其中 filter 类型存储了布隆过滤器的数据,stats 类型存储了一些 Data Block 的统计信息;
  • MetaIndex Block:存储了 Meta Block 的索引信息;
  • Index Block:存储了 Data Block 的索引信息;
  • Footer:长度固定,存储了 MetaIndex Block 和 Index Block 的索引信息以及一个魔数;

索引信息实际上是一个称为 BlockHandle 的指针结构,包含了 offset 和 size 两个属性,用来索引对应的 Block。

我们的 SSTable

在我们的 SSTable 实现中,对 LevelDB 的 SSTable 进行了简化,一个示意图如下所示:

image-20250102110951542.png

  • Data Block:存储了有序的键值对序列;
  • Meta Block:存储了 SSTable 的一些元信息;
  • Index Block:存储了 Data Block 的索引信息;
  • Footer:长度固定,存储了 Meta Block 和 Index Block 的索引信息;

SSTable 的完整实现在 github.com/B1NARY-GR0U…

Data Block

Data Block 的结构体定义如下,存储了有序的 Entry 序列。

// Data Block
type Data struct {
	Entries []types.Entry
}

我们为 Data Block 实现三个主要的方法:

  • Encode:将 Data Block 编码为二进制数据;
func (d *Data) Encode() ([]byte, error)

我们使用**前缀压缩(Prefix Compression)**来对键值序列进行编码,在 buffer 中依次写入公共前缀的长度,后缀的长度,后缀,值的长度,值以及 “墓碑” 标记。

var prevKey string
for _, entry := range d.Entries {
    lcp := utils.LCP(entry.Key, prevKey)
    suffix := entry.Key[lcp:]

    // lcp
    if err := binary.Write(buf, binary.LittleEndian, uint16(lcp)); err != nil {
        return nil, err
    }

    // suffix length
    if err := binary.Write(buf, binary.LittleEndian, uint16(len(suffix))); err != nil {
        return nil, err
    }
    // suffix
    if err := binary.Write(buf, binary.LittleEndian, []byte(suffix)); err != nil {
        return nil, err
    }

    // value length
    if err := binary.Write(buf, binary.LittleEndian, uint16(len(entry.Value))); err != nil {
        return nil, err
    }
    // value
    if err := binary.Write(buf, binary.LittleEndian, entry.Value); err != nil {
        return nil, err
    }

    // tombstone
    tombstone := uint8(0)
    if entry.Tombstone {
        tombstone = 1
    }
    if err := binary.Write(buf, binary.LittleEndian, tombstone); err != nil {
        return nil, err
    }

    prevKey = entry.Key
}

最后使用 s2 进行压缩。

S2 是 Snappy 压缩算法的一个高性能扩展.

// s2 compress
if err := utils.Compress(buf, compressed); err != nil {
    return nil, err
}
return compressed.Bytes(), nil
  • Decode:将二进制数据解码为 Data Block;
func (d *Data) Decode(data []byte) error

解码时只需要将编码的过程反过来即可,通过前缀和后缀组装出完整的键值。

reader := bytes.NewReader(buf.Bytes())
var prevKey string
for reader.Len() > 0 {
    // lcp
    var lcp uint16
    if err := binary.Read(reader, binary.LittleEndian, &lcp); err != nil {
        return err
    }

    // suffix length
    var suffixLen uint16
    if err := binary.Read(reader, binary.LittleEndian, &suffixLen); err != nil {
        return err
    }
    // suffix
    suffix := make([]byte, suffixLen)
    if err := binary.Read(reader, binary.LittleEndian, &suffix); err != nil {
        return err
    }

    // value length
    var valueLen uint16
    if err := binary.Read(reader, binary.LittleEndian, &valueLen); err != nil {
        return err
    }
    // value
    value := make([]byte, valueLen)
    if err := binary.Read(reader, binary.LittleEndian, &value); err != nil {
        return err
    }

    var tombstone uint8
    if err := binary.Read(reader, binary.LittleEndian, &tombstone); err != nil {
        return err
    }

    key := prevKey[:lcp] + string(suffix)
    d.Entries = append(d.Entries, types.Entry{
        Key:       key,
        Value:     value,
        Tombstone: tombstone == 1,
    })

    prevKey = key
}
  • Search:使用二分查找定位键值对;
func (d *Data) Search(key types.Key) (types.Entry, bool) {
	low, high := 0, len(d.Entries)-1
	for low <= high {
		mid := low + ((high - low) >> 1)
		if d.Entries[mid].Key < key {
			low = mid + 1
		} else if d.Entries[mid].Key > key {
			high = mid - 1
		} else {
			return d.Entries[mid], true
		}
	}
	return types.Entry{}, false
}

Index Block

Index Block 的结构体定义如下,保存了每一个 Data Block 的第一个和最后一个键,以及 Data Block 的 BlockHandle

// Index Block
type Index struct {
	// BlockHandle of all data blocks of this sstable
	DataBlock BlockHandle
	Entries   []IndexEntry
}

// IndexEntry include index of a sstable data block
type IndexEntry struct {
	// StartKey of each Data block
	StartKey string
	// EndKey of each Data block
	EndKey string
	// offset and length of each data block
	DataHandle BlockHandle
}

type BlockHandle struct {
	Offset uint64
	Length uint64
}

同样为 Index Block 实现 EncodeDecodeSearch 这三个主要的方法。Encode 和 Decode 方法的实现思路基本一致,我们主要来看一下 Search 方法。

Data Block 的 Search 方法是为了在一个 Data Block 所保存的有序键值序列内根据键值找到对应的键值对,而 Index Block 的 Search 方法是为了在根据键值在这个 SSTable 的所有 Data Block 中找到包含这个键值的 Data Block。

// Search data block included the key
func (i *Index) Search(key types.Key) (BlockHandle, bool) {
	n := len(i.Entries)
	if n == 0 {
		return BlockHandle{}, false
	}

	// check if the key is beyond this sstable
	if key > i.Entries[n-1].EndKey {
		return BlockHandle{}, false
	}

	low, high := 0, n-1
	for low <= high {
		mid := low + ((high - low) >> 1)
		if i.Entries[mid].StartKey > key {
			high = mid - 1
		} else {
			if mid == n-1 || i.Entries[mid+1].StartKey > key {
				return i.Entries[mid].DataHandle, true
			}
			low = mid + 1
		}
	}
	return BlockHandle{}, false
}

Meta Block 和 Footer

type Meta struct {
	CreatedUnix int64
	Level       uint64
}

type Footer struct {
	MetaBlock  BlockHandle
	IndexBlock BlockHandle
	Magic      uint64
}

这两个 Block 的实现都非常简单,同样实现 Encode 和 Decode 方法即可。

Build

介绍完了我们的 SSTable 的所有 Block,构建 SSTable 只需要根据键值对一步一步构建每一个 Block 即可,最后返回保存在内存中的索引以及编码后的 SSTable。

func Build(entries []types.Entry, dataBlockSize, level int) (Index, []byte)

K-Way Merge

KWay Merge 的完整实现在 github.com/B1NARY-GR0U…

我们在概念部分以绘图的形式展示了多个 SSTable 压缩合并的过程,这个过程是由 k-way merge 算法来完成的。

k-way merge 算法是一种将 k 个已排序序列合并为一个有序序列的算法,时间复杂度为 O(knlogk)

算法的一个实现方式是使用最小堆来辅助:

  • 将每个序列的首个元素插入堆中;
  • 然后从堆中弹出最小值并加入结果集,如果弹出元素所在的序列还有元素,则将下一个元素插入堆中;
  • 一直重复这个过程直到所有序列中的元素都被合并;

Heap

标准库提供了堆的实现 container/heap ,通过实现 heap.Interface 我们可以构建一个最小堆。

  • 首先定义最小堆的基本结构,我们使用切片来存储元素,每一个元素除了包含 Entry 外还需要一个 LI 用来代表这个元素来自于哪个有序序列;
type Element struct {
	types.Entry
	// list index
	LI int
}

// Heap min heap
type Heap []Element
  • 实现 sort.Interface 接口,用来对堆中的元素进行排序,需要注意的是 Less 方法,我们通过对元素的 LI 进行比较来保证在元素的键值相同的情况下,属于较早有序序列的元素排序在前,这样在后序合并到结果集中时可以很方便的进行去重,这也要求我们在使用 k-way merge 算法时需要将从旧到新的顺序排列有序序列。
func (h *Heap) Len() int {
	return len(*h)
}

func (h *Heap) Less(i, j int) bool {
	return (*h)[i].Key < (*h)[j].Key && (*h)[i].LI == (*h)[j].LI || (*h)[i].Key == (*h)[j].Key && (*h)[i].LI < (*h)[j].LI
}

func (h *Heap) Swap(i, j int) {
	(*h)[i], (*h)[j] = (*h)[j], (*h)[i]
}
  • 最后实现 PushPop 方法,Push 将元素追加到切片末尾,Pop 将切片末尾的元素弹出。
func (h *Heap) Push(x any) {
	*h = append(*h, x.(Element))
}

// Pop the minimum element in heap
// 1. move the minimum element to the end of slice
// 2. pop it (what this method does)
// 3. heapify
func (h *Heap) Pop() any {
	curr := *h
	n := len(curr)
	e := curr[n-1]
	*h = curr[0 : n-1]
	return e
}

Merge

Merge 方法的函数定义如下:

func Merge(lists ...[]types.Entry) []types.Entry

按照 k-way merge 的算法流程,我们先将每个有序序列的首个元素插入最小堆中。

h := &Heap{}
heap.Init(h)

// push first element of each list
for i, list := range lists {
    if len(list) > 0 {
        heap.Push(h, Element{
            Entry: list[0],
            LI:    i,
        })
        lists[i] = list[1:]
    }
}

依次从最小堆中弹出一个元素并加入结果队列,如果弹出元素所在的序列还有元素,则将下一个元素插入堆中。这里我们使用一个 map 代替结果序列,map 可以对结果进行去重,并且旧的键总会被新的键覆盖。

latest := make(map[string]types.Entry)

for h.Len() > 0 {
    // pop minimum element
    e := heap.Pop(h).(Element)
    latest[e.Key] = e.Entry
    // push next element
    if len(lists[e.LI]) > 0 {
        heap.Push(h, Element{
            Entry: lists[e.LI][0],
            LI:    e.LI,
        })
        lists[e.LI] = lists[e.LI][1:]
    }
}

最后遍历 map 将元素依次加入结果队列并删除具有 “墓碑” 标记的键值对,由于 map 是无序的所以最后需要对结果序列进行排序后返回。

var merged []types.Entry

for _, entry := range latest {
    if entry.Tombstone {
        continue
    }
    merged = append(merged, entry)
}

slices.SortFunc(merged, func(a, b types.Entry) int {
    return cmp.Compare(a.Key, b.Key)
})

return merged

Bloom Filter

Bloom Filter 的完整实现在 github.com/B1NARY-GR0U…

布隆过滤器是一种可以高效的检查某个元素是否属于一个集合的数据结构。

  • 使用一个位数组和多个哈希函数;

  • 添加元素时,将元素经过多个哈希函数映射到位数组的不同位置,并将这些位置置为 1;

  • 查询时,如果元素经过哈希函数映射的所有位置都为 1,则可能存在,如果有任何一个位置为0,则一定不存在;

插入和查询时间复杂度都是 O(k),k 为哈希函数个数,可能会出现假阳性(布隆过滤器预测元素在集合中,但元素实际不在集合中),但不可能出现假阴性(布隆过滤器预测元素不在集合中,但元素实际在集合中)。

真阳性(True Positive, TP):系统预测某事件为“正”,且实际确实为“正”。

假阳性(False Positive, FP):系统预测某事件为“正”,但实际是“负”。

真阴性(True Negative, TN):系统预测某事件为“负”,且实际确实为“负”。

假阴性(False Negative, FN):系统预测某事件为“负”,但实际是“正”。

核心结构体

布隆过滤器的核心结构体如下,包含了我们所说的位数组(可以优化为使用 uint8)和多个哈希函数。

type Filter struct {
	bitset  []bool
	hashFns []hash.Hash32
}

New

创建布隆过滤器实例的方法如下,接收两个参数:n(预期的元素数量)和 p(预期的误报率)。

我们先对参数进行校验,然后通过公式计算 m(位数组的大小)和 k(哈希函数的数量),最后就可以根据 m 和 k 创建对应的位数组和哈希函数。

// New creates a new BloomFilter with the given size and number of hash functions.
// n: expected nums of elements
// p: expected rate of false errors
func New(n int, p float64) *Filter {
	if n <= 0 || (p <= 0 || p >= 1) {
		panic("invalid parameters")
	}

	// size of bitset
	// m = -(n * ln(p)) / (ln(2)^2)
	m := int(math.Ceil(-float64(n) * math.Log(p) / math.Pow(math.Log(2), 2)))
	// nums of hash functions used
	// k = (m/n) * ln(2)
	k := int(math.Round((float64(m) / float64(n)) * math.Log(2)))

	hashFns := make([]hash.Hash32, k)
	for i := range k {
		hashFns[i] = murmur3.New32WithSeed(uint32(i))
	}

	return &Filter{
		bitset:  make([]bool, m),
		hashFns: hashFns,
	}
}

Add

向布隆过滤器添加元素时,我们遍历所有的哈希函数,用每个哈希函数计算 key 的哈希值,然后计算位数组的索引并将对应位置置为 true

// Add adds an element to the BloomFilter.
func (f *Filter) Add(key string) {
	for _, fn := range f.hashFns {
		_, _ = fn.Write([]byte(key))
		index := int(fn.Sum32()) % len(f.bitset)
		f.bitset[index] = true
		fn.Reset()
	}
}

Contains

在判断一个 key 是否在集合中时,我们同样使用所有的哈希函数计算哈希值以及对应的位数组索引,如果有 1 位不为 true,则我们判断这个元素不在集合中,返回 false

// Contains checks if an element is in the BloomFilter.
func (f *Filter) Contains(key string) bool {
	for _, fn := range f.hashFns {
		_, _ = fn.Write([]byte(key))
		index := int(fn.Sum32()) % len(f.bitset)
		fn.Reset()
		if !f.bitset[index] {
			return false
		}
	}
	return true
}

Leveled Compaction

Leveled Compaction 的完整实现在 github.com/B1NARY-GR0U…

在实现了 K-Way Merge,Bloom Filter 这些组件后,我们就可以完成我们实现的最后一部分,LSM-Tree 存储引擎中最重要的 SSTable 压缩过程,我们将实现在 “压缩数据” 部分介绍的 Leveled 压缩策略

由于在 Leveled 压缩策略中,SSTable 被组织划分至多个层级(Level 0 - Level N),我们需要有一个结构来存储这些信息,对各个层级的 SSTable 进行管理。

因此我们实现了一个叫做 levelManager 的结构,我们通过 []*list.List 来存储各个层级的 SSTable 信息,切片的索引对应层级,切片元素 list.List 是一个双端链表,保存了一个层级内的所有 SSTable 的信息。

type levelManager struct {
	mu            sync.Mutex
	dir           string
	l0TargetNum   int
	ratio         int
	dataBlockSize int
	// list.Element: tableHandle
	levels []*list.List
	logger logger.Logger
}

type tableHandle struct {
	// list index of table within a level
	levelIdx int
	// bloom filter
	filter filter.Filter
	// index of data blocks in this sstable
	dataBlockIndex sstable.Index
}

compactLN

compactLN 方法负责 Level N - Level N+1 (N>0) 的压缩,我们从 LN 中选择最旧的一个 SSTable,并从 LN+1 中选择所有与这个 SSTable 存在键范围重叠的 SSTable。

lnTable := lm.levels[n].Front()
start, end := boundary(lnTable)

// overlap sstables in LN+1
ln1Tables := lm.overlapLN(n+1, start, end)

我们按照从旧到新的顺序将所有选择的 SSTable 的 Data Blocks 中包含的键值对传入一个二维切片,然后使用 k-way merge 算法进行压缩合并。

// old -> new (append LN+1 first)
var dataBlockList [][]types.Entry
// LN+1 data block entries
for _, table := range ln1Tables {
    th := table.Value.(tableHandle)
    dataBlockLN1 := lm.fetch(n+1, th.levelIdx, th.dataBlockIndex.DataBlock)
    dataBlockList = append(dataBlockList, dataBlockLN1.Entries)
}
// LN data block entries
dataBlockLN := lm.fetch(n, lnTable.Value.(tableHandle).levelIdx, lnTable.Value.(tableHandle).dataBlockIndex.DataBlock)
dataBlockList = append(dataBlockList, dataBlockLN.Entries)

// merge sstables
mergedEntries := kway.Merge(dataBlockList...)

我们可以使用压缩合并后的键值对构建新的 Bloom Filter 以及 SSTable,然后将 SSTable 的相关信息追加到 LN+1 层的最后。

// build new bloom filter
bf := filter.Build(mergedEntries)
// build new sstable
dataBlockIndex, tableBytes := sstable.Build(mergedEntries, lm.dataBlockSize, n+1)

// table handle
th := tableHandle{
    levelIdx:       lm.maxLevelIdx(n+1) + 1,
    filter:         *bf,
    dataBlockIndex: dataBlockIndex,
}

// update index
// add new index to LN+1
lm.levels[n+1].PushBack(th)

最后删除旧的 SSTable,并将新构建的 SSTable 写入磁盘。

// remove old sstable index from LN
lm.levels[n].Remove(lnTable)
// remove old sstable index from LN+1
for _, e := range ln1Tables {
    lm.levels[n+1].Remove(e)
}

// delete old sstables from LN
if err := os.Remove(lm.fileName(n, lnTable.Value.(tableHandle).levelIdx)); err != nil {
    lm.logger.Panicf("failed to delete old sstable: %v", err)
}
// delete old sstables from LN+1
for _, e := range ln1Tables {
    if err := os.Remove(lm.fileName(n+1, e.Value.(tableHandle).levelIdx)); err != nil {
        lm.logger.Panicf("failed to delete old sstable: %v", err)
    }
}

// write new sstable
fd, err := os.OpenFile(lm.fileName(n+1, th.levelIdx), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0600)
if err != nil {
    lm.logger.Panicf("failed to open sstable: %v", err)
}
defer func() {
    if err = fd.Close(); err != nil {
        lm.logger.Errorf("failed to close file: %v", err)
    }
}()

_, err = fd.Write(tableBytes)

compactL0 负责 Level 0 - Level 1 的压缩,其过程除了需要从 L0 中选择一个 SSTable 外还需要选择所有与之重叠的 L0 SSTable,剩余的流程与 compactLN 一致。

search

search 方法从所有 SSTable 中查找对应的键值对,我们从 L0 开始遍历每一层的 SSTable 直到 LN,由于我们使用了布隆过滤器以及 SSTable 有序的特性,可以快速跳过不包含对应键值对的 SSTable。

func (lm *levelManager) search(key types.Key) (types.Entry, bool) {
	lm.mu.Lock()
	defer lm.mu.Unlock()

	if len(lm.levels) == 0 {
		return types.Entry{}, false
	}

	for level, tables := range lm.levels {
		for e := tables.Front(); e != nil; e = e.Next() {
			th := e.Value.(tableHandle)

			// search bloom filter
			if !th.filter.Contains(key) {
				// not in this sstable, search next one
				continue
			}

			// determine which data block the key is in
			dataBlockHandle, ok := th.dataBlockIndex.Search(key)
			if !ok {
				// not in this sstable, search next one
				continue
			}

			// in this sstable, search according to data block
			entry, ok := lm.fetchAndSearch(key, level, th.levelIdx, dataBlockHandle)
			if ok {
				return entry, true
			}
		}
	}

	return types.Entry{}, false
}

DB

至此我们实现了基于 LSM-Tree 的存储引擎中的所有核心组件,通过按照 LSM-Tree 介绍中的思路对组件进行拼装即可完成最后的数据库接口。

总结

我们从认识 LSM-Tree 开始,熟悉 LSM-Tree 中的各个核心组件以及处理客户端请求的流程,最终从 0 到 1 实现了我们自己的 LSM-Tree 存储引擎。

当然这个实现只是一个玩具,实际的存储引擎需要考虑更多细节上的内容,ORIGINIUM 后续也会进行更多的优化与改进。希望本篇文章以及 ORIGINIUM 可以让你对 LSM-Tree 有进一步的理解。

以上就是本篇文章的所有内容了,如果哪里写错了或者有任何问题,欢迎私聊或评论指出,以上。

参考列表