RocksDB解析


 

1. 简介

RocksDB的项目起源于Facebook的一个实验,希望能够开发一个高效的数据库实现能够在快速存储设备(特别是Flash)上存储数据并服务服务器的负载,同时完全挖掘这类存储设备的潜能。RocksDB是一个C++库用于存储kv数据并且支持原子读写。RocksDB实现了在配置上的较高的灵活性并且可以运行到各种生产环境中,包括纯内存、Flash、HDD或者HDFS。RocksDB支持多种压缩算法以及多种工具用于生产支持以及debug。RocksDB借用了许多LevelDB的代码以及Apache HBase中的思想。最初是基于LevelDB1.5开发。

RocksDB是一个嵌入式的K-V(任意字节流)存储。所有的数据在引擎中是有序存储,可以支持Get(key)、Put(Key)、Delete(Key)和NewIterator()。RocksDB的基本组成是memtable、sstfile和logfile。memtable是一种内存数据结构,写请求会先将数据写到memtable中,然后可选地写入logfile。logfile是一个顺序写的文件。当内存表溢出的时候,数据会flush到sstfile中,然后这个memtable对应的logfile也会安全地被删除。sstfile中的数据也是有序存储以方便查找。

RocksDB中的key和value完全是byte stream,key和value的大小没有任何限制。Get接口提供用户一种从DB中查询key对应value的方法,MultiGet提供批量查询功能。DB中的所有数据都是按照key有序存储,其中key的compare方法可以用户自定义。Iterator方法提供用户RangeScan功能,首先seek到一个特定的key,然后从这个点开始遍历。Iterator也可以实现RangeScan的逆序遍历,当执行Iterator时,用户看到的是一个时间点的一致性视图。

Fault Torlerance     

RocksDB通过checksum来检测磁盘数据损坏。每个sst file的数据块(4k-128k)都有相应的checksum值。写入存储的数据块内容不允许被修改。

Multi-Threaded Compactions     

当用户重复写入一个key时,在DB中会存在这个key的多个value,compaction操作就是来删除这个key的冗余数据。当一个key被删除时,compation也可以用来真正执行这个底层数据的删除工作,如果用户配置合适的话,compation操作可以多线程执行。DB的数据都存储在sstfile中,当内存表的数据满的时候,会将内存数据(去重、删除无效数据后)写入到L0 文件中。每隔一段时间小文件中的数据会重新merge到更大的文件中,这就是compation。LSM引擎的写吞吐直接依赖于compation的性能,特别是数据存储在SSD或者RAM的情况。RocksDB也支持多线程并行compaction。后台的compaction线程用来将内存数据flush到存储,当所有的后台线程都正在执行compaction时,瞬时大量写操作会很快将内存表写满,这就会引起写停顿。可以配置少一些的线程用于执行数据flush操作,

Block Cache -- Compressed and Uncompressed Data     

RocksDB使用LRU cache提供block的读服务。block cache partition为两个独立的cache,其中一块可以cache未压缩RAM数据,另一块cache 压缩RAM数据。如果压缩cache配置打开的话,用户一般会开启direct io,以避免OS的也缓存重新cache相同的压缩数据。

可用配置  

不论是在option string还是option map中,option name是目标类中的变量名,这些包括:DBOptions, ColumnFamilyOptions, BlockBasedTableOptions, or PlainTableOptions。DBOptions and ColumnFamilyOptions中的变量名和变量描述信息可以在options.h中找到,BlockBasedTableOptions, and PlainTableOptions中的变量信息可以在table.h中找到。需要注意的是,尽管绝大部分的配置项都可以在option string和option map中支持,仍然有一些例外。RocksDB支持的所有配置项可以在db_options_type_info, cf_options_type_info and block_based_table_type_info中查阅,源文件是util/options_helper.h。

LSM-Tree

RocksDB 是基于 LSM-Tree 的,大概如下

首先,任何的写入都会先写到 WAL,然后在写入 Memory Table(Memtable)。当然为了性能,也可以不写入 WAL,但这样就可能面临崩溃丢失数据的风险。Memory Table 通常是一个能支持并发写入的 skiplist,但 RocksDB 同样也支持多种不同的 skiplist,用户可以根据实际的业务场景进行选择。

