使用go实现LSM Tree (3) - compaction

232 阅读8分钟

使用go实现LSM Tree (3) - compaction


之前我们已经实现的LSM树在在磁盘、内存的基本单元结构,现在我们来将这些基本单元来构成实际的树,及实现树节点的压缩合并。 LSM中压缩分两种:

  • minor compaction:内存数据持久化,一次minor compaction会产生一个0层SSTable文件,多个SSTable文件间数据有重叠
    • 仅有minor compaction时,0层存在大量分散的有重叠的SSTable文件,一次查询可能需要读取全部文件
  • merging compaction/major compaction:为了提高查询效率,将0层多个SSTable归并为1层没有数据重叠的SSTable文件,当仅有0层、1层时,随数据量增加每次1层要合并的文件会越来越多,为降低每次compaction的io开销需要继续分层

首先定义一个树的节点:

  • 一个节点代表一个SSTable文件,对应一个SstReader
    • 文件名为level_seqNo_extra.sst
      • level 为节点位于树的第几层(从0开始)
      • seqNo 为文件在该层的序号,单调增加,越新的文件序号越大
      • extra 一些文件额外信息,在我们的实现中会额外保存一个raft状态机的信息,单独在LSM树中无作用
  • 节点缓存SSTable对应的布隆过滤器、索引的内存表现形式,启动时读取到内存,以便在查询时直接使用不用在重新读取磁盘
type Node struct {
	wg         sync.WaitGroup    // 等待外部读取文件完成
	sr         *SstReader        // sst文件读取
	filter     map[uint64][]byte // 布隆过滤器
	startKey   []byte            // 起始键
	endKey     []byte            // 结束键
	index      []*Index          // 索引数组
	Level      int               //lsm层
	SeqNo      int               // lsm 节点序号
	Extra      string            // 文件额外信息,手动添加
	FileSize   int64             //文件大小
	compacting bool              //已在合并处理
	// 遍历节点数据用
	curBlock int           // 当前读取块
	curBuf   *bytes.Buffer // 当前读取到缓冲
	prevKey  []byte        // 前次读取到键
	logger   *zap.SugaredLogger
}

基于节点定义LSM树如下:

  • 使用二维切片来保存树结构
  • LSM树中会出现重复数据,读取数据时以新的数据为准,层数越低的文件数据越新,各层编号越新的文件数据也越新
type Tree struct {
	mu      sync.RWMutex
	conf    *Config
	tree    [][]*Node     // lsm
	seqNo   []int         // 各层sst文件最新序号
	compacc chan int      // 合并通知通道
	stopc   chan struct{} // 停止通知通道
	logger  *zap.SugaredLogger
}

然后我们来实现minor compaction,创建树第0层的节点,将内存的immutable memtable转换为SSTable文件

  • 我们的memtable由跳表实现,SSTable转换过程可视为遍历跳表将键值对写入SstWriter;
  • 每次写入都会重新创建一个新文件,文件名中的序号单调增加;
  • 文件写入完成后将其作为树0层的节点加入到树中,
    • 0层,按序列号,将节点插入到切片对应位置;
    • 其他层,按节点起始key,插入到切片对应位置;
  • 添加到树后通知检查0层节点数量是否已达到阈值;
func (t *Tree) FlushRecord(sl *skiplist.SkipListIter, extra string) error {
	level := 0
	seqNo := t.NextSeqNo(level)

	file := formatName(level, seqNo, extra)
	w, err := NewSstWriter(file, t.conf, t.logger)
	if err != nil {
		return fmt.Errorf("创建sst writer失败: %v", err)
	}
	defer w.Close()

	// 遍历跳表,将键值对写入sst文件
	for sl.Next() {
		w.Append(sl.Key, sl.Value)
	}

	// 完成写入
	size, filter, index := w.Finish()

	// 添加节点到内存lsm
	node, err := NewNode(level, seqNo, extra, file, size, filter, index, t.conf)
	if err != nil {
		return fmt.Errorf("创建lsm节点失败: %v", err)
	}
	t.insertNode(node)
	// 检查level是否合并
	t.compacc <- level

	return nil
}

