Rocksdb结构简述

78 阅读10分钟

Rocksdb结构简述

rocksdb是一个kv存储引擎——说人话存储引擎就是帮助管理存储数据的,负责数据检索与压缩存储等。这相比于redis,redis是一个基于内存的kv数据库,数据库已经是组装了存储引擎的产品了,数据库在引擎上提供了多种格式的数据,不让用户去直接修改与定制底层数据。

使用介绍

Rocksdb只是一个存储引擎,所以通常需要与别的应用一起使用,完成数据管理链路。

#include <iostream>
#include <rocksdb/db.h>
#include <rocksdb/options.h>
#include <rocksdb/status.h>

int main() {
    // 指定数据库目录
    std::string db_path = "testdb";

    // 创建数据库选项
    rocksdb::Options options;
    options.create_if_missing = true; // 如果数据库不存在,则创建

    // 打开数据库
    rocksdb::DB* db;
    rocksdb::Status status = rocksdb::DB::Open(options, db_path, &db);

    if (!status.ok()) {
        std::cerr << "Error opening database: " << status.ToString() << std::endl;
        return 1;
    }

    // 写入数据
    status = db->Put(rocksdb::WriteOptions(), "key1", "value1");
    if (!status.ok()) {
        std::cerr << "Error writing data: " << status.ToString() << std::endl;
        return 1;
    }

    // 读取数据
    std::string value;
    status = db->Get(rocksdb::ReadOptions(), "key1", &value);
    if (!status.ok()) {
        std::cerr << "Error reading data: " << status.ToString() << std::endl;
        return 1;
    }

    std::cout << "Value for key1: " << value << std::endl;

    // 删除数据
    status = db->Delete(rocksdb::WriteOptions(), "key1");
    if (!status.ok()) {
        std::cerr << "Error deleting data: " << status.ToString() << std::endl;
        return 1;
    }

    // 关闭数据库
    delete db;

    std::cout << "Database closed successfully." << std::endl;
    return 0;
}

底层介绍

图片来源于参考文章<图片来源于参考文章>

  • 数据管理上,使用SST文件存储数据,使用ColumnFamily做逻辑切分,不同的CF具有数据隔离性。为了应对物理设备的随机读写特点,使用LSM Tree进行数据有序整理,并定时合并。

  • 一致性保证,在数据读写的时候,先写wal(write ahead log),然后写入内存的Memtable,最终基于策略合并到硬盘中的sst文件。Rocksdb 对每个 kv 以及整体数据文件都分别计算了 checksum,以进行数据正确性校验。

    wal文件在每一个DB打开时创建,如果CF数据刷新到磁盘上,也会创建wal文件。只有当wal中的数据都已经刷入磁盘的sst后,对应的wal文件会被异步删除。wal的写入为单线程串行写入,确保操作顺序。

    LSM的元数据需要常驻内存,数据就存在ManiFest中,CF的元数据也存储于此。MANIFEST 保存当前db的状态信息(类似于快照),主要是SST文件的各个版本信息(当sst文件被改动,即会生成对应的versionEdit,并触发sync写manifest文件)。如果rocksdb崩溃重启,也是通过ManiFest读取wal信息来恢复数据。ManiFest是维护一致性的核心控制。

    数据快照。Checkpoint可以在单独的目录中创建rocksdb的完整数据备份,支持增量备份,一般用于灾备。Snapshot,是一个内存视图,是rocksdb中某一个时间点的一致性视图,只是数据的引用而没有存储实际性数据,一般用于临时操作。

  • 数据分区,ColumFamily是逻辑上分区,存在的意义是所有 table 共享 WAL,但不共享 memtable 和 table 文件,通过 WAL 保证原子写,通过分离 table 可快读快写快删除。每次 flush 一个 CF 后,都会新建一个 WAL,都这并不意味着旧的 WAL 会被删除,因为别的 CF 数据可能还没有落盘,只有所有的 CF 数据都被 flush 且所有的 WAL 有关的 data 都落盘,相关的 WAL 才会被删除。

  • 数据流程:[]byte - wal - memtable - immutable - sst

    memtable,用于内存中承载数据,默认是skipList结构(skipList支持并发插入)。memtable中负责数据的MVCC管理,每次操作的数据都会有数据版本,传入的key-value,会转化为rocksdb视角的internal_key并赋予版本号,随后存入skipList,查找的时候基于版本确定最新数据。

    sst,sst文件是rocksdb定义的结构,里面除了存储具体数据外,还有bloom filter,sst加载进入内存的时候,会将bloom filter一起加载,以便与快速查找key是否在sst中。

  • 写操作,读操作都是原子性的,不存在中间失败的状态。

读过程

image.png<图片来源于参考文章>

读取的时候,先查找memtable,如果找不到,查找immumtable,如果还是没有,即从L0开始往下查找SST文件。因为memtable存在多个数据,所以需要基于数据版本获取得到最新的key对应的值。内存里找不到,需要查找L0里的sst文件。通过元数据查找出key相关的sst并读取。持久化层的L0是最顶层,L0中的数据仍然可能重复而且不是有序的,所以需要不断遍历查找sst文件,比较版本之后返回。如果数据不在L0中,则下层数据都是有序而且不重复的,会直接通过二分法查找数据,找到则返回。

写过程

image.png<图片来源于参考文章>

(各个对象之间只需要关注对象的创建、对象运行、删除,就可以了解系统的运行过程)

写数据的时候,先写wal,wal中的记录按照时间顺序记录,写完成后再更新内存中的memtable,这是一个跳表结构,方便数据快速检索与插入。(5.5版本后,wal与memtable已经可以并行写入了,就是多个wal writer组成队列,一个线程写完wal后,其他的wal writer就可以写了,而这个线程再去写memtable)。
如果插入数据导致memtable满了,会变成immemtable,然后通过某种策略触发flush,将内存的数据刷入L0的sst文件。L0作为持久化的最上层,可能会存在重复数据,L0往后的持久化层因为LSM的合并特性能够确保数据没有重复。