当一个 Memtable 写满了之后,就会变成 immutable 的 Memtable,RocksDB 在后台会通过一个 flush 线程将这个 Memtable flush 到磁盘,生成一个 Sorted String Table(SST) 文件,放在 Level 0 层。当 Level 0 层的 SST 文件个数超过阈值之后,就会通过 Compaction 策略将其放到 Level 1 层,以此类推。

这里关键就是 Compaction,如果没有 Compaction,那么写入是非常快的,但会造成读性能降低,同样也会造成很严重的空间放大问题。为了平衡写入,读取,空间这些问题,RocksDB 会在后台执行 Compaction,将不同 Level 的 SST 进行合并。但 Compaction 并不是没有开销的,它也会占用 I/O,所以势必会影响外面的写入和读取操作。

对于 RocksDB 来说,他有三种 Compaction 策略,一种就是默认的 Leveled Compaction,另一种就是 Universal Compaction,也就是常说的 Size-Tired Compaction,还有一种就是 FIFO Compaction。对于 FIFO 来说,它的策略非常的简单,所有的 SST 都在 Level 0,如果超过了阈值,就从最老的 SST 开始删除,其实可以看到,这套机制非常适合于存储时序数据。

实际对于 RocksDB 来说,它其实用的是一种 Hybrid 的策略,在 Level 0 层,它其实是一个 Size-Tired 的,而在其他层就是 Leveled 的。

这里在聊聊几个放大因子,对于 LSM 来说,我们需要考虑写放大,读放大和空间放大,读放大可以认为是 RA = number of queries * disc reads,譬如用户要读取一个 page,但实际下面读取了 3 个 pages,那么读放大就是 3。而写放大则是 WA = data writeen to disc / data written to database,譬如用户写入了 10 字节,但实际写到磁盘的有 100 字节,那么写放大就是 10。而对于空间放大来说,则是 SA = size of database files / size of databases used on disk,也就是数据库可能是 100 MB,但实际占用了 200 MB 的空间,那么就空间放大就是 2。


2. compaction

LSM-Tree 能将离散的随机写请求都转换成批量的顺序写请求(WAL + Compaction),以此提高写性能。但也带来了一些问题:

  • 读放大(Read Amplification)。LSM-Tree 的读操作需要从新到旧(从上到下)一层一层查找,直到找到想要的数据。这个过程可能需要不止一次 I/O。特别是 range query 的情况,影响很明显。
  • 空间放大(Space Amplification)。因为所有的写入都是顺序写(append-only)的,不是 in-place update ,所以过期数据不会马上被清理掉。

RocksDB 和 LevelDB 通过后台的 compaction 来减少读放大(减少 SST 文件数量)和空间放大(清理过期数据),但也因此带来了写放大(Write Amplification)的问题。

  • 写放大。实际写入 HDD/SSD 的数据大小和程序要求写入数据大小之比。正常情况下,HDD/SSD 观察到的写入数据多于上层程序写入的数据。

在 HDD 作为主流存储的时代,RocksDB 的 compaction 带来的写放大问题并没有非常明显。这是因为:

  • HDD 顺序读写性能远远优于随机读写性能,足以抵消写放大带来的开销。
  • HDD 的写入量基本不影响其使用寿命。

现在 SSD 逐渐成为主流存储,compaction 带来的写放大问题显得越来越严重:

  • SSD 顺序读写性能比随机读写性能好一些,但是差距并没有 HDD 那么大。所以,顺序写相比随机写带来的好处,能不能抵消写放大带来的开销,这是个问题。
  • SSD 的使用寿命和其写入量有关,写放大太严重会大大缩短 SSD 的使用寿命。因为 SSD 不支持覆盖写,必须先擦除(erase)再写入。而每个 SSD block(block 是 SSD 擦除操作的基本单位) 的平均擦除次数是有限的。

所以,在 SSD 上,LSM-Tree 的写放大是一个非常值得关注的问题。而写放大、读放大、空间放大,三者就像 CAP 定理一样,需要做好权衡和取舍。

RocksDB 的写放大分析:

+1 - redo log 的写入

+1 - Immutable Memtable 写入到 L0 文件

+2 - L0 和 L1 compaction(L0 SST 文件的 key 范围是重叠的,出于性能考虑,一般尽量保持 L0 和 L1 的数据大小是一样的,每次拿全量 L0 的数据和全量 L1 的数据进行 compaction)

