RocksDB-Cloud 源码及存算分离实践解析

图片

作者 | pika 开源社区
         王少一,来自 360 智汇云基础架构部
前   言

RocksDB-Cloud 是基于 Facebook 开源的高性能键值数据库 RocksDB 的一种扩展,支持数据存储在 S3,为云环境下的部署和管理进行了优化。本篇文章基于代码示例对 RocksDB-Cloud 进行源码分析以及基于 RocksDB-Cloud 如何快速搭建一个简单的存算分离 KV 存储服务。

背景简介

RocksDB-Cloud 是一个开源的 C++ 库,基于单机存储引擎 RocksDB 进行二次开发,实现了全量数据存储在 S3。RocksDB-Cloud 三个主要特性:

  • RocksDB-Cloud 实例持久化。通过对接 S3 和数据持久化的日志服务,RocksDB-Cloud 存量的元信息和 sst 文件都持久化在 S3,Memtable 中的数据记录在日志服务中,即使宿主机不可恢复,还可以在其他节点创建实例,使用 S3 上数据恢复 SST 文件,使用日志服务恢复 Memtable 中的数据。

  • RocksDB-Cloud 实例支持零拷贝克隆。即另一台机器上的 RocksDB-Cloud 实例可以克隆现有数据库内容而不需要进行实际的数据移动。

  • RocksDB-Cloud 实例支持分层存储。RocksDB-Cloud 全量的数据保存在 S3,本地磁盘和内存中缓存热数据。

除此以外,RocksDB-Cloud 为上层服务的主从复制提供了便利,通过继承实现若干接口,即可快速搭建一个简单的存算分离、数据持久化、一主多从架构的存储服务。

源码分析

本小节将从一个使用 demo 开始介绍 RocksDB-Cloud 简单使用方式,然后介绍主要类以及类关系,并通过 RocksDB-Cloud 的一次 flush 过程展示 RocksDB-Cloud 与 S3 的交互过程。最后介绍基于 RocksDB-Cloud 如何实现一个存算分离的 KV 存储引擎。

使用示例

如下为 RocksDB-Cloud 中使用示例 simple_example.cc 的简化代码。

int main() {  // cloud environment config options here  CloudFileSystemOptions cloud_fs_options;  loud_fs_options.credentials.InitializeSimple(      getenv("AWS_ACCESS_KEY_ID"), getenv("AWS_SECRET_ACCESS_KEY"));
// setup s3 bucket const std::string bucketPrefix = "rockset."; cloud_fs_options.src_bucket.SetBucketName(kBucketSuffix, bucketPrefix); cloud_fs_options.dest_bucket.SetBucketName(kBucketSuffix, bucketPrefix);
CloudFileSystem* cfs; Status s = CloudFileSystem::NewAwsFileSystem( FileSystem::Default(), kBucketSuffix, kDBPath, kRegion, kBucketSuffix, kDBPath, kRegion, cloud_fs_options, nullptr, &cfs); if (!s.ok()) { fprintf(stderr, "Unable to create cloud env in bucket %s. %s\n", bucketName.c_str(), s.ToString().c_str()); return -1; } cloud_fs.reset(cfs);
// Create options and use the AWS file system that we created earlier auto cloud_env = NewCompositeEnv(cloud_fs); Options options; options.env = cloud_env.get(); options.create_if_missing = true; // options for each write WriteOptions wopt; wopt.disableWAL = disableWAL;
DBCloud* db; s = DBCloud::Open(options, kDBPath, persistent_cache, 0, &db); if (!s.ok()) { fprintf(stderr, "Unable to open db at path %s with bucket %s. %s\n", kDBPath.c_str(), bucketName.c_str(), s.ToString().c_str()); return -1; } std::string value; s = db->Put(wopt, "key", "value"); s = db->Flush(rocksdb::FlushOptions{}); s = db->Get(ReadOptions(), "key", &value);
delete db;}

RocksDB-Cloud 增加了 CloudFileSystemOptions,用来设置 S3 相关参数以及管理本地文件的配置参数。RocksDB-Cloud 支持设置两个 S3 bucket 配置,主要用来实现数据 clone 功能,即 src_bucket 中的数据为被 clone 的只读数据,dest-bucket 为新生成的 sst 文件。代码中初始化的 CloudFileSystem 是 FileSystem 的派生类,实现了本地文件与 S3 之间的数据传递。