Compaction过程

compaction是SST数据的合并过程,可以手动触发或者在切换wal、memtable满的时候进行Compaction队列检查,挑选合适的sst进行compaction。简单归纳就是一个文件输入,做合并与排序,然后输出。虽然动作很简单,但是里面有许多需要注意的细节以及许多巧妙的设计,要理解透彻得下一番功夫,本篇只是简要介绍。rocksdb实现了多种这样的compaction策略,这里以默认的level compaction为切入点。(详细的过程可以看看这篇文章:vigourtyy-zhg.blog.csdn.net/article/det…

compaction 作为单机引擎rocksdb/leveldb LSM tree 实现中的一个关键步骤,用来对底层存储的SST文件中的key进行排序去重,同时对其中针对key的不同操作进行处理。总之,就是保证了底层数据存储的有序性。
github.com/facebook/ro…

Compaction简单可以分为三个步骤,准备文件、处理数据、输出文件。

  • 针对通过每层的文件总大小与文件数量对各个sst文件打分,挑选文件加入compaction队列
  • 队列中查找文件,计算将要合并的key与将合并进入的层级。这里计算key使用了clean cut,批量处理关联的多个文件。然后查找将合并进入的层级中需要合并的文件,将一起处理合并
  • 文件挑选完成后,开始并发处理,对文件实际kv数据处理。构造一个大顶堆的结构,然后读取实际kv数据,不断将数据合并后,传入堆中。这样最终得到一个有序的堆。(L0层是每个sst文件一个堆,其下每层都是整层只有一个堆,因为L0层数据存在重复,而下面各层的数据不会重复)。处理数据的过程中,除了维护堆结构外,还存在许多数据状态处理,将kValueType,kTypeDeletion,kTypeSingleDeletion,kValueDeleteRange,kTypeMerge 等不同的key type处理完成等。
  • 处理完成数据后,开始写入sst文件。这里面维护了一个table_builder状态机,负责多个输入数据的聚合后落盘。按照sst的文件格式,生成bloom filter,Index block,填充kv数据,写入footer

进阶

概念明晰

  • 空间放大,1M的数据,写入到数据库中需要2M的空间,这个现象就是空间放大。
    指的是用于存储数据的实际磁盘空间与逻辑数据大小之间的比率。例如,如果一个数据库需要2MB的磁盘空间来存储逻辑键值对,而逻辑键值对只占用1MB,那么空间放大率就是2。这表明存储利用效率低下,占用的磁盘空间大于逻辑数据大小。空间放大率越高,表明存储利用效率越低,即数据库占用的磁盘空间大于逻辑数据大小的比率越大。导致空间放大的因素主要是已删除或更新的键仍占用磁盘空间。RocksDB中的压缩功能可以通过合并SST文件和丢弃不必要的键来减少空间放大。
  • 写入放大,写入存储设备的字节数大于数据库中存储这个key的字节数。
    是指写入存储的字节数与写入数据库的字节数之间的比率,量化了存储系统为适应用户写操作而产生的额外I/O操作。写入放大率反映了数据写入底层存储的效率,写入放大率过高会增加存储设备的磨损,影响系统性能。
  • 读取放大,读取逻辑数据所需要执行IO的次数变多
    衡量的是逻辑读取操作所需的I/O操作次数。在一次读取操作中,RocksDB需要搜索多个SST文件来查找特定的键值,访问SST文件数量会导致读取放大,读取放大的增加会影响系统的读取性能。
  • sst设计理念:LSM Tree通过内存聚合,保证了数据是顺序写入磁盘,提高了写时候的性能。但是,分层架构使得读性能下降——如果数据在比较低层,那么需要IO多次才能找到数据,读放大严重。所以,sst增加多个设计来缓解读性能的损失:sst内嵌bloom fliter快速判断一个值是否存在sst中;index_filter通过二分法查找数据,防止不存在的数据导致全盘读写。所以,LSM提高了写性能,通过Compaction减少了读性能的损失。

实践与部署

  • 通过监控三个放大因子(写入放大、空间放大和读取放大),可以深入了解不同配置对性能的影响。
  • RocksDB Advisor命令行工具可以帮助用户找到最佳配置参数,可以根据具体需求和工作负载特征,帮助用户确定最合适的配置设置。利用这一工具,用户可以减轻在配置RocksDB获得最佳性能时的复杂性。
  • 关于写的一些参数调优
    通过在rocksdb打开的时候增加自调优的参数设置:
    options.OptimizeLevelStyleCompaction();总体上的一个参数调整是增加memtable的吞吐量:增加了memtable的大小,可以同时存在于内存中的memtable文件的个数,并且适配了L1的容量保持和L0的容量接近,还有一些各层的压缩算法的配置。大概测试了一下该配置的随机写吞吐能够在原有基础之上提升50-80%。不过该配置肯定对内存资源的消耗比较大,所以如果系统资源足够且是IO密集型业务对性能有较高的要求可以尝试一下该配置。
    options.allow_concurrent_memtable_write=true ; 允许多个writer 对memtable的并发写入
    options.enable_pipelined_write=true ; 开启pipeline的写机制,允许memtable和wal并发写入

参考

segmentfault.com/a/119000004…
github.com/facebook/ro…
www.jianshu.com/p/73fa1d4e4…
必读的wiki:alexstocks.github.io/html/rocksd…
必读专栏:https://blog.csdn.net/Z_Stand/article/details/106367782