func (t *Tree) insertNode(node *Node) {
	t.mu.Lock()
	defer t.mu.Unlock()

	// 按序号将节点插入合适位置
	level := node.Level
	length := len(t.tree[level])
	if length == 0 {
		t.tree[level] = []*Node{node}
		return
	}

	if level == 0 {
		idx := length - 1
		for ; idx >= 0; idx-- {
			if node.SeqNo > t.tree[level][idx].SeqNo {
				break
			} else if node.SeqNo == t.tree[level][idx].SeqNo {
				t.tree[level][idx] = node
				return
			}
		}
		t.tree[level] = append(t.tree[level][:idx+1], t.tree[level][idx:]...)
		t.tree[level][idx+1] = node
	} else {
		for i, n := range t.tree[level] {
			cmp := bytes.Compare(n.startKey, node.startKey)
			if cmp < 0 {
				t.tree[level] = append(t.tree[level][:i+1], t.tree[level][i:]...)
				t.tree[level][i] = node
				return
			}
		}
		t.tree[level] = append(t.tree[level], node)
	}
}

接下来我们来实现major compaction

  • 在每次0层写入SSTable文件,检查0层文件数量是否达到阈值,
  • 0层合并后会写入下一层,每次合并后检查下一层数据大小是否达到阈值
  • 数据合并时会取当前层的部分节点和下一层数据有重叠的节点,对全部数据排序去重,重新写入下一层,如数据大小超过下一层SSTable最大大小,则将其写入多个SSTable文件

实现合并节点挑选方法,选取需合并数据的节点

  • 0层,每次选择将最旧的节点为检查条件, 其他层选择将前一半的节点为检查条件
  • 统计得到检查条中键的起始值、结束值,遍历下一层及当前层节点,找出值域有交叉的节点,并使用下一层交叉节点的起始键/结束键扩大检查条件
  • 节点加入切片后,各节点数据新旧有序(低层数据在后,0层数据节点序列号较大的在后),切片中下标越大的节点值越新
func (t *Tree) PickupCompactionNode(level int) []*Node {
	t.mu.Lock()
	defer t.mu.Unlock()

	compactionNode := make([]*Node, 0)

	if len(t.tree[level]) == 0 {
		return compactionNode
	}

	// 第0层 ,第一个节点起始,结束键作为检查条件
	startKey := t.tree[level][0].startKey
	endKey := t.tree[level][0].endKey
	// 其他层,取前半节点起始,结束键作为检查条件
	if level != 0 {
		node := t.tree[level][(len(t.tree[level])-1)/2]
		if bytes.Compare(node.startKey, startKey) < 0 {
			startKey = node.startKey
		}
		if bytes.Compare(node.endKey, endKey) > 0 {
			endKey = node.endKey
		}
	}

	// 检查与当前层、下一层有数据交叉的节点
	for i := level + 1; i >= level; i-- {
		for _, node := range t.tree[i] {
			if node.index == nil {
				continue
			}
			nodeStartKey := node.index[0].Key
			nodeEndKey := node.index[len(node.index)-1].Key
			if bytes.Compare(startKey, nodeEndKey) <= 0 && bytes.Compare(endKey, nodeStartKey) >= 0 && !node.compacting {
				compactionNode = append(compactionNode, node)
				node.compacting = true
				if i == level+1 {
					if bytes.Compare(nodeStartKey, startKey) < 0 {
						startKey = nodeStartKey
					}
					if bytes.Compare(nodeEndKey, endKey) > 0 {
						endKey = nodeEndKey
					}
				}
			}
		}
	}
	return compactionNode
}