下图为 RocksDB-Cloud 实例启动之后在本地目录下生成的文件列表。对比原生的 RocksDB,主要的不同点包括:1. 新增了 CLOUDMANIFEST 文件;2. sst 文件和 MANIFEST 文件名增加了后缀。

图片

MANIFEST 与 sst 文件名的后缀在 RocksDB-Cloud 中称为 epochId,进程启动后会生成一个随机的 epochId,之后 compaction/flush 生成的 sst 文件或产生的 MANIFEST 文件新增都会带上该 epochId 后缀。CLOUDMANIFEST 相当于保存了 MANIFEST 的元信息,记录了每个 epoch 生成的 sst 文件 filenumber。因此对于每个 sst 文件,都可以通过 CLOUDMANIFEST 中记录的映射关系找到真实的文件名。在一主多从的架构中,这种方式可以防止多节点同时更新同一个 sst 文件导致数据冲突的问题。我们将在主从复制小节详细介绍。

下图为 RocksDB-Cloud 实例启动之后在对应的 S3 bucket 下创建的文件。分为两部分,一部分是 .rockset/dbid/, 这个前缀下的 object 记录了对应 RocksDB-Cloud 实例的 dbid 和其对应的 S3 上存储路径的映射关系。

图片

第二部分是 RocksDB-Cloud 实例在 S3 上的数据存储路径,保存了整个 RocksDB-Cloud 生成的所有文件,每个文件对应 S3 上的一个 object。

图片

存算分离

RocksDB-Cloud 可以用来实现存算分离,即全量数据及索引信息保存在 S3。为此,RocksDB-Cloud 通过自定义 FileSystem,实现了全量文件存储在 S3. 主要的类关系如下:

图片

在上图中,RocksDB 定义的四个虚基类为 FileSystem,FSSequentialFile,FSRandomAccessFile,FSWritableFile。FileSystem 为 RocksDB 抽象出的文件系统接口,定义了对文件或目录的操作。比如对文件顺序读取可以调用 FSRandomAccessFile() 构造 FSSequentialFile,其封装了顺序读取文件所需操作,使用场景如读取 WAL 或 MANIFEST 文件。对文件的随机读取通过调用 NewRandomAccessFile() 构造 FSRandomAccessFile,其封装了随机读文件所需要的操作,使用场景如从 sst 文件中读取数据。生成文件时使用 NewWritableFile(),构造 FSWritableFile 执行文件追加写及 Sync 操作。对于数据存储在本地磁盘的场景,RocksDB 提供了默认的实现,如 PosixFileSystem。

CloudFileSystem 继承并扩展了 FileSystem,增加了两个成员变量 base_fs_ 和 cloud_fs_options. base_fs_ 用来操作本地磁盘文件,cloud_fs_options 中定义 S3 访问相关参数和类。CloudFileSystem 只是声明了一些操作远端 S3 对象的方法,并没有真正实现虚函数,两个成员变量提供给派生类使用。

CloudFileSystemImpl 真正实现了 FileSystem 和 CloudFileSystem 中定义的虚函数,当然受限于 S3 所支持的语义,部分接口(如 LockFile,renamefile) 只是在 base_fs_ 层面做了实现或者不能保证原子语义。

S3ReadableFile 实现了 FSRandomAccessFile 和 FSSequentialFile 接口,Read 方法都是根据指定的 offset 从 S3 上下载指定 Range 数据。这种方式不会在本地磁盘存储数据,但缺点是会同步地从 S3 上获取数据,请求延迟大。所以 CloudFileSystemImpl 的 NewSequentialFile() 接口的另一种实现是同步地将文件下载本地磁盘,然后使用 base_fs->NewSequentialFile() 实现。S3WritableFile 实现了 FSWritableFile 接口,其思路是在执行 Append 操作时只操作本地磁盘的临时文件,在 S3WritableFile 执行 close 或 sync 时将文件上传到 S3。这里针对不同类型的文件采取了不同策略,如果打开的文件是 MANIFEST 文件,那么在 sync 被调用时就需要将文件内容上传,保证最新的 VersionEdit 信息不丢失。而对于 sst 文件,则是在 Close 时将整个 sst 文件上传。

