古话说得好:“工欲善其事必先利其器”,要做好一件事情之前先把工具或者武器强化一下还是很值当的。所以本文将会把RocksDB的主要概念向大家讲解一下,方便后面具体内容的展开。本文所提到的概念大家仅需要了解和留个印象,如果不是很理解的话不需要纠结,后续的章节中会详细展开。
RocksDB的概念纷繁复杂,我根据自己的理解将概念分为架构概念、存储概念以及操作概念,分门别类,帮助大家理解。下面就按照这个观其貌、识其物、辩其行的顺序进行讲解。
首先RocksDB是一个嵌入式数据库,这个根本特征决定了RocksDB不需要单独部署,也不需要单独的进程,随应用程序一起部署即可,节省资源且便于管理。
另外由于RocksDB未实现真正意义上的分布式(虽然底层可以使用HDFS目录进行存储,但是数据未作分片,所以无法实现真正的分布式),准确来说是一个单机的KV数据库。
但是没有分布式的血统不代表没有一颗成为分布式应用的心。在实际的分布式使用场景中以RocksDB作为某个副本的存储介质,上层通过Paxos或者Raft协议来保证副本之间的数据一致性,完美的明确了RocksDB在分布式应用使用场景中的定位与作用。
这是RocksDB的LSM树模型,具体的LSM树相关的概念和知识请参考之前的博文俗话说别在一棵树上吊死,那为什么那么多NOSQL都喜欢在LSM树上吊死呢?
根据这个模型,数据的写入流程为:
任何的写入都会先写到 WAL,然后在写入 Memory Table(Memtable)。当然为了性能,也可以不写入 WAL,但这样就可能面临崩溃丢失数据的风险。Memory Table 通常是一个能支持并发写入的 skiplist,但 RocksDB 同样也支持多种不同的 Memory Table,下面在Memory Table章节详细讲解,用户可以根据实际的业务场景进行选择,如果没有特殊需求,默认即可。
当一个 Memory Table 写满了之后,就会变成 immutable 的 Memory Table,RocksDB 在后台会通过一个 flush 线程根据条件从flush queue中按顺序将 Memory Table flush 到磁盘,生成一个 Sorted String Table(SST) 文件,放在 Level 0 层。当 Level 0 层的 SST 文件个数超过阈值之后,就会通过 Compaction 策略将其放到 Level 1 层,以此类推,直到最底层
这是从HBase老师那里学来的,HBase老师就有Column Family的概念,严格上来说HBase是宽列式数据库,列族级别是列式存储,列族内部是行式存储。
由于RocksDB是KV数据库,没有schema的概念,所有数据都是二进制,采用列式存储。不同的Column Family共享WAL,独享sst和memtable,所以Column Family起到了一定的逻辑和资源隔离的作用。
RocksDB的每个键值对都与唯一一个列族(column family)结合。如果没有指定Column Family,键值对将会结合到“default” 列族。
通过共享WAL文件,RocksDB实现了原子写。通过隔离memtable和table文件,RocksDB可以独立配置每个列族并且快速删除它们。
这个RocksDB两个不同的事务数据库模式,可以理解为悲观事务数据库和乐观事务数据库。对应的是关系型数据库的悲观锁和乐观锁,所以这个应该很好理解,下面来看看两者的具体说明:
TransactionDB:简单来说就是在所有写入的时候进行加锁判断,如果无法加锁,则当前数据正在被操作,需要等待;如果加锁成功,则可以在直接操作数据。该类型的事务数据库适用于高并发场景下数据被频繁修改的场景。
OptimisticTransactionDB:简单来说就是在写入的数据不使用任何锁,而在提交的时候判断事务是否被修改。如果事务被修改而造成冲突,提交会返回错误,需要重新处理。显而易见,如果频繁修改一条数据,则重新处理的成本过高,所以,该类型数据库适用于大量非事务写入,少量事务写入的场景。
RocksDB的配置项,在实例化RocksDB时作为构造函数传给构造对象。java示例如下:
String dbPath = "/Users/bangcle/Documents/rocksdb/data";
Options options = new Options();
RocksDB rocksDB = RocksDB.open(options, dbPath);
RocksDB有非常多的配置,但是对于多数用户来说,很多选项都是可以不管的,因为他们里面的大多数,都只会影响特定的工作负荷。通常,大多数rocksDB的选项只要保持默认就好了。
下面这些选项,可以帮助我们在大多数情况获得一个合理的开箱即用的性能。建议用户在使用新的rocksdb工程的时候使用以下选项:
cf_options.level_compaction_dynamic_level_bytes = true;
options.max_background_compactions = 4;
options.max_background_flushes = 2;
options.bytes_per_sync = 1048576;
options.compaction_pri = kMinOverlappingRatio;
table_options.block_size = 16 * 1024;
table_options.cache_index_and_filter_blocks = true;
table_options.pin_l0_filter_and_index_blocks_in_cache = true;
如果你有服务使用默认选项运行,而不是使用这些设置,也不太过担心。尽管这些选项比默认选项好,但是它们一般也不会带来明显的性能优化。建议先按照默认配置进行数据处理,如果性能有瓶颈,可以根据需求进行优化,具体的优化措施会在性能优化的章节详细讲解。
MemTable是一个内存数据结构,他保存了落盘到SST文件前的数据。他同时服务于读和写:
新的写入总是将数据插入到memtable
读取在查询SST文件前总是要查询memtable,因为memtable里面的数据总是更新的。
一旦一个memtable被写满,他会变成不可修改的,并被一个新的memtable替换。一个后台线程会把这个memtable的内容落盘到一个SST文件,然后这个memtable就可以被销毁了。
影响memtable的最重要的几个选项是:
memtable_factory: memtable对象的工厂。通过声明一个工厂对象,用户可以改变底层memtable的实现,并提供事先声明的选项。
write_buffer_size:一个memtable的大小
db_write_buffer_size:多个列族的memtable的大小总和。这可以用来管理memtable使用的总内存数。
write_buffer_manager:除了声明memtable的总大小,用户还可以提供他们自己的写缓冲区管理器,用来控制总体的memtable使用量。这个选项会覆盖db_write_buffer_size
max_write_buffer_number:内存中可以拥有刷盘到SST文件前的最大memtable数。
默认的memtable实现是基于skiplist的。除了默认的memtable实现,用户可以使用其他memtable实现,例如HashLinkList,HashSkipList或者Vector,以加快查询速度。下面列出各个MEMTABLE类型的对比表:
MEMTABLE类型 | SKIPLIST | HASHSKIPLIST | HASHLINKLIST | VECTOR |
---|---|---|---|---|
最佳使用场景 | 通用 | 带特殊key前缀的范围查询 | 带特殊key前缀,并且每个前缀都只有很小数量的行 | 大量随机写压力 |
索引类型 | 二分搜索 | 哈希+二分搜索 | 哈希+线性搜索 | 线性搜索 |
是否支持全量db有序扫描 | 天然支持 | 非常耗费资源(拷贝以及排序生成一个临时视图 | 同HashSkipList | 同HashSkipList |
额外内存 | 平均(每个节点有多个指针 | 高(哈希桶+非空桶的skiplist元数据+每个节点多个指针 | 稍低(哈希桶+每个节点的指针 | 低(vector尾部预分配的内存) |
Memtable落盘 | 快速,以及固定数量的额外内存 | 慢,并且大量临时内存使用 | 同HashSkipList | 同HashSkipList |
并发插入 | 支持 | 不支持 | 不支持 | 不支持 |
带Hint插入 | 支持(在没有并发插入的时候 | 不支持 | 不支持 | 不支持 |
Block Cache是Rocksdb在内存中缓存数据以用于读取的地方。用户可以带上一个期望的空间大小,传一个Cache对象给RocksDB实例。一个缓存对象可以在同一个进程的多个RocksDB实例之间共享,这允许用户控制总的缓存大小。
用户可以配置两个Block Cache,默认的是存储未压缩块的Block Cache,还可以单独配置一个存储压缩块的Block Cache。读取的时候会先拉去未压缩的数据块的缓存,然后才拉取压缩数据块的缓存。在打开直接IO的时候压缩块缓存可以替代OS的页缓存。配置项如下:
# 设置存储未压缩的块
table_options.block_cache = cache;
# 设置存储压缩的块
table_options.block_cache_compressed = another_cache;
RocksDB里面有两种实现方式,分别叫做LRUCache和ClockCache。两个类型的缓存都通过分片来减轻锁冲突。容量会被平均的分配到每个分片,分片之间不共享空间。默认情况下,每个缓存会被分片到64个分片,每个分片至少有512kB空间。
开箱即用的情况下,RocksDB会使用LRU块缓存实现,空间为8MB。如果希望使用自定义的块缓存,调用额外LRUCache()或者NewClockCache()来创建一个缓存对象,然后把它设置到基于块的表选项。用户也可以使用自己实现的缓存,只需要实现Cache接口即可
RocksDB中的每个更新操作都会写到两个地方:
一个内存数据结构,名为memtable(后面会被刷盘到SST文件)
写到磁盘上的WAL日志。
在出现服务崩溃的时候,WAL日志可以用于完整的恢复memtable中的数据,以保证数据库能恢复到原来的状态并且数据不丢失。
SST文件是RocksDB在磁盘上的file结构,sstfile由block作为基本单位组成,一个sstfile结构由多个data block和meta block组成,其中data block就是数据实体block,meta block为元数据block。sstfile组成的block有可能被压缩(compression),不同level也可能使用不同的compression方式。sstfile如果要遍历block,会逆序遍历,从footer开始。
RocksDB对文件系统以及存储介质保持不可预知的态度。文件系统操作不是原子的,并且在系统错误的时候容易出现不一致。即使打开了日志系统,文件系统还是不能在一个不合法的重启中保持一致。POSIX文件系统不支持原子化的批量操作。因此,无法依赖RocksDB的数据存储文件中的元数据文件来构建RocksDB重启前的最后的状态。
RocksDB有一个内建的机制来处理这些POSIX文件系统的限制,这个机制就是保存一个名为MANIFEST的ROCKSDB状态变化的事务日志文件。MANIFEST文件用于在重启的时候,恢复rocksdb到最后一个一致的一致性状态。
下面有三个术语需要区分一下:
MANIFEST:指通过一个事务日志,来追踪Rocksdb状态迁移的系统
Manifest日志:指一个独立的日志文件,它包含RocksDB的状态快照/版本
CURRENT:指最后的Manifest日志
Iterator方法提供用户RangeScan功能,首先seek到一个特定的key,然后从这个点开始遍历。Iterator也可以实现RangeScan的逆序遍历,当执行Iterator时,用户看到的是一个时间点的一致性视图。
大部分的LSM引擎都不支持高效的RangeScan操作,这是由于执行RangeScan操作时都要访问所有的数据文件导致。但是大部分用户并不仅仅是完全scan所有的数据,相反,很多情况下仅仅需要按照key的前缀字符串去遍历。RocksDB根据这些应用场景,优化了对应的底层实现。用户可以prefix_extractor来声明一个key_prefix,然后RocksDB为每一个key_prefix存储相应的blooms。配置了key_prefix的Iterator操作可以通过对应的bloom bits来避免检索不含有特定key prefix的数据文件,以此可以提高Iterator性能。
Snapshot接口可以创建数据库在某一个时间点的快照。Iterator接口也可以执行在某一个Snapshot上。
某种意义上,Iterator和Snapshot提供了DB在某个时间点的一个一致性视图,但是其实现原理却不一样。快速短期/前台的scan操作比较适合用Iterator,长期/后台操作适合用Snapshot。当使用Iterator时,会对数据库相应时间点的所有底层文件增加引用计数,直到Iterator结束或者释放了引用计数后,这些文件才允许被删除。
Snapshot不关注数据文件是否被删除的问题,Compation进程会感知Snapshot的存在,会保证对应视图的数据不会被删除。当实例重启时,Snapshot会丢失,这是因为RocksDB不会持久化Snapshot相关数据。
在RocksDB中,最终数据的持久化都是保存在SST中,而SST则是由Memtable刷新到磁盘生成的,这就是所谓的Flush过程。此处仅讲述概念,下一篇基础操作中详细讲解Flush过程
英文翻译是压缩(与Compression翻译一致,容易混淆),但是我更喜欢称之为合并,因为这个动作做的事情就是SST的合并并下降一层迁移。此处仅讲述概念,下一篇基础操作中详细讲解Compaction过程。
在每个SST文件里,数据块和索引块会被分别压缩。用户可以指定压缩类型。压缩配置是针对每个列族的。此处仅讲述概念,下一篇基础操作中详细讲解Compression过程。
很多时候,我们使用数据库时会有离线向数据库导入数据的需求。比如大量用户在本地的一些离线数据,想要将这一些数据导入到已有的数据库中;或者说NewSQL场景中部分机器离线,重新上线之后的数据增量/全量同步 等场景。这个时候 我们并不想要让这一些数据占用过多的系统资源,更不希望他们对正常的线上业务有影响,所以高效的离线数据导入功能基本上都是数据库必备的功能,RocksDB也不例外。在RocksDB中离线导入的功能是通过Ingest的实现。此处仅讲述概念,下一篇基础操作中详细讲解Ingest过程。
上面的内容比较多,基本上涵盖了RocksDB常用的绝大部分概念,可能需要好好的理解下,这对后续学习RocksDB非常关键,但是相信熟悉HBase以及LSM tree的小伙伴对这些概念应该绝大部分都熟悉,应该比较好上手,如果不是很熟悉,可以看看之前的两篇文章回顾下。
俗话说别在一棵树上吊死,那为什么那么多NOSQL都喜欢在LSM树上吊死呢?
HBase生产环境从入门到熟练使用,这一篇文章就够了
后续会在这篇文章的基础上展开讲一下RocksDB相关的属性以及操作。
最后,笔者长期关注大数据通用技术,通用原理以及NOSQL数据库的技术架构以及使用。如果大家感觉笔者写的还不错,麻烦大家多多点赞和分享转发,也许你的朋友也喜欢。
最后挂个公众号二维码,欢迎大家关注,谢谢大家支持。