现在我们得到了了多个节点,每个节点中数据有序,可以使用归并排序对数据排序,每次取各节点中最小值得最小值,写入新节点。 定义一个链表,用来排序各节点最小值,最小值排序使用插入排序方式。

type Record struct {
	Key   []byte  // 键
	Value []byte  // 值
	Idx   int     // 数据来源数组下标
	next  *Record // 下一数据位置
}

实现添加键值对到链表方法,

  • 出现相同键的数据时,依据节点的层数、序列号判断保留哪条数据,层数越低数据越新,同层中序列号越大数据越新,在选取合并节点时已按前述规则排序,在切片中下标越大的节点数据越新,添加键值对时传入键值对来源节点在切片的下标,表示值得新旧程度
  • 键不同时按SSTable中键的比较规则,进行排序插入当前链表
func (r *Record) push(key, value []byte, idx int) (*Record, int) {
	h := r
	cur := r
	var prev *Record
	for {
		if cur == nil {
			// 添加数据到链表尾
			if prev != nil {
				prev.next = &Record{Key: key, Value: value, Idx: idx}
			} else { // 添加数据到链表头
				h = &Record{Key: key, Value: value, Idx: idx}
			}
			break
		}

		cmp := bytes.Compare(key, cur.Key)
		// 链表存在相同键数据,保留更新来源数据
		if cmp == 0 {
			// 节点按旧到新排序,下标更大表示数据更新
			if idx >= r.Idx {
				old := cur.Idx
				cur.Key = key
				cur.Value = value
				cur.Idx = idx
				return h, old
			} else {
				return h, idx
			}
		} else if cmp < 0 { // 新增键小于当前位置键,已找到目标位置插入
			if prev != nil {
				prev.next = &Record{Key: key, Value: value, Idx: idx, next: cur}
			} else {
				h = &Record{Key: key, Value: value, Idx: idx, next: cur}
			}
			break
		} else { // 新增键大于当前位置键,继续查找
			prev = cur
			cur = cur.next
		}
	}
	return h, -1
}

实现从待合并节点切片指定位置读取数据,加入排序链表

  • 从指定节点每次读入一条数据,插入到链表
  • 当新键在链表中已存在,且旧键被替换时,被替换的键值对所在节点需再读入一条数据,包装链表中存在所以待合并节点的数据,直到某个节点数据已读取完毕
func (r *Record) Fill(source []*Node, idx int) *Record {
	record := r
	// 读取节点数据
	k, v := source[idx].nextRecord()
	if k != nil {
		// 添加数据到链表
		record, idx = record.push(k, v, idx)

		//	如存在键被替换,重新填充被替换来源数据
		for idx > -1 {
			k, v := source[idx].nextRecord()
			if k != nil {
				record, idx = record.push(k, v, idx)
			} else {
				idx = -1
			}
		}
	}
	return record
}

实现从指定节点遍历键值对方法

  • 当数据块未读入内存,从SSTable文件读取块解码存到到节点缓冲
  • 从块缓冲解码一条键值对(当缓冲读取完成递归调用本方法读取数据),返回调用方
func (n *Node) nextRecord() ([]byte, []byte) {
	// 当前缓冲数据为空,加载数据
	if n.curBuf == nil {
		// 读取完成
		if n.curBlock > len(n.index)-1 {
			return nil, nil
		}

		// 读取数据块
		data, err := n.sr.ReadBlock(n.index[n.curBlock].Offset, n.index[n.curBlock].Size)
		if err != nil {
			if err != io.EOF {
				n.logger.Errorf("%s 读取data block失败: %v", n.Level, n.SeqNo, err)
			}
			return nil, nil
		}

		// 解析记录缓冲,更新相关属性
		record, _ := DecodeBlock(data)
		n.curBuf = bytes.NewBuffer(record)
		n.prevKey = make([]byte, 0)
		n.curBlock++
	}

	// 读取记录
	key, value, err := ReadRecord(n.prevKey, n.curBuf)
	if err == nil {
		n.prevKey = key
		return key, value
	}

	if err != io.EOF {
		n.logger.Errorf("%s 读取记录失败: %v", n.Level, n.SeqNo, err)
		return nil, nil
	}

	// 当前缓冲读取完成,加载下一缓冲
	n.curBuf = nil
	return n.nextRecord()
}