+11 - Ln-1 和 Ln 合并的写入(n >= 2,默认情况下,Ln 的数据大小是 Ln-1 的 10 倍,见max_bytes_for_level_multiplier )。

所以,总的写放大是 4 + 11 * (n-1)  = 11 * n - 7 倍。关键是 n 的取值。

假设 max_bytes_for_level_multiplier 取默认值 10,则 n 的取值受 L1 的大小和 LSM-Tree 的大小影响。

L1 的大小由 max_bytes_for_level_base 决定,默认是 256 MB。

默认情况下 L0 的大小和 L1 一样大,也是 256 MB。不过 L0 比较特殊,当 L0 的 SST 文件数量达到 level0_file_num_compaction_trigger 时,触发 L0 -> L1 的 comapction。所以 L0 的最大大小为 write_buffer_size * min_write_buffer_number_to_merge * level0_file_num_compaction_trigger。

write_buffer_size 默认 64 MB

min_write_buffer_number_to_merge 默认 1

level0_file_num_compaction_trigger 默认 4

所以 L0 默认最大为 64 MB * 1 * 4 = 256 MB

因此,RocksDB 每一层的默认大小为 :

L0 - 256 MB

L1 - 256 MB

L2 - 2.5 GB

L3 - 25 GB

L4 - 250 GB

L5 - 2500 GB

Tiered Compaction vs Leveled Compaction

大家应该都知道,对于 LSM 来说,它会将写入先放到一个 memtable 里面,然后在后台 flush 到磁盘,形成一个 SST 文件,这个对写入其实是比较友好的,但读取的时候,很可能会遍历所有的 SST 文件,这个开销就很大了。同时,LSM 是多版本机制,一个 key 可能会被频繁的更新,那么它就会有多个版本留在 LSM 里面,占用空间。

为了解决这两个问题,LSM 会在后台进行 compaction,也就是将 SST 文件重新整理,提升读取的性能,释放掉无用版本的空间,通常,LSM 有两种 Compaction 方式,一个就是 Tiered,而另一个则是 Leveled。

上图是两种 compaction 的区别,当 Level 0 刷到 Level 1,让 Level 1 的 SST 文件达到设定的阈值,就需要进行 compaction。对于 Tiered 来说,我们会将所有的 Level 1 的文件 merge 成一个 Level 2 SST 放在 Level 2。也就是说,对于 Tiered 来说,compaction 其实就是将上层的所有小的 SST merge 成下层一个更大的 SST 的过程。

而对于 Leveled 来说,不同 Level 里面的 SST 大小都是一致的,Level 1 里面的 SST 会跟 Level 2 一起进行 merge 操作,最终在 Level 2 形成一个有序的 SST,而各个 SST 不会重叠。

上面仅仅是一个简单的介绍,大家可以参考 ScyllaDB 的两篇文章 Write Amplification in Leveled Compaction,Space Amplification in Size-Tiered Compaction,里面详细的说明了这两种 compaction 的区别。


3. Block Cache

Block Cache是RocksDB把数据缓存在内存中以提高读性能的一种方法。开发者可以创建一个cache对象并指明cache capacity,然后传入引擎中。cache对象可以在同一个进程中供多个DB Instance使用,这样开发者就可以通过配置控制所有的cache使用。Block cache存储的是非压缩的数据块内容。用户也可以设置另外一个block cache来存储压缩数据块。读数据时首先从非压缩数据块cache中读数据、然后读压缩数据块cache。当Direct-IO打开的话,压缩数据库可以作为系统页缓存的替代。RocksDB中有两种cache的实现方式,分别为LRUCache和CLockCache。这两种cache都会被分片,来降低锁压力。用户设置的容量平均分配给每个shard。默认情况下,每个cache都会被分片为64块,每块大小不小于512K字节。

LRU Cache

默认情况,RocksDB使用LRU Cache,默认大小为8M。cache的每个分片都有自己的LRU list和hash表来查找使用。每个shard都有个mutex来控制数据并发访问。不管是数据查找还是数据写入,线程都要获取cache分片的锁。开发中也可以调用NewLRUCache()来创建一个LRU cache。这个函数提供了几个有用的配置项来设置cache:

Capacity               cache的总大小

