0 前言
本期咱们延续专题——基于 go 语言从零到一实现 lsm tree 的分享内容,本专题共分为下述四篇,本文是其中的第二篇——memtable 结构篇:
• 基于go实现lsm tree之memtable结构(本篇)
• 基于go实现lsm tree之sstable结构
• 基于go实现lsm tree之level sorted merge流程
关于以上内容的源码均已上传到我的个人开源项目中,大家根据需要自取,如果觉得代码写得还凑合,可以留个 star,不胜感激.
开源项目 golsm 传送门 >> https://github.com/xiaoxuxiansheng/golsm
1 memtable&wal
1.1 memtable 定位
• memtable 是 lsm tree 架构最顶层使用到的内存有序表,分为读写表 active memtable,和只读表 read-only memtable 两类.
• active memtable: 又称读写表,是 lsm tree 唯一的写入口. 本身基于有序表实现,因此内部 kv 对是有序且不重复的 . 当读写表容量达到阈值,会切换为 read only 模式
• read-only memtable: 又称只读表. 是处于溢写磁盘流程的中间状态. 溢写落盘后,会成为 level0 层的 sstable 文件,因此能保证文件内部是有序且不重复的, 但是 level0 层文件与文件之间无法保证上述性质.
有序表指的是内部元素已经按照 key 大小指定好顺序的数据结构,能够在检索时提供更好的性能表现. 有序表本身可以理解为一个 interface,任何一个实现了各项功能的数据结构都可以称之为有序表,包括我们今天会提及的:红黑树(Red-Black Tree)以及跳表(Skiplist) .
1.2 memtable 选型
在 memtable 有序表的选型上,纳入我们考量范围的结构包括跳表(Skiplist) 和红黑树(Red-Black Tree) 两种.
• 跳表 skiplist
跳表 skiplist 是有序表下的一种经典实现,本质是采取以空间换时间的策略,在有序链表的基础之上,维护了多层级的索引结构,基于二分查找的方式实现 O(logN) 级别时间复杂度下的增删改查操作. 跳表结构示意图如下:
• 红黑树 Red-Black Tree
红黑树,顾名思义,整棵树由红、黑两种颜色的节点组成,该数据结构包含了下述性质:
(1)本身是一棵搜索二叉树(左子树 key 均小于自身,右子树 key 均大于自身)
(2)根节点为黑色
(3)所有叶节点也都是黑色(叶节点都是nil节点)
(4)从根节点到叶节点的路径上,不允许出现两个红色的节点
(5)从任意一条根节点到叶节点的路径中,都包含有相同数目的黑色节点
综上可以看出,从任意节点出发沿任意路径抵达叶子节点,途径的节点个数相差不会超过一倍,即红黑树的平衡性能够得到保证,其增删改查操作,均能维持在 O(logN) 的时间复杂度内.
• 有序表选型
首先给出选型结论:golsm 中选用跳表作为 memtable 的实现结构. 选型原因如下:
(1)跳表性能不弱于红黑树
(2)跳表实现难度低于红黑树
(3)跳表支持范围查询
(4)跳表具有更好的并发性能
实际上,上述第(4)点仅停留在理论层面,我曾根据该方向展开一次分享探讨,但最终结果还不具备落到工程实践的成熟程度. 具体内容可以参见我之前分享的文章——如何实现一个并发安全的跳表.
1.3 skiplist 实现原理
下面简要概括一下跳表结构的实现原理,更完整的原理剖析内容可以参见我之前发表的文章——基于 golang 从零到一实现跳表.
跳表核心性质:
• 由多层单向链表结构组成,最底层拥有全量数据,层数越高,数据量越稀疏. 高层可以理解为索引结构
• 每次节点个数接近于相邻下层节点数的一半(事实上,这个比例可以配置);跳表在插入节点时,随机选取节点高度,保证每提高一层的概率都逐级递减,以满足不同层之间的节点个数比例分布
• 对于一个 m 层存在的节点,在 1~m-1层中这个节点也一定存在
• 头节点和尾节点的高度是动态扩缩的,其高度取决于当前跳表内数据节点的最大高度
• 跳表的”跳“字体现在,每跳过一个高层节点,实际上都过滤跳过了底层的大量数据,从而实现检索加速
1.4 wal 定位
wal 全称 write-ahead log,是基于磁盘存储的预写日志. 其功能是为了避免易失性存储的 memtable 出现数据丢失,保留基于 wal 恢复 memtable 数据的能力.
在 lsm tree 写数据的流程组织中,会保证先将写入的数据落到 wal 文件中,再进行 memtable 的写入操作. 在 wal 文件中,会基于【key长度】-【value长度】-【key内容】-【value内容】的形式完成内容存储,以实现不同 kv 对的数据分隔.
原理部分我们概述至此,从下一章开始,我们一起深入 golsm 源码看看有关于 memtable 和 wal 的实现细节.
2 memtable实现
2.1 memtable interface
有关 memtable 我们将其定义为抽象 interface,包括下述方法:
• Put:写入一条 kv 数据
• Get:读取一条 kv 数据
• All:返回其中所有 kv 数据
• Size:数据量总大小,单位 byte
• EntriesCnt:kv 对数
// 有序表 interface
type MemTable interface {
Put(key, value []byte) // 写入数据
Get(key []byte) ([]byte, bool) // 读取数据,第二个 bool flag 标识数据是否存在
All() []*KV // 返回所有的 kv 对数据
Size() int // 有序表内数据大小,单位 byte
EntriesCnt() int // kv 对数量
}
type KV struct {
Key, Value []byte
}
2.2 skiplist 数据结构
下面是有关跳表的实现部分.
skipNode 是跳表中的节点:
• key、value:节点中存储的数据
• nexts:下一个节点的指针. 由于跳表是多层结构,因此指针为切片的形式,切片 index 对应所属的层数
// 跳表节点
type skipNode struct {
nexts []*skipNode // 通过 next slice 来实现跳表节点多层指针结构
key, value []byte // 节点内存储的 kv 对数据
}
Skiplist 是跳表主体类,其中持有跳表头节点指针 head.
// 跳表,未加锁,不保证并发安全
type Skiplist struct {
head *skipNode // 跳表的头节点
entrisCnt int // 跳表中的 kv 对个数
size int // 跳表数据量大小,单位 byte
}
// 构造跳表实例
func NewSkiplist() MemTable {
return &Skiplist{
head: &skipNode{}, // 需要初始化根节点
}
}
2.3 跳表读流程
跳表读流程步骤如下:
(1)以 head 节点作为起点
(2)从当前跳表存在的最大高度出发
(3)倘若右侧节点 key 值小于 target,则持续向右遍历
(4)倘若右侧节点 key 值等于 target,则代表找到目标,直接取值返回
(5)倘若右侧节点为终点(nil)或者 key 值大于 target,则沿当前节点降低高度进入下一层
(6)重复上述(3)-(5)步
(7)倘若已经抵达第1层仍然找到不到 target,则返回 key 不存在的信息
// 从跳表中读取 kv 对
func (s *Skiplist) Get(key []byte) ([]byte, bool) {
// 倘若 key 存在,返回对应 val
if node := s.getNode(key); node != nil {
return node.value, true
}
return nil, false
}
// 根据 key 获取跳表中对应节点
func (s *Skiplist) getNode(key []byte) *skipNode {
move := s.head
// 层数自高向低,逐层检索
for level := len(s.head.nexts) - 1; level >= 0; level-- {
// 持续向右移动,直到右侧为空或者右侧节点 key >= 检索 key
for move.nexts[level] != nil && bytes.Compare(move.nexts[level].key, key) < 0 {
move = move.nexts[level]
}
// 如果右侧节点 key = 检索 key,则找到目标返回. 否则进入下一层
if move.nexts[level] != nil && bytes.Equal(move.nexts[level].key, key) {
return move.nexts[level]
}
}
return nil
}
跳表获取全量数据的流程比较简单,只需要沿着最底层依次遍历数据即可:
// 获取跳表中全量 kv 对数据
func (s *Skiplist) All() []*KV {
if len(s.head.nexts) == 0 {
return nil
}
kvs := make([]*KV, 0, s.entrisCnt)
// 从第 0 层开始自左向右依次遍历读取
for move := s.head; move.nexts[0] != nil; move = move.nexts[0] {
kvs = append(kvs, &KV{
Key: move.nexts[0].key,
Value: move.nexts[0].value,
})
}
return kvs
}
2.4 跳表写流程
(1)首先基于读流程检索出 key 对应的节点是否存在,倘若存在,则对值进行更新并直接返回
(2)随机出新插入节点的高度;倘若新插入节点的高度大于当前跳表的最大高度,则需要对起点和终点的高度进行扩容
(3)以 head 节点作为起点,从当前跳表存在的最大高度出发
(4)倘若右侧节点 key 值小于 target,则持续向右遍历
(5)倘若右侧节点为终点(nil)或者 key 值大于 target,则在该空隙插入新节点,并降低高度进入下一层
(6)重复(4)-(5)步
(7)倘若已经进入第 1 层,插入新节点后即可返回
// 写入一笔 kv 对到跳表. 如果 key 不存在,则为插入操作;如果 key 已存在则为覆盖操作
func (s *Skiplist) Put(key, value []byte) {
// 倘若 key 已存在
if node := s.getNode(key); node != nil {
// 根据新老 value dif 值,调整 skiplist 数据量 size 大小
s.size += (len(value) - len(node.value))
// 覆盖之
node.value = value
return
}
// key 不存在,则为插入行为. 在跳表 size 基础上加上 key 和 value 的大小
s.size += (len(key) + len(value))
s.entrisCnt++
// roll 出新节点高度
newNodeHeight := s.roll()
// 倘若跳表原高度不足,则补齐高度
if len(s.head.nexts) < newNodeHeight {
dif := make([]*skipNode, newNodeHeight+1-len(s.head.nexts))
s.head.nexts = append(s.head.nexts, dif...)
}
// 构造新节点
newNode := skipNode{
nexts: make([]*skipNode, newNodeHeight),
key: key,
value: value,
}
// 层数自高向低,每层按序插入节点
move := s.head
for level := newNodeHeight - 1; level >= 0; level-- {
// 层内持续向右遍历,直到右侧节点不存在或者 key 值更大
for move.nexts[level] != nil && bytes.Compare(move.nexts[level].key, key) < 0 {
move = move.nexts[level]
}
// 插入节点
newNode.nexts[level] = move.nexts[level]
move.nexts[level] = &newNode
}
}
// roll 出一个节点的高度. 最小为 1,每提高 1 层,概率减少为 1//2
func (s *Skiplist) roll() int {
var level int
rander := rand.New(rand.NewSource(time.Now().Unix()))
for rander.Intn(2) == 1 {
level++
}
return level + 1
}
3 wal 实现
聊完跳表,接下来看一下预写日志 wal 的实现. wal 文件根据写入数据和读取数据的视角,拆分为 walWriter 和 walReader 两部分.
3.1 walWriter
用于写 wal 文件的 walWriter 类定义如下:
// 预写日志写入口
type WALWriter struct {
file string // 预写日志文件名,是包含了目录在内的绝对路径
dest *os.File // 预写日志文件
assistBuffer [30]byte // 辅助转移数据使用的临时缓冲区
}
// 构造器
func NewWALWriter(file string) (*WALWriter, error) {
// 打开 wal 文件,如果文件不存在则进行创建
dest, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
return &WALWriter{
file: file,
dest: dest,
}, nil
}
写入 wal 文件的数据也是以 kv 对的形式存在. 为了能够找到 kv 对之间的间隔,采用【key长度】-【value长度】-【key内容】-【value内容】的形式完成内容的存储,读取时也采用同样的协议解析即可.
// 写入一笔 kv 对到 wal 文件中
func (w *WALWriter) Write(key, value []byte) error {
// 首先将key 和 value 长度填充到临时缓冲区 assistBuffer 中
n := binary.PutUvarint(w.assistBuffer[0:], uint64(len(key)))
n += binary.PutUvarint(w.assistBuffer[n:], uint64(len(value)))
// 依次将 key 长度、val 长度、key、val 填充到 buf 中
var buf []byte
buf = append(buf, w.assistBuffer[:n]...)
buf = append(buf, key...)
buf = append(buf, value...)
// 将以上内容写入到 wal 文件中
_, err := w.dest.Write(buf)
return err
}
func (w *WALWriter) Close() {
_ = w.dest.Close()
}
3.2 walReader
用于读 wal 文件的 walReader 类定义如下:
// wal 文件读取器
type WALReader struct {
file string // 预写日志文件名,是包含了目录在内的绝对路径
src *os.File // 预写日志文件
reader *bufio.Reader // 基于 bufio reader 对日志文件的封装
}
// 构造器函数.
func NewWALReader(file string) (*WALReader, error) {
// 以只读模式打开 wal 文件,要求目标文件必须存在
src, err := os.OpenFile(file, os.O_RDONLY, 0644)
if err != nil {
return nil, err
}
return &WALReader{
file: file,
src: src,
reader: bufio.NewReader(src),
}, nil
}
一个 wal 文件对应一个 memtable 的全量数据.
RestoreToMemtable 方法用于读取 wal 文件的全量内容,将所有 kv 对写入到 memtable 当中. 遵循数据格式,每次采用【key长度】-【value长度】-【key内容】-【value内容】的协议完成一组 kv 数据的读取,直到 wal 文件内容读完即可.
// 读取 wal 文件,将所有内容注入到 memtable 中,以实现内存数据的复原
func (w *WALReader) RestoreToMemtable(memTable memtable.MemTable) error {
// 读取 wal 文件全量内容
body, err := io.ReadAll(w.reader)
if err != nil {
return err
}
// 兜底保证文件偏移量被重置到起始位置
defer func() {
_, _ = w.src.Seek(0, io.SeekStart)
}()
// 将文件中读取到的内容解析成一系列 kv 对
kvs, err := w.readAll(bytes.NewReader(body))
if err != nil {
return err
}
// 将所有 kv 数据注入到 memtable 中
for _, kv := range kvs {
memTable.Put(kv.Key, kv.Value)
}
return nil
}
// 将文件中读到的原始内容解析成一系列 kv 对数据
func (w *WALReader) readAll(reader *bytes.Reader) ([]*memtable.KV, error) {
var kvs []*memtable.KV
// 循环读取每组 kv 对,直到遇到 eof 错误才终止流程
for {
// 从 reader 中读取首个 uint64 作为 key 长度
keyLen, err := binary.ReadUvarint(reader)
// 如果遇到 eof 错误说明文件内容已经读取完毕,终止流程
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
// 从 reader 中读取下一个 uint64 作为 val 长度
valLen, err := binary.ReadUvarint(reader)
if err != nil {
return nil, err
}
// 从 reader 中读取对应于 key 长度的字节数作为 key
keyBuf := make([]byte, keyLen)
if _, err = io.ReadFull(reader, keyBuf); err != nil {
return nil, err
}
// 从 reader 中读取对应于 val 长度的字节数作为 val
valBuf := make([]byte, valLen)
if _, err = io.ReadFull(reader, valBuf); err != nil {
return nil, err
}
kvs = append(kvs, &memtable.KV{
Key: keyBuf,
Value: valBuf,
})
}
return kvs, nil
}
4 memtable&wal 流程串联
接下来,我们从 lsm tree 整体视角出发,梳理一下有关于 memtable 和 wal 模块的交互流程.
4.1 写流程
在 lsm tree 的写流程中,需要先将 kv 对数据写入预写日志中,再将 kv 对数据写入读写 memtable.
// 写入一组 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)
// ...
return nil
}
在写流程结束前,需要校验一下读写 memtable 中的数据量大小是否达到溢写落盘的阈值,是的话,需要通过 refreshMemTableLocked 方法完成 memtable 的模式切换:
• 将 memtable 由读写模式切换为只读模式,然后通过 memCompactC 通道将其传递给 compact 协程进行溢写处理
• 构建一个新的 memtable 用于处理写请求,并构造与之相应的 wal 文件
// 切换读写跳表为只读跳表,并构建新的读写跳表
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()
}
func (t *Tree) newMemTable() {
t.walWriter, _ = wal.NewWALWriter(t.walFile())
t.memTable = t.conf.MemTableConstructor()
}
4.2 落盘流程
golsm 专门开启了一个 compact 协程用于处理 memtable 溢写落盘的流程. 在接收到待落盘的read-only memtable 后,会通过 flushMemTable 方法将其组织成一个 sstable 文件溢写到 level0 中,并从 lsm tree 的 rOnlyMemTable 切片中移除相应 memtable.
// 运行 compact 协程.
func (t *Tree) compact() {
for {
select {
// ...
// 接收到 read-only memtable,需要将其溢写到磁盘成为 level0 层 sstable 文件.
case memCompactItem := <-t.memCompactC:
t.compactMemTable(memCompactItem)
// ...
}
}
// 将只读 memtable 溢写落盘成为 level0 层 sstable 文件
func (t *Tree) compactMemTable(memCompactItem *memTableCompactItem) {
// 处理 memtable 溢写工作:
// 1 memtable 溢写到 0 层 sstable 中
t.flushMemTable(memCompactItem.memTable)
// 2 从 rOnly slice 中回收对应的 table
t.dataLock.Lock()
for i := 0; i < len(t.rOnlyMemTable); i++ {
if t.rOnlyMemTable[i].memTable != memCompactItem.memTable {
continue
}
t.rOnlyMemTable = t.rOnlyMemTable[i+1:]
}
t.dataLock.Unlock()
// 3 删除相应的预写日志. 因为 memtable 落盘后数据已经安全,不存在丢失风险
_ = os.Remove(memCompactItem.walFile)
}
4.3 复原流程
最后是 lsm tree 重启时,通过已有的 wal 文件还原出一系列 memtable 的复原流程.
// 构建出一棵 lsm tree
func NewTree(conf *Config) (*Tree, error) {
// ...
// 读取 wal 还原出 memtable
if err := t.constructMemtable(); err != nil {
return nil, err
}
// ...
}
复原流程入口方法是 constructMemtable,其中会读取所有 wal 文件,然后调用 restoreMemTable 方法将其一一还原成内存中的 memtable.
// 读取 wal 还原出 memtable
func (t *Tree) constructMemtable() error {
// 1 读 wal 目录,获取所有的 wal 文件
raw, _ := os.ReadDir(path.Join(t.conf.Dir, "walfile"))
// 2 wal 文件除杂
var wals []fs.DirEntry
for _, entry := range raw {
if entry.IsDir() {
continue
}
// 要求文件必须为 .wal 类型
if !strings.HasSuffix(entry.Name(), ".wal") {
continue
}
wals = append(wals, entry)
}
// 3 倘若 wal 目录不存在或者 wal 文件不存在,则构造一个新的 memtable
if len(wals) == 0 {
t.newMemTable()
return nil
}
// 4 依次还原 memtable. 最晚一个 memtable 作为读写 memtable
// 前置 memtable 作为只读 memtable,分别添加到内存 slice 和 channel 中.
return t.restoreMemTable(wals)
}
值得一提的是,复原流程会根据 wal 文件创建的先后顺序,将最后一个 memtable 作为提供读写能力的 active memtable,其余则作为只读的 read-only memtable 并继续推进其溢写落盘的流程.
// 基于 wal 文件还原出一系列只读 memtable 和唯一一个读写 memtable
func (t *Tree) restoreMemTable(wals []fs.DirEntry) error {
// 1 wal 排序,index 单调递增,数据实时性也随之单调递增
sort.Slice(wals, func(i, j int) bool {
indexI := walFileToMemTableIndex(wals[i].Name())
indexJ := walFileToMemTableIndex(wals[j].Name())
return indexI < indexJ
})
// 2 依次还原 memtable,添加到内存和 channel
for i := 0; i < len(wals); i++ {
name := wals[i].Name()
file := path.Join(t.conf.Dir, "walfile", name)
// 构建与 wal 文件对应的 walReader
walReader, err := wal.NewWALReader(file)
if err != nil {
return err
}
defer walReader.Close()
// 通过 reader 读取 wal 文件内容,将数据注入到 memtable 中
memtable := t.conf.MemTableConstructor()
if err = walReader.RestoreToMemtable(memtable); err != nil {
return err
}
if i == len(wals)-1 { // 倘若是最后一个 wal 文件,则 memtable 作为读写 memtable
t.memTable = memtable
t.memTableIndex = walFileToMemTableIndex(name)
t.walWriter, _ = wal.NewWALWriter(file)
} else { // memtable 作为只读 memtable,需要追加到只读 slice 以及 channel 中,继续推进完成溢写落盘流程
memTableCompactItem := memTableCompactItem{
walFile: file,
memTable: memtable,
}
t.rOnlyMemTable = append(t.rOnlyMemTable, &memTableCompactItem)
t.memCompactC <- &memTableCompactItem
}
}
return nil
}
5 小结
至此,本文内容结束. 本文和大家展开探讨了 lsm tree 中 memtable 和 wal 模块的底层实现细节. 在此展望未来几周继续和大家展开讨论的内容:
• 基于go实现lsm tree 之主干框架(已完成)
• 基于go实现lsm tree之memtable结构(本篇)
• 基于go实现lsm tree之sstable结构(待完成)
• 基于go实现lsm tree之level sorted merge流程(待完成)