LevelDB
架构
LevelDB的架构是什么?
LevelDB 将数据分为两大部分,分别存放在内存和文件系统中。主要数据模块包括 WAL log,memtable,immutable memtable,sstable。
LevelDB的写入流程是什么?
按照数据流向依次如下:
- 当 LevelDB 收到一个写入请求 put(k, v) ,会首先将其操作日志追加到日志文件(WAL)中,以备节点意外宕机恢复。
- 写完 WAL 后,LevelDB 将该条 kv 插入内存中的查找结构:memtable。
- 在 memtable 积累到一定程度后,会 rotate 为一个只读 memtable,即 immutable memtable;同时生成一个新的 memtable 以供写入。
- 当内存有压力后,会将 immutable memtable 顺序写入文件系统,生成一个 level0 层的 sstable(sorted strings table) 文件。该过程称为一次 minor compaction。
- 由于查询操作需要按层次遍历 memtable、immutable 和 sstable。当 sstable 文件生成的越来越多之后,查询性能必然越来越差,因此需要将不同的 sstable 进行归并,称为 major compaction。
memtable
多线程并发跳表操作
LevelDB读写memtable需要加锁吗?
LevelDB 中对 SkipList 的实现增加了多线程并发访问方面的优化的代码,提供以下保证:
Write:在修改跳表时,需要在用户代码侧加锁。
Read:在访问跳表(查找、遍历)时,只需保证跳表不被其他线程销毁即可,不必额外加锁。
这是因为在实现时,LevelDB 做了以下假设(Invariants):
除非跳表被销毁,跳表节点只会增加而不会被删除,因为跳表对外根本不提供删除接口。
被插入到跳表中的节点,除了 next 指针其他域都是不可变的,并且只有插入操作会改变跳表。
跳表的接口有三个:插入、查询和遍历
查找算法
如果参数 prev 不为空,在查找过程中,记下待查找节点在各层中的前驱节点。显然,如果查找操作,则指定 prev = nullptr 即可;若要插入数据,则需传入一个合适尺寸的 prev 参数。
template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node*
SkipList<Key, Comparator>::FindGreaterOrEqual(const Key& key, Node** prev) const {
Node* x = head_; // 从头结点开始查找
int level = GetMaxHeight() - 1; // 从最高层开始查找
while (true) {
Node* next = x->Next(level); // 该层中下一个节点
if (KeyIsAfterNode(key, next)) {
x = next; // 待查找 key 比 next 大,则在该层继续查找
} else {
if (prev != nullptr) prev[level] = x;
if (level == 0) { // 待查找 key 不大于 next,则到底返回
return next;
} else { // 待查找 key 不大于 next,且没到底,则往下查找
level--;
}
}
}
}
sst 和 bloom filter
levelDB是怎么减少读放大/写放大的?
为了减小读放大,LevelDB 采取了几方面措施:
- 通过 major compaction 尽量减少 sstable 文件
- 使用快速筛选的办法,快速判断 key 是否在某个 sstable 文件中
bloom filter的参数应该如何设计?
bloom filter误判率和以下参数有关:
- 哈希函数的个数 k
- 底层位数组的长度 m
- 数据集大小 n
当 k = ln2 * (m/n) 时,Bloom Filter 获取最优的准确率
LRU缓存
LevelDB是怎么加速sst访问的?
lru主要是为了加速对于sst上block的访问。
entry状态机
双链表
LRU缓存中有几种链表?分别作用是什么?
- in-use 链表。所有正在被客户端使用的数据条目(an kv item)都存在该链表中,该链表是无序的,因为在容量不够时,此链表中的条目是一定不能够被驱逐的,因此也并不需要维持一个驱逐顺序。
- lru 链表。所有已经不再为客户端使用的条目都放在 lru 链表中,该链表按最近使用时间有序,当容量不够用时,会驱逐此链表中最久没有被使用的条目。
RocksDB
架构
rocksdb的整体架构是什么样的?
compaction流程和优化
STCS和LCS
**Size-Tiered Compaction Strategy (STCS) **
memtable 逐步刷入到磁盘 sst,刚开始 sst 都是小文件,随着小文件越来越多,当数据量达到一定阈值时,STCS 策略会将这些小文件 compaction 成一个中等大小的新文件。同样的道理,当中等文件数量达到一定阈值,这些文件将被 compaction 成大文件,这种方式不断递归,会持续生成越来越大的文件。总的来说,STCS 就是将 sst 按大小分类,相似大小的 sst 分在同一类,然后将多个同类的 sst 合并到下一个类别。通过这种方式,可以有效减少 sst 的数量。
由于 STCS 策略比较简单,同一份数据在 compaction 期间拷贝的次数相对较少,即写入放大相对小,很多基于 LSM-Tree 的系统将其作为默认的 compaction 策略,如 Lucene、Cassandra、Scylla 等。STCS 逻辑简单、写入放大低,但是它也有很大的缺陷 – 空间放大。其实也存在较大的读放大
为什么会产生空间放大呢?compaction 的过程中,参与 compaction 的 sst 不能立马删除,直到新生成的 sst 写入完毕,这里其实还有一个原因,如果老的 sst 有读操作,由于文件还被引用,也是不能立即删除的。因此,在 compaction 的过程中,磁盘上新老文件共存,产生临时空间放大。即使这种空间放大是临时的,但是对于系统来说,不得不使用比实际数据量更大的磁盘空间,以保证 compaction 正常执行,这产生的代价很昂贵。
这也就是上面一节提到的那个问题。
**Leveled Compaction Strategy (LCS) **
- sst 的大小可控,默认每个 sst 的大小一致(STCS 在经过多次合并后,层级越深,产生的 sst 文件就越大,最终会形成超大文件)
- LCS 在合并时,会保证除 Level 0(L0)之外的其他 Level 有序且无覆盖
- 除 L0 外,每层文件的总大小呈指数增长,假如 L1 最多 10 个,则 L2 为 100 个,L3 为 1000 个…
首先是内存中的 memtable 刷到 L0,当 L0 中的文件数达到一定阈值后,会将 L0 的所有文件及与 L1 有覆盖的文件做合并,然后生成新文件(如果文件大小超过阈值,会切成多个)到 L1,L1 中的文件时全局有序的,不会出现重叠的情况;
当 L1 的文件数量达到阈值时,会选取 L1 中的一个 sst 与 L2 中的多个文件做合并,假设 L1 有 10 个文件,那么一个文件便占 L1 数据量的 1/10,假设每层包含的 key 范围相同,那么 L1 中的一个文件理论上会覆盖 L2 层的 10 个文件,因此会选取 L1 中的一个文件与 L2 中的 10 个文件一起 compaction,将生成的新文件放到 L2;
LCS 不会有超大文件,而且在层与层之间合并时,大体上只选取 11 个 sst 进行 compaction,而这 11 个文件的大小只占整个系统的小部分,因此临时空间放大很小。
LCS 最好的情况是,最后一层数据已经被填满。假设最后一层为 L3,一共有 1000 个 sst。那么 L1 和 L2 一共最多 110 个文件。由于每个 sst 基本大小相同,因此,几乎 90% 的数据在 L3。我们直到每层内的数据不会重复,因此最多,L1 和 L2 的数据包含在 L3 中,重复数据导致的空间放大为 1/0.9=1.11,
相比STCS 8 倍的空间放大好得多。然后,也存在比较差的情况,即当最后一层没有填满时,假设 L3 的文件个数为 100 个,L2 中的文件数也是 100 个,最坏情况下,如果 L2 和 L3 的数据相同,则会产生 2 倍的空间放大。但即使是这样,相对 STCS 还是好得多。
LCS存在写放大问题,写放大指的是IO写带宽的放大
对于 STCS 来说,写入放大和层高强相关,而每层的数据量又是呈指数增长的(即第一层最多 400MB,第二层 1600MB,第三层6400MB,一次类推),很明显层高和数据量的关系为对数关系,而写入放大和层高一致,因此写入放大随数据量增长为 O(logN),N 为数据量大小。
但是对于 LCS 来说,情况会更糟糕,我们来分析下原因:假设需要将 L1 层的数据量 X compaction 到下一层,STCS 的写如放大为 1。而 LCS 需要将这 X 的数据量和 L1 层 10倍 X 数据量,一共 11 * X 一起 compaction,写入放大为 11,是 STCS 的 11 倍。
如果大部分都是写新数据建议stcs 如果更新频繁那么选lcs
rocksdb的LCS(Leveled compaction)
rocksdb的lcs是默认的compaction策略,也可以说它综合了STCS(L0)和LCS(其它层)的策略。
levelDB/rocksDB的compaction流程是怎样的?怎么进行优化?
compaction分为minor和major compaction。
minor compaction / flush的基本流程如下:
1.遍历immutable-list,如果没有其它线程flush,则加入队列
2.通过迭代器逐一扫描key-value,将key-value写入到data-block
3.如果data block大小已经超过block_size(比如16k),或者已经key-value对是最后的一对,则触发一次block-flush
4.根据压缩算法对block进行压缩,并生成对应的index block记录(begin_key, last_key, offset)
5.至此若干个block已经写入文件,并为每个block生成了indexblock记录
6.写入index block,meta block,metaindex block以及footer信息到文件尾
7.将变化sst文件的元信息写入manifest文件
major compaction的主要流程如下:
1.首先找score最高的level,如果level的score>1,则选择从这个level进行compaction
2.根据一定的策略,从level中选择一个sst文件进行compact,对于level0,由于sst文件之间(minkey,maxkey)有重叠,所以可能有多个。
3.从level中选出的文件,我们能计算出(minkey,maxkey)
4.从level+1中选出与(minkey,maxkey)有重叠的sst文件
5.多个sst文件进行归并排序,合并写出到sst文件
6.根据压缩策略,对写出的sst文件进行压缩
7.合并结束后,利用VersionEdit更新VersionSet,更新统计信息
rocksdb的STCS
RocksDB中提供的stcs策略叫做universal compaction。
1.如果空间放大超过一定的比例,则所有sst进行一次compaction,所谓的full compaction,通过参数max_size_amplification_percent控制。
2.如果前size(R1)小于size(R2)在一定比例,默认1%,则与R1与R2一起进行compaction,如果(R1+R2)*(100+ratio)%100<R3,则将R3也加入到compaction任务中,依次顺序加入sst文件
3.如果第1和第2种情况都没有compaction,则强制选择前N个文件进行合并。
选择参与compaction的文件
如何选择参与compaction的文件?
- 根据每一层的分数选择要compaction的层{Li}以及输出的层{Lo}.打分的默认策略是选择文件size较大,包含delete记录较多的sst文件,这种文件尽快合并有利于缩小空间。
- 根据compaction优先级的设置选择优先级最高的文件。如果这个文件或者和他key范围重叠的下一层文件正在参与另一个compaction,那么就选择优先级第二高的文件,以此类推。将选中的文件加入compaction的输入文件集合(inputs set)。
- 重复第一步,直到选中文件的key范围与其周围sst文件的key范围严格不重叠。(这一步是对于minor来说的)
- 将input set的key范围与当前正在被compact的文件进行检查确保他们不重叠。如果重叠就终止这次compaction。
- 在{Lo}中找到与input set的key范围重叠的所有文件,并将其放入输出文件集合中(output level inputs set)
- 将inputs set与output level ivnputs set进行compaction。
优化compaction
- 优化长时间没有更新的值
如果一个文件长时间没有被更新,那么它就会一直常驻在系统中。一个常见的例子是一个key被设为空值而并不是真正的删除,随后也不会再有针对这个key的更新,那么这个key就会永久驻留,造成空间浪费。RocksDB中可以针对一个CF设置ttl时间,对所有旧于这个时间的数据调度一个compaction。
- 优化L0堆积问题
RocksDB引入了intra-L0 compaction,即在发生上述情况时在L0层内部进行compact,将多个sst file合并为1个大的sst file,以此减少L0层文件数量,在一定程度上能够提升L0层的读性能和减少write stall的发生。
- 并行compaction
Sub-Compaction的思想其实很简单,就是将一个compaction的任务划分成多个子任务,通过并行执行的方式提高compaction的速度。
Sub-Compaction触发的时机有如下几个:
-
对于Leveled-Compaction,如果触发compaction的是L0或者手动触发compaction,并且output level大于第0层,则构建sub-compaction。
-
对于Universal-Compaction,如果总共的层数大于1,且output level大于0,则构建sub-compaction。
-
用户手动compaction
通过begin和end两个参数来确定想要compact的key范围。在不同的compaction类型下这两个参数也有不同的效果。对于universal和fifo类型的compaction,不管begin和end是什么数值,都会进行全量的compaction,并把output的文件放到同一层。对于leveled compaction,会将所有包含符合条件的key(begin < key < end)的文件compact到非空的最后一层。
- 使用移动compaction
首先,这会造成很多没有被compact过的小文件(因为直接被移动了);其次因为同样的原因,compaction filter没办法作用在这些文件上。可以通过结合manual compaction的方式来手动compact解决问题。
- 使用远端compaction
Remote compaction分为下面几步:
Schedule:rocksdb会将通过回调的方式将compaction的信息(哪些文件参与,哪一层,什么key range之类的)发给远端compaction worker。
Compact:worker会以只读的方式拿到数据,并且将output文件放到临时目录里。
Result send:类似于前面的schedule,worker会将compact之后的元数据信息发回给rocksdb。
Install & Purge:rocksdb这个时候会将临时目录里的文件进行重命名,然后再进行purge,整个流程就结束了。
memtable
rocksdb的memtable结构有几种?
wal
rocksDB的wal文件格式是什么样的?
sst
rocksdb有哪些SST结构?对于leveldb的sst结构有什么改进?
第一种结构:每块的K-V都是有序的,而多块也是有序的。文件中包含元数据相关的信息,包括数据压缩字典、过滤器等。会按照数据块所属的K-V范围来创建索引,为提升查询性能会给索引分片。
另外一种结构是每个K-V来存储。它的索引比较特殊,由hash结构和二进制查找缓存两部分组成。依然按照key的前缀做hash,如果桶对应的K-V记录很少,则直接指向第一个key(有多个key属于该桶)的记录位置。如果属于桶的K-V记录多于16条,或者包含多于一个前缀的记录,则先指向二进制查找缓存(先二分查找),而后指向第一个key的记录位置。
改进:
Indexing SST 引入了四个下标指针,分别是下层比smallest大/小 和 largest大/小的第一个文件下标
优化方法汇总
- 使用LRU加速SST访问
- memtable – 哈希的memtable 列式SST(游程编码 字典压缩 位图编码 增量编码 ) – 哈希的列式SST
- bloom filter
- 优化compact(优化长时间未更新的值 L0内部compact 并行compact 用户手动compact 移动compact)
- 使用多个memtable而不是一个
其它问题
对比 b+ 树,lsm树
- 结构和特点:
- B+树是一种平衡树结构,用于实现有序的数据存储和检索。它具有良好的顺序访问性能和范围查询性能,适用于随机读写和范围查询的场景。
- LSM树(Log-Structured Merge-Tree)是一种基于日志结构的树状数据结构,将写操作追加到日志中,然后周期性地将日志合并成更大的文件,以优化写入性能。LSM树具有高写入性能和压缩优化,适用于写入密集型工作负载。
- 写入性能:
- B+树在每次写入时需要更新内部节点和叶子节点,因此写入操作的开销相对较高,特别是在频繁写入的场景下。
- 读取性能:
- B+树由于具有良好的有序性,对于范围查询和顺序访问具有较好的性能。在频繁的范围查询和顺序扫描场景下,B+树的读取性能较好。
- LSM树的读取性能相对较差,特别是在数据量较大时。由于数据分散在多个文件中,需要进行多次查找和合并操作,导致读取延迟较高。
- 空间利用和存储效率:
- B+树存储的数据较为紧凑,对于索引和元数据的存储效率较高。
- LSM树由于数据存储在多个文件中,可能存在较大的空间浪费和碎片化,但通过合并操作可以实现数据的压缩和优化。
如何减少写放大?
- kv分离
- 移动compaction/手动compaction
kv分离时value的存储方案?
- WiscKey:Value和WAL合并,叫做vLog形成环,GC时从后往前读
- HashKV:改进WiscKey,Value和WAL合并,通过一个固定的 hash function 分配到多个 segment 里面去,冷热分离,如果上次 GC 之后有两次 update 就算是热 key,冷数据单独写vLog
- Titan:顺序存放Value文件,提高范围读性能
如何优化cache?
- 降低元素移动频率
- threadlocal
如何优化delete?
- 提前(不必等到最后一层)清理 delete tombstone,如果确认更高的层没有对应的 KV 的话
- 优先 compaction 「含 tombstone 率」高的文件
- 直接清理 delete tomstone 只要它遇上「一」个 KV(不必等到最后一层),前提是用户能保证 KV 插入后不会更新
- range delete 会把 range tombstone 写到 SST 文件中一个专门的区域。读请求在读的时候都要过一遍 range tombstone,以过滤掉删除的数据。RocksDB 的分段 + 排序是在 open SST 文件的时候实时做的,做完之后会把结果缓存下来供后续使用。
- 优化 time-bounded delete SST 文件过期时,compact 到下一层。在所有层中的总时间和为 time-bounded delete时间
- 优化range delete 按delete key排序,感觉不太实用