num_shard_bits               去cache key的多少字节来选择shard_id。cache将会被分片为2^num_shard_bits

strict_capacity_limit        很少会出现block cache的size超过容量的情况,这种情况发生在持续不断的read or iteration 访问block cache,pinned blocks的总大小会超过容量。如果有更多的读请求将block数据写入block cache时,且strict_capacity_limit=false(default),cache服务会不遵循容量限制并允许写入。如果host没有足够内存的话,就会导致DB instance OOM。如果将这个配置设置为true,就可以拒绝将更多的数据写入cache,fail掉那些read or iterator。这个参数配置是以shard为控制单元的,所以会出现某一个shard在capcity满时拒绝继续写入cache,而另一个shard仍然有extra unpinned space。

high_pri_pool_ratio        为高优先级block预留的capacity 比例

Clock Cache

  ClockCache实现了CLOCK算法。CLOCK CACHE的每个shard都有一个cache entry的圆环list。算法会遍历圆环的所有entry寻找unspined entry来回收,但是如果上次scan操作这个entry被使用的话,也会有继续留在cache中的机会。寻找并回收entry使用tbb::concurrent_hash_map。

  使用LRUCache的一个好处是有一把细粒度的锁。在LRUCache中,即使是查找操作也需要获取分片锁,因为有可能会更改LRU-list。在CLock cache中查找并不需要获取分片锁,只需要查找当前hash_map就可以了,只有在insert时需要获取分片锁。使用clock cache,相比于LRU cache,写吞吐有一定提升。

当创建clock cache时,也有一些可以配置的信息。

    Capacity               same as LRUCache

    num_shard_bits              same as LRUCache

    strict_capacity_limit       same as LRUCache

Simulated Cache

SimCache是当cache capacity或者shard num发生改变时预测cache hit的方法。SimCache封装了真正的Cache 对象,运行一个shadow LRU cache模仿具有同样capacity和shard num的cache服务,检测cache hit和miss。这个工具在下面这种情况很有用,比如:开发者打开了一个DB 实例,配置了4G的cache size,现在想知道如果将cache size调整到64G时的cache hit。

SimCache的基本思想是根据要模拟的容量封装正常的block cache,但是这个封装后的block cache只有key,没有value。当插入数据时,把key插入到两个cache中,但是value只插入到normal cache。value的size会在两种cache中都计算进去,但是SimCache中因为只有key,所以并没有占用那么多的内存,但是以此却可以模拟block cache的一些行为。


4. MemTable

MemTable是一种在内存中保存数据的数据结构,然后再在合适的时机,MemTable中的数据会flush到SST file中。MemTable既可以支持读服务也可以支持写服务,写操作会首先将数据写入Memtable,读操作在query SST files之前会首先从MemTable中query数据(因为MemTable中的数据一直是最新的)。一旦MemTable满了,就会转换为只读的不可改变的,然后会创建一个新的MemTable来提供新的写操作。后台线程负责将MemTable中的数据flush到SST file,然后这个MemTable就会被销毁。

重要的配置:

memtable_factory:memtable的工厂对象。通过这个工厂对象,用户可以改变memtable的底层实现并提供个性化的实现配置。

write_buff_size :单个内存表的大小限制

db_write_buff_size: 所有列族的内存表总大小。这个配置可以管理内存表的总内存占用。

write_buffer_manager : 这个配置不是管理所有memtable的总内存占用,而是,提供用户自定义的write buffer manager来管理整体的内存表内存使用。这个配置会覆盖db_write_buffer_size。

max_write_buffer_number:内存表的最大个数

memtable的默认实现是skiplist。除了默认memtable实现外,用户也可以使用其他类型的实现方法比如 HashLinkList、HashSkipList or Vector 来提高查询性能。

Skiplist MemTable

基于Skiplist的memtable在支持读、写、随机访问和顺序scan时提供了较好的性能。此外,还支持了一些其他实现不能支持的feature比如concurrent insert和 insert with hint。

HashSkiplist MemTable