接下来以上述 demo 中 flush 流程为例,简单介绍其工作流程。

在 Flush 开始前,首先会创建 sst 文件。对应的调用栈为:

图片

在将所有数据都写入 sst 文件之后,执行 close,对应的调用栈如下。可以看到对于 sst 文件在 close 时会同步上传到 S3.

图片

RocksDB 在文件生成之后,会加入到 TableCache 中,因此需要重新打开刚刚生成的 sst 文件,对应的调用栈为:

图片

最后,在 Flush 操作完成时,RocksDB 会将变更的 VersionEdit 追加到 MANIFEST 文件之后执行 sync,对应的调用栈为:

图片

至此,在 Flush 执行完成时,新产生的 sst 文件以及变更后的 MANIFEST 文件都上传到 S3。

启动流程

RocksDB-Cloud 启动流程如下所示:

DBCloud::Open(const Options& opt)--> SanitizeDirectory--> 检查是否要重建本地目录,检查的条件包括:
1. 本地目录,CURRENT,CLOUDMANIFEST, IDENTITY 文件是否存在
2. 本地 db_id 与远端 S3 上 db_id 比较
--> 如果校验失败需要重建,删除本地 db 目录下除 LOG 外所有文件,从 S3 下载 IDENTITY 文件到本地
--> LoadCloudManifest--> 从 S3 下载 CLOUDMANIFEST 文件到本地,CLOUDMANIFEST 文件支持指定 cookie,即 CLOUDMANIFEST 文件后缀
--> 解析 CLOUDMANIFEST 文件,其中记录了每个 filenumber 对应的 rocksdb-cloud 的 epoch
--> 根据 CLOUDMANIFEST 文件解析结果,从 S3 下载最新 epoch 的 MANIFEST 文件。
--> 根据 CLOUDMANIFEST 清理无效的文件,即检查每个 sst 文件的 epoch 是否与 CLOUDMANIFEST 中对应,如果不对应,清理掉。
--> 根据 roll_cloud_manifest_on_open 参数设置,生成新的 epoch 并记录到 CLOUDMANIFEST 中,上传最新的 MANFIEST 和 CLOUDMANIFEST。
--> DB::Open--> 正常的 RocksDB 启动流程

相比与 RocksDB 启动方式,RocksDB-Cloud 增加了对本地目录和文件的校验逻辑,同时引入了 CLOUDMANIFEST。CLOUDMANIFEST 记录了 sst file_number 和其文件名后缀的映射关系。如下图所示,CLOUDMANIFEST 包含了 RocksDB-Cloud 创建以来所有的 EpochId 以及下一个 EpochId 的起始 filenumber。

图片

每次 RocksDB-Cloud 进程启动可以选择是否生成一个新的 epochId,之后该进程生成的所有 sst 文件都包含相同的 epochId 后缀名。同理,在读取一个 sst 文件时,同样需要根据 filenumber 在 CLOUDMANIFEST 中找到对应的 epochId,进而确定完整的 sst 文件名。

主从复制

RocksDB 作为单机存储引擎,在 KV 等存储产品中被广泛使用。以 Pika 为例,上层服务为保证高可用会部署多个副本,副本间使用 Binlog 进行数据同步,每个副本独立地写 MemTable 以及发起 Flush/Compaction 操作, 额外占用计算节点的 CPU 和磁盘 IO。

在 RocksDB-Cloud 中,全量数据存储在共享存储 S3 上,这样从副本就不需要做 Flush 以及 Compaction,以只读的方式使用主副本的数据即可,这样可以大大减少计算节点资源消耗。这种实现方式需要解决的四个问题是:

  • 主副本 Memtable 数据如何同步到从副本。

  • 从副本如何获取主副本增量的元数据变更信息。

  • 日志服务消费位点如何存储。

  • 出现网络分区或计算节点假死导致出现多个"主副本"时,如何避免对共享数据的写冲突。

为解决第一和第二个问题,RocksDB-Cloud 提供了 ReplicationLogListener 类方法供上层服务获取 Memtable 实时数据和 VersionEdit 元数据变更,通过 ApplyReplicationLogRecord 方法实现实时数据和元信息在从副本的加载。相关类定义如下:

struct ReplicationLogRecord {enum Type { kMemtableWrite, kMemtableSwitch, kManifestWrite };Type type;std::string contents;};class ReplicationLogListener {public:virtual ~ReplicationLogListener() = default;virtual std::string OnReplicationLogRecord(ReplicationLogRecord record) = 0;};

ReplicationLogRecord 共三种数据类型:kMemtableWrite 表示用户数据,kMemTableSwitch 表示 Master 节点 switch 事件,kManifestWrite 表示 MANIFEST 变更事件,不同 contents 记录不同数据内容。

在主副本一侧,OnReplicationLogRecord 函数会在上述写 Memtable,Memtable Switch 以及 Write MANIFEST 之前被触发,用户可以在该回调函数中写 LOG,并将 LOG 同步给从副本。

在从副本一侧,消费到 LOG 数据之后,与 Master 对应的,根据事件的不同分别执行 Write Memtable,Swith Memtable 以及 Apply VersionEdit。

其整体流程如下所示:

图片

通过日志同步,从副本就可以追上主副本的数据,已经持久化到MANIFEST文件中的元信息数据通过ApplyVersionEdit加载到内存,同时从 S3 上可以下载新增的 sst 文件,内存中的数据在从副本中重放即可。因此,只要保证日志数据不丢失不乱序,基于 RocksDB-Cloud 可以较为方便的实现一个数据高可靠的存算分离服务。

主从复制中另一个需要解决的问题是消费位点的存储,也就是上文中提到的问题 3. 不管是主副本还是从副本,其全量数据都是由日志服务中记录的 Memtable 数据和 S3 上记录的 sst 文件构成。节点重启之后,需要从 S3 上恢复 sst 文件,从日志服务指定消费位点开始恢复 Memtable 数据,同时消费位点需要与 RocksDB-Cloud 存量数据匹配。RocksDB-Cloud 通过引入 replication_sequence 记录位点,replication_sequence 在 OnReplicationLogRecord 回调函数中生成,并随着 Flush 事件完成持久化到 MANIFEST 文件中。简单流程如下:

图片

图中,小写字母为 Memtable 数据,大写字母 S 表示 Memtable Switch 事件,大写字母 F 表示 Flush 事件,数字表示对应数据在日志服务中的序列号,单调递增。如图所示,整个过程简述为四个阶段:

  • 节点依次写入 a, b, c, d 四条数据到 Memtable 和日志服务。此时如果进程重启,需要从 1 的位置开始消费日志。

  • Memtable 写满触发 Switch 事件,Switch 事件信息通过 OnReplicationLogRecord 回调函数写日志服务,同时将该消息对应的 LogId 作为 replication_sequence 返回给 RocksDB-Cloud. RocksDB-Cloud 将 replication_sequence 记录到对应的 Immutable Memtable 中(成员变量)。之后继续写入用户数据 e。此时消费位点仍然存储在本地内存,重启恢复时仍然要从 1 开始回放。

  • 后台线程执行 Flush 完成,此时会将 2 中记录的 replication_sequence 与 Flush 事件一同持久化到 MANIFEST 文件并上传 S3. 在上传完成之后,进程重启时重放 MANIFEST 可以获得消费位点为 5,所以从 6 的位置开始消费,由于 a, b, c, d 已经持久化到 sst 文件中,因此从 6 开始消费不会出现数据重复或数据丢失。

  • 接续写入用户数据 f, g, h 并触发 Memtable Switch。由于图中只有 Switch 事件没有对应的 Flush 事件,因此在该阶段发生进程重启,消费的位点仍然是 6.

  • 最后一个需要解决的问题即多个节点更新共享数据导致数据冲突,RocksDB-Cloud 通过引入 epochId 和 cookie 来解决。

首先介绍 epochId 的作用。某个副本在成为主副本之后,会生成一个新的 epochId,之后由该节点生成的 sst 文件和 MANIFEST 文件都包含 epochId 后缀。由于每次切主都会导致 epochId 变更,每个主副本都会生成不定数量的 sst 文件,因此 CLOUDMANIFEST 记录每个 epochId 对应的起止 sst 文件 filenumber,用来根据 sst file-number 定位到具体的文件。当出现两个主节点都更新 S3 时,由于其对应的 epoch 值不相同,因此其更新的 sst 文件或者 MANIFEST 文件在 S3 上对应的是不同的 object,并没有数据冲突。