实现节点合并方法

  • 按当前层,选取待合并节点
  • 在下一层创建一个新的SSTable Writer,准备写入合并后数据
  • 创建一个有序链表用来归并各节点数据
    • 从各节点取出最小数据,插入链表
    • 循环从链表取出最小值,写入SSTable,再从最小值的来源节点补一条数据到链表,继续循环
      • 当前SSTable文件写满后,完成当前文件的写入,将其作为一个节点加入LSM树,再创建一个新的SSTable Writer写入后续数据
  • 当前层合并完成后,移除已合并的节点,删除对应文件,再检查下一层是否需合并
func (t *Tree) compaction(level int) error {
	// 获取需合并节点
	nodes := t.PickupCompactionNode(level)
	lenNodes := len(nodes)
	if lenNodes == 0 {
		return nil
	}

	// 创建新节点写入
	nextLevel := level + 1
	seqNo := t.NextSeqNo(nextLevel)
	extra := nodes[lenNodes-1].Extra
	file := formatName(nextLevel, seqNo, extra)
	writer, err := NewSstWriter(file, t.conf, t.logger)

	if err != nil {
		t.logger.Errorf("%s 创建writer失败,无法合并lsm日志:%v", file, err)
		return err
	}

	var record *Record
	var files string
	maxNodeSize := t.conf.SstSize * int(math.Pow10(nextLevel))

	// 从各节点填充数据到链表
	for i, node := range nodes {
		files += fmt.Sprintf("%d_%d_%s.sst ", node.Level, node.SeqNo, node.Extra)
		record = record.Fill(nodes, i)
	}
	t.logger.Debugf("合并: %v", files)

	// 遍历链表归并节点数据到新节点
	for record != nil {
		writeCount++
		// 写入数据
		i := record.Idx
		writer.Append(record.Key, record.Value)
		// 从消费了数据的节点填充数据
		record = record.next.Fill(nodes, i)

		// 节点数据大于当前层节点大小,完成节点节点,新建节点再写入
		if writer.Size() > maxNodeSize {
			size, filter, index := writer.Finish()
			writer.Close()
			// 添加新节点到树
			node, err := NewNode(nextLevel, seqNo, extra, file, size, filter, index, t.conf)
			if err != nil {
				return fmt.Errorf("创建lsm节点失败: %v", err)
			}
			t.insertNode(node)
			// 创建新节点写入
			seqNo = t.NextSeqNo(nextLevel)
			file = formatName(nextLevel, seqNo, extra)
			writer, err = NewSstWriter(file, t.conf, t.logger)
			if err != nil {
				t.logger.Errorf("%s 创建writer失败,无法合并lsm日志:%v", file, err)
				return err
			}
		}
	}

	//完成节点写入
	size, filter, index := writer.Finish()
	// 添加到树
	node, err := NewNode(nextLevel, seqNo, extra, file, size, filter, index, t.conf)
	if err != nil {
		return fmt.Errorf("创建lsm节点失败: %v", err)
	}
	t.insertNode(node)
	t.removeNode(nodes)
	// 检查是否继续合并
	t.compacc <- nextLevel

	return nil
}

func (t *Tree) removeNode(nodes []*Node) {
	t.mu.Lock()
	defer t.mu.Unlock()
	for _, node := range nodes {
		t.logger.Debugf("移除: %d_%d_%s.sst ", node.Level, node.SeqNo, node.Extra)
		for i, tn := range t.tree[node.Level] {
			if tn.SeqNo == node.SeqNo {
				t.tree[node.Level] = append(t.tree[node.Level][:i], t.tree[node.Level][i+1:]...)
				break
			}
		}
	}
	go func() {
		for _, n := range nodes {
			n.destory()
		}
	}()
}