如其名,HashSkipList是在hash table中组织数据,hash table中的每个bucket都是一个skip list,HashLinkList也是在hash table中组织数据,但是每一个bucket是一个有序的单链表。这两种结构实现目的都是在执行query操作时可以减少比较次数。一种使用场景就是把这种memtable和PlainTable SST格式结合在一起,然后将数据保存在RAMFS中。当执行检索或者插入一个key时,key的前缀可以通过Options.prefix_extractor来检索,之后就找到了相应的hash bucket。进入到 hash bucket内部后,使用全部的key数据来进行比较操作。使用hash实现的memtable的最大限制是:当在多个key前缀上执行scan操作需要执行copy和sort操作,非常慢且很耗内存。

flush

在以下三种情况下,内存表的flush操作会被触发:

内存表大小超过了write_buffer_size

全部列族的所有内存表大小超过了db_write_buffer_size,或者wrtie_buffer_manager发出了flush的指令。这种情况下,最大的内存表会被选择进行flush操作。

全部的WAL文件大小超过max_total_wal_size。在这种场景下,内存中数据最老的内存表会被选择执行flush操作,然后这个内存表对应的WAL file会被回收。

所以,内存表也可以在未满时执行flush操作。这也是产生的SST file比对应的内存表小的一个原因,压缩是是另一个原因(内存表总的数据是没有压缩的,SST file是压缩过的)。

Concurrent Insert

如果不支持concurrent insert to memtable的话,来自多个线程的concurrent 写会顺序地写入memtable。默认是打开concurrent insert to memtable,也可以通过设置allow_concurrent_memtable_write来关闭。


5. Write Ahead Log

对RocksDB的每一次update都会写入两个位置:1) 内存表(内存数据结构,后续会flush到SST file) 2)磁盘中的write ahead log(WAL)。在故障发生时,WAL可以用来恢复内存表中的数据。默认情况下,RocksDB通过在每次用户写时调用fflush WAL文件来保证一致性。


6. Write Buffer Manager

Write buffer mnager帮助开发者管理列族或者DB instance的内存表的内存使用。

  • 管理内存表的内存占用在阈值内
  • 内存表的内存占用转移到block cache

  Write buffer manager与rate_limiter和sst_file_manager类似。用户创建一个write buffer manager对象,传入 column family或者DBs的配置中。可以参考write_buffer_manager.h的注释部分来学习如何使用。

Limit total memory of memtables

  在创建write buffer manager对象时,内存限制的阈值就已经确定好了。RocksDB会按照这个阈值去管理整体的内存占用。

  在5.6或者更高版本中,如果整体内存表使用超过了阈值的90%,就会触发正在写入的某一个column family的数据执行flush动作。如果DB instance实际内存占用超过了阈值,即使全部的内存表占用低于90%,那也会触发更加激进的flush动作。在5.6版本以前,只有在内存表内存占用的total超过阈值时才会触发flush。

  在5.6版本及更新版本中,内存是按照arena分配的total内存计数的,即使这些内存不是被内存表使用。在5.6之前版本中,内存使用是按照内存表实际使用的内存

Cost memory used in memtable to block cache

  从5.6版本之后,用户可以将内存表的内存使用的占用转移到block cache。不管是否打开内存表的内存占用,都可以这样操作。

  大部分情况下,block cache中实际使用的blocks远比block cache中的数据少很多,所以如果用户打开了这个feature后,block cache的容量会覆盖掉block cache和内存表的内存占用。如果用户打开了cache_index_and_filter_blocks的话,这三种内存占用都在block cache中。

  具体实现如下,针对内存表分配的每一个1M内存,WriteBufferManager都会在block cache中put一个dummy 1M的entry,这样block cache就可以正确的计算内部占用,而且可以在需要时淘汰掉一些block以便腾出内存空间。如果内存表的内存占用降低了,WriteBufferManager也不会立马三除掉dummmy blocks,而是在后续慢慢地释放掉。这是因为内存表空间占用的up and down太正常不过了,RocksDB不需要对此太过敏感。

  • 把使用的block cache传递给WriteBufferManager
  • 把WriteBufferManager的参数传入RocksDB内存表占用的最大内存
  • 把block cache的容量设置为 data blocks和memtables的内存占用总和

Ref:

Tuning RocksDB – Options    https://www.jianshu.com/p/8e0018b6a8b6

https://www.jianshu.com/u/aa9cae571502

https://www.jianshu.com/p/9b7437b5ea5b

https://zhuanlan.zhihu.com/p/37193700

猜你喜欢

转载自www.cnblogs.com/pdev/p/11277784.html