新老主副本更新的 sst 文件或者 MANIFEST 文件通过上述策略可以进行避免,但 CLOUDMANIFEST 文件还是可能会出现更新冲突的情况。此时可以使用 cookie 值来解决。具体就是 RocksDB-Cloud 在启动时,可以由上层服务设置两个 cookie 值,一个为 open 过程中要读取的 CLOUDMANIFEST 文件的后缀,另一个是 open 完成之后新的 CLOUDMANIFEST 内容要写到的文件名后缀。上层服务保存并提供 cookie 值保证唯一。

举例来说,假设在一个一主一从的主从架构中,主节点 epochId 为 e1,生成了 sst 文件 1,2,3,之后某种原因 hang 住导致无法响应中心节点的心跳包。中心节点检测到心跳丢失之后执行切主,原来的 slave 提升为新 master,新 epochId 为 e2,从 4 开始生成新的文件。此时如果老 master 节点仍然继续生成并且上传 sst 文件,两个节点生成的 sst 文件 filenumber 就可能会冲突。而由于我们在文件名末尾追加了 epochId,即使 filenumber 相同生成的 sst 文件也不会冲突。至于 CLOUDMANIFEST 文件本身的写冲突,Rocksdb-cloud 支持在 open 时传入指定新老 cookie,即 CLOUDMANIFEST 后缀名,也可以保证唯一性。

优化建议
串行上传

RocksDB-Cloud 使用的 S3Client 只能串行上传数据到 S3,对于 Level 0 上的大文件,上传耗时较大,进而导致 Flush 时间变长,在持续写入的情况,会更容易触发 RocksDB 的限速或停写策略。

S3CrtClient 默认支持了并行的分片上传,可以优化与 S3 的数据交互速度。

阻塞上传 / 下载

从上述存算分离的实现源码分析中可以看到,sst 文件在执行 Close 时会阻塞上传到 S3,等到所有 sst 文件上传完成之后才会去更新 MANIFEST 文件,然后再串行上传。考虑到 S3 请求延时相比与写本地文件要高,这种完全串行执行的方式在一次 Compaction 生成多个 sst 文件时会导致耗时明显增加。

图片

一种改进的办法是使用异步上传的接口,回调函数中封装一个 promise,Compaction 线程发起文件异步上传之后继续执行写其他 sst 文件的逻辑,直到最后要上传 MANIFEST 时,阻塞等待所有 future 都拿到值之后再上传 MANIFEST。如果中间某个 sst 文件上传失败,阻塞等待后退化为同步上传即可。由于 MANIFEST 文件更新之前,其生成的 sst 文件都是不可见的,因此也并不会导致数据不一致。两种方式流程对比如上图所示。

另外,RocksDB-Cloud slave 节点在 Apply Master 节点的 VersionEdit 时,对于新增的 sst 文件会加到 TableCache 中,根据之前的代码介绍,NewRandomAccessRead() 同样会阻塞下载 sst 文件,对于一次 VersionEdit 中记录了多个新 sst 文件时,同样可以使用异步下载然后统一 Wait 的方式。

文件本地缓存

RocksDB-Cloud 支持了基于本地磁盘的 Cache 功能,但 insert 到 Cache 只会在从 S3 上下载时触发。对于 Flush 或是 Compaction 新生成的 SST 文件会在上传到 S3 之后立刻删掉。但是新生成的 sst 文件在 Flush/Compaction 完成时会加入到 TableCache 中,此时由于本地文件不存在,会额外引入一次 S3 的访问。所以,在开启本地 cache 的情况下,在文件生成时就直接加入到 cache,只要本地 cache 足够放下当前 sst 文件就可以避免这个问题。

小   结

本文对 RocksDB-Cloud 进行了源码分析,着重介绍了其与 S3 交互的实现以及上层服务如何基于 RocksDB-Cloud 实现主从复制。基于 RocksDB-Cloud,我们内部开发了 Pikiwidb(原名 Pika) 存算分离版 (暂未开源)。