合并压缩使用单独协程完成,防止阻塞主协程写入数据

  • 0层依据节点数量判断阈值
  • 其他层计算文件总大小,判断阈值
func (t *Tree) CheckCompaction() {
	level0 := make(chan struct{}, 100)
	levelN := make(chan int, 100)

	// 第0层合并协程
	go func() {
		for {
			select {
			case <-level0:
				if len(t.tree[0]) > 4 {
					t.logger.Infof("Level 0 执行合并, 当前数量: %d", len(t.tree[0]))
					t.compaction(0)
				}
			case <-t.stopc:
				close(level0)
				return
			}
		}
	}()

	// 非0层合并协程
	go func() {
		for {
			select {
			case lv := <-levelN:
				var prevSize int64
				maxNodeSize := int64(t.conf.SstSize * int(math.Pow10(lv+1)))
				for {
					var totalSize int64
					for _, node := range t.tree[lv] {
						totalSize += node.FileSize
					}
					if totalSize > maxNodeSize && (prevSize == 0 || totalSize < prevSize) {
						t.compaction(lv)
						prevSize = totalSize
					} else {
						break
					}
				}
			case <-t.stopc:
				close(levelN)
				return
			}
		}
	}()

	// 合并通知处理
	go func() {
		for {
			select {
			case <-t.stopc:
				return
			case lv := <-t.compacc:
				if lv == 0 {
					level0 <- struct{}{}
				} else {
					levelN <- lv
				}
			}
		}
	}()
}

添加新建函数,创建LSM树实例,树创建后启动协程等待合并压缩任务

func NewTree(conf *Config) *Tree {
	compactionChan := make(chan int, 100)
	levelTree := make([][]*Node, conf.MaxLevel)

	for i := range levelTree {
		levelTree[i] = make([]*Node, 0)
	}

	seqNos := make([]int, conf.MaxLevel)
	lt := &Tree{
		conf:    conf,
		tree:    levelTree,
		seqNo:   seqNos,
		compacc: compactionChan,
		stopc:   make(chan struct{}),
		logger:  conf.Logger,
	}

	lt.CheckCompaction()
	return lt
}

最后实现LSM树的还原函数,从磁盘文件重新构成内存中的树

func RestoreTree(conf *Config) (*Tree, error) {
	lt := NewTree(conf)
	callbacks := []func(string, fs.FileInfo){
		func(name string, fileInfo fs.FileInfo) {
			err := lt.LoadNode(name)
			if err != nil {
				conf.Logger.Errorf("加载文件%s 到lsm树失败: %v", name, err)
			}
		},
	}

	if err := utils.CheckDir(conf.Dir, callbacks); err != nil {
		return lt, fmt.Errorf("还原LSM Tree状态失败: %v", err)
	}

	return lt, nil
}

func RestoreNode(level, seqNo int, extra string, file string, conf *Config) (*Node, error) {

	r, err := NewSstReader(file, conf)
	if err != nil {
		return nil, fmt.Errorf("%s 创建sst Reader: %v", file, err)
	}

	filter, err := r.ReadFilter()
	if err != nil {
		r.Close()
		return nil, fmt.Errorf("%s 读取过滤块失败: %v ", file, err)
	}

	index, err := r.ReadIndex()
	if err != nil {
		r.Close()
		return nil, fmt.Errorf("%s 读取索引失败: %v ", file, err)
	}

	return &Node{
		sr:       r,
		Level:    level,
		SeqNo:    seqNo,
		Extra:    extra,
		curBlock: 1,
		FileSize: r.IndexOffset + r.IndexSize + int64(conf.SstFooterSize),
		filter:   filter,
		index:    index,
		startKey: index[0].Key,
		endKey:   index[len(index)-1].Key,
		logger:   conf.Logger,
	}, nil
}

完整代码

参考: