前言
很多时候,我们使用数据库时会有离线向数据库导入数据的需求。比如大量用户在本地的一些离线数据,想要将这一些数据导入到已有的数据库中;或者说NewSQL场景中部分机器离线,重新上线之后的数据增量/全量同步 等场景。这个时候 我们并不想要让这一些数据占用过多的系统资源,更不希望他们对正常的线上业务有影响,所以尽可能高效得完成这一些数据的同步就需要深入设计一番。
而如果底层引擎使用的是rocksdb,那就非常省事了,只需要组织好你们的数据调用接口就完事了,剩下的导入过程由引擎完成。 tikv便是通过 rocksdb的这个功能完成集群异常恢复之后 region之间的全量增量同步的。回到今天我们要讨论的主题,便是rocksdb的这个数据导入过程是如何尽可能快、尽可能高效得完成的。
使用方式
讲解实现原理之前我们先看看如何使用这个功能,功能的易用性也很重要,用户还是希望尽可能得少写代码来完成这个工作。使用上主要是两部分:创建SST文件 和 导入SST文件。
-
创建sst文件:这一步主要是通过一个sst_filter_writer,将需要导入的 k/v 数据转换成sst文件
需要注意的是:
- 用户k/v 数据需要按照options.comparator 严格有序,默认是按照key的字典序
- 这里的options 建议和db写入的options用一套(压缩选项,sst文件相关选项等)
Options options; SstFileWriter sst_file_writer(EnvOptions(), options); // 指定形成的sst文件的路径 std::string file_path = "/home/usr/file1.sst"; // open file_path Status s = sst_file_writer.Open(file_path); for (...) { // 写入sst,用户保证k/v 的顺序 s = sst_file_writer.Put(key, value); if (!s.ok()) { printf("Error while adding Key: %s, Error: %s\n", key.c_str(), s.ToString().c_str()); return 1; } } // 完成写入 s = sst_file_writer.Finish();
-
导入sst文件:这个步骤就是将创建好的一个或者多个sst文件导入到db中,也允许向多个cf中导入
IngestExternalFileOptions ifo;
// Ingest the 2 passed SST files into the DB
// 导入数据
Status s = db_->IngestExternalFile({"/home/usr/file1.sst", "/home/usr/file2.sst"}, ifo);
使用还是比较简单的,整体的使用过程如下:
#include <iostream>
#include <vector>
#include <gflags/gflags.h>
#include <rocksdb/db.h>
#include <rocksdb/env.h>
#include <rocksdb/sst_file_writer.h>
#define DATA_SIZE 10
#define VALUE_SIZE 1024
using namespace std;
// 比较函数
bool cmp(pair<string, string> str1,
pair<string, string> str2) {
if(str1.first < str2.first) {
return true;
} else if (str1.first == str2.first
&& str1.second < str2.second) {
return true;
} else {
return false;
}
}
// 随机字符串
static string rand_data(long data_range) {
char buff[30];
unsigned long long num = 1;
for (int i = 0;i < 4; ++i) {
num *= (unsigned long long )rand();
}
sprintf(buff, "%llu", num % (unsigned long long)data_range );
string data(buff);
return data;
}
// 构造有序数据
void construct_data(vector<pair<string,
string>> &input) {
int i;
string key;
string value;
for (i = 0;i < DATA_SIZE; i++) {
if(key == "0") {
continue;
}
key = rand_data(VALUE_SIZE);
value = rand_data(VALUE_SIZE);
input.push_back(make_pair(key, value));
}
}
void traverse_data(vector<pair<string,string>> input) {
int i;
for(auto data : input) {
cout << data.first << " " << data.second << endl;
}
}
// 创建sst文件
int create_sst(string file_path) {
vector<pair<string,string>> input;
vector<pair<string,string>>::iterator input_itr;
rocksdb::Options option;
/* open statistics and disable compression */
option.create_if_missing = true;
option.compression = rocksdb::CompressionType::kNoCompression;
rocksdb::SstFileWriter sst_file_writer(rocksdb::EnvOptions(), option);
rocksdb::Status s = sst_file_writer.Open(file_path);
if (!s.ok()) {
printf("Error while opening file %s, Error: %s\n",
file_path.c_str(),s.ToString().c_str());
return 1;
}
// 需要保证数据有序后再写入
construct_data(input);
sort(input.begin(), input.end(), cmp);
traverse_data(input);
// Insert rows into the SST file, note that inserted keys must be
// strictly increasing (based on options.comparator)
for (input_itr = input.begin(); input_itr != input.end();
input_itr ++) {
rocksdb::Slice key(input_itr->first);
rocksdb::Slice value(input_itr->second);
s = sst_file_writer.Put(key, value);
if (!s.ok()) {
printf("Error while adding Key: %s, Error: %s\n",
key.ToString().c_str(),s.ToString().c_str());
return 1;
}
}
// Close the file
s = sst_file_writer.Finish();
if (!s.ok()) {
printf("Error while finishing file %s, Error: %s\n",
file_path.c_str(),s.ToString().c_str());
return 1;
}
return 0;
}
static rocksdb::DB *db;
void create_db() {
rocksdb::Options option;
/* open statistics and disable compression */
option.create_if_missing = true;
option.compression = rocksdb::CompressionType::kNoCompression;
rocksdb::Status s = rocksdb::DB::Open( option,"./db", &db);
if (!s.ok()) {
printf("Open db failed : %s\n", s.ToString().c_str());
return;
}
}
void db_write(int num_keys) {
rocksdb::WriteOptions write_option;
write_option.sync = true;
rocksdb::Slice key;
rocksdb::Slice value;
rocksdb::Status s;
int i;
printf("begin write \n");
for (i = 0;i < num_keys; i++) {
key = rand_data(VALUE_SIZE);
value = rand_data(VALUE_SIZE);
s = db->Put(write_option, key, value);
if (!s.ok()) {
printf("Put db failed : %s\n", s.ToString().c_str());
return;
}
}
db->Flush(rocksdb::FlushOptions());
printf("finish write \n");
}
int main() {
// 先写入一批数据
create_db();
db_write(100000);
// 创建sst文件
if (create_sst("./test.sst") == 0) {
printf("creates sst success !\n");
} else {
printf("creates sst failed !\n");
}
// 导入数据
rocksdb::IngestExternalFileOptions ifo;
// Ingest the 2 passed SST files into the DB
printf("Ingest sst !\n");
rocksdb::Status s = db->IngestExternalFile({"test.sst"}, ifo);
if (!s.ok()) {
printf("Error while adding file test.sst , Error %s\n",
s.ToString().c_str());
return 1;
}
return 0;
}
运行输出如下:
begin write
finish write
# consturct data,需按照字典序,如果没有按照字典序构造的话会报错
1008 232
240 880
288 63
410 768
506 56
534 256
640 180
72 248
800 672
944 217
creates sst success !
通过db日志可以看到我们创建的sst文件test.sst
被成功导入到db,形成了./db/000020.sst
,且在db目录中。
╰─$ cat db/LOG |grep ingested
[AddFile] External SST file test.sst was ingested in L0 with path ./db/000020.sst (global_seqno=200012)
╰─$ ls db
000017.log 000020.sst IDENTITY LOG LOG.old.1618643738564935 OPTIONS-000008
000019.sst CURRENT LOCK LOG.old.1618123487361092 MANIFEST-000013 OPTIONS-000016
实现原理
从如何使用这个功能上我们能够感觉到这一些数据并不是通过rocksdb正常的I/O流程写入的。如果使用正常的接口,那我们用户不需要排序,而是直接通过db->Put
接口将k/v写入,凡事都有但是,但是这样来导入离线数据在rocksdb内部后续的flush/compaction 都会消耗大量的系统资源,而这并不是我们想要的高效。所以,rocksdb提供的ingest
接口肯定不会让这一些要导入的数据消耗过多的资源,接下来我们一起看看底层的详细实现。
为了更形象得告诉大家在rocksdb作为存储引擎的场景,如果通过传统的put接口导入数据会多出哪一些I/O,如下图
其中红色的尖头 是ingest file 相比于传统的put接口 少的I/O部分,可以说ingest方式导入数据极大得节约了整个系统资源的开销(包括但不限于I/O , CPU 资源的开销)。
下面主要介绍的是有了sst文件,接下来如何导入到db中的过程。关于通过sst_file_writer
创建具体的sst文件的过程就不多说了,也就是按照sst文件的格式(datablock,index block…footer)等将有序的数据一个个添加进去而已。
主要有如下几步:
- 为待插入的sst文件创建file link到db目录,或者直接拷贝进去
- 停止写入,需要保证即将导入的sst文件在db中拥有一个安全合理的seqno,如果持续写入,那这个seqno可能不会全局递增了。
- 检查导入的sst文件是否和memtable中的key-range有重叠,有的话需要flush memtable
- 为这个sst文件 按照其key-range挑选一个合适的level放进去
- 为这个问天添加一个全局的seqno
- 恢复db的写入
其中停止写入到恢复写入这段时间对于用户来说越小越好,所以ingest的性能很重要。
接下来看看详细的源代码实现:
导入数据的函数入口是DBImpl::IngestExternalFiles
导入的sst文件最后都需要形成一个db内部的sst文件,因为这个时候已经停止写入了,所以会从最新的sst文件编号之后取一个文件编号,后续的其他要导入的sst文件会不断追加。
Status DBImpl::IngestExternalFiles(
const std::vector<IngestExternalFileArg>& args) {
...
// 构造文件编号到next_file_number中
Status status = ReserveFileNumbersBeforeIngestion(
static_cast<ColumnFamilyHandleImpl*>(args[0].column_family)->cfd(), total,
pending_output_elem, &next_file_number);
if (!status.ok()) {
InstrumentedMutexLock l(&mutex_);
ReleaseFileNumberFromPendingOutputs(pending_output_elem);
return status;
}
...
}
有了在db内部的合法文件编号,我们就可以进行文件迁移了,将待导入的sst文件迁移到db内部已经构造好的sst文件编号之中。
会为每一个cf构造一个ingest_job, 将待导入文件拷贝/移动到 db内部的sst文件中,这个过程是在接下来的Prepare
函数中。
uint64_t start_file_number = next_file_number;
for (size_t i = 1; i != num_cfs; ++i) {
start_file_number += args[i - 1].external_files.size();
auto* cfd =
static_cast<ColumnFamilyHandleImpl*>(args[i].column_family)->cfd();
SuperVersion* super_version = cfd->GetReferencedSuperVersion(this);
// prepare 函数
exec_results[i].second = ingestion_jobs[i].Prepare(
args[i].external_files, start_file_number, super_version);
exec_results[i].first = true;
CleanupSuperVersion(super_version);
}
看看Prepare
的函数实现:
- 拿着输入的多个sst文件,如果有多个,则需要检查这一些文件之间是否有重叠key,有的话就不支持了(rocksdb除了l0,其他层不允许有重叠key)。
- 根据用户指定的ingest option: move_files 是否为true,来将待导入文件move到db中, 如果move失败了就拷贝文件。
Status ExternalSstFileIngestionJob::Prepare(
const std::vector<std::string>& external_files_paths,
uint64_t next_file_number, SuperVersion* sv) {
// 解析文件信息
for (const std::string& file_path : external_files_paths) {
IngestedFileInfo file_to_ingest;
status = GetIngestedFileInfo(file_path, &file_to_ingest, sv);
if (!status.ok()) {
return status;
}
files_to_ingest_.push_back(file_to_ingest);
}
// 确保导入的多个sst文件之间没有重叠
......
} else if (num_files > 1) {
// Verify that passed files dont have overlapping ranges
autovector<const IngestedFileInfo*> sorted_files;
for (size_t i = 0; i < num_files; i++) {
sorted_files.push_back(&files_to_ingest_[i]);
}
std::sort(
sorted_files.begin(), sorted_files.end(),
[&ucmp](const IngestedFileInfo* info1, const IngestedFileInfo* info2) {
return sstableKeyCompare(ucmp, info1->smallest_internal_key,
info2->smallest_internal_key) < 0;
});
// 如果有重叠的话,ingest也无法支持,因为在db中大于level0的更高层level内部的
// sst文件之间是不允许有重叠的,加速更高层的二分查找。
for (size_t i = 0; i < num_files - 1; i++) {
if (sstableKeyCompare(ucmp, sorted_files[i]->largest_internal_key,
sorted_files[i + 1]->smallest_internal_key) >= 0) {
files_overlap_ = true;
break;
}
}
}
......
// 根据用户参数move文件
if (ingestion_options_.move_files) {
status = env_->LinkFile(path_outside_db, path_inside_db);
...
} else { // 否则就拷贝文件
f.copy_file = true;
}
if (f.copy_file) {
TEST_SYNC_POINT_CALLBACK("ExternalSstFileIngestionJob::Prepare:CopyFile",
nullptr);
// CopyFile also sync the new file.
status = CopyFile(env_, path_outside_db, path_inside_db, 0,
db_options_.use_fsync);
}
...
}
到此,文件就已经进入到了rocksdb 之中,ingest_job的prepare流程就结束了。
接下来 就到了我们前面介绍总步骤的第二步,停止用户对当前db的写入:
DBImpl::IngestExternalFiles
WriteThread::EnterUnbatched
其中WriteThread::EnterUnbatched
函数会让当前db的写入线程都处于wait状态。
接下来就是检查当前要导入的文件是否和memtable中的key-range有重叠,函数调用如下:
DBImpl::IngestExternalFiles
ExternalSstFileIngestionJob::NeedsFlush
ColumnFamilyData::RangesOverlapWithMemtables
这个函数ColumnFamilyData::RangesOverlapWithMemtables
会拿着从ingest files中构造好的key-range和memtable中的 key-range 进行对比,如果有重叠key,则会将memtable flush置为true
Status ColumnFamilyData::RangesOverlapWithMemtables(
const autovector<Range>& ranges, SuperVersion* super_version,
bool* overlap) {
...
Status status;
// 拿着ingest files的range中的每一个key,看是否能够从memtable中找到
for (size_t i = 0; i < ranges.size() && status.ok() && !*overlap; ++i) {
auto* vstorage = super_version->current->storage_info();
auto* ucmp = vstorage->InternalComparator()->user_comparator();
InternalKey range_start(ranges[i].start, kMaxSequenceNumber,
kValueTypeForSeek);
// 从memtable中找
memtable_iter->Seek(range_start.Encode());
status = memtable_iter->status();
ParsedInternalKey seek_result;
if (status.ok()) {
if (memtable_iter->Valid() &&
!ParseInternalKey(memtable_iter->key(), &seek_result)) {
status = Status::Corruption("DB have corrupted keys");
}
}
// 找到了,则置overlap为true
if (status.ok()) {
if (memtable_iter->Valid() &&
ucmp->Compare(seek_result.user_key, ranges[i].limit) <= 0) {
*overlap = true;
} else if (range_del_agg.IsRangeOverlapped(ranges[i].start,
ranges[i].limit)) {
*overlap = true;
}
}
}
...
}
在后续的DBImpl::FlushMemTable
函数中会flush memtable,不同的cf是分开进行的
DBImpl::IngestExternalFiles
DBImpl::FlushMemTable
接下来就开始了第四步和第五步的处理逻辑,需要为每一个落到db中的sst文件挑选合适的level以及分配全局seqno,处理逻辑在Run
函数中:
DBImpl::IngestExternalFiles
ExternalSstFileIngestionJob::Run
主要处理逻辑如下:
一个一个ingest file进行处理
-
选择一个合适的level,将ingest file插入进去
如果user配置了allow_ingest_behind=true
,即允许导入的数据直接插入到最后一层的文件位置,且ingest的时候配置的ingest option中ingest_behind=true
,则会先尝试插入到bottomest level
,如果最后一层的文件和待插入的文件有重叠,则插入失败。处理逻辑在CheckLevelForIngestedBehindFile
函数之中。否则逐层遍历,优先找到和当前文件key-range不重叠的最大层,如果找的过程中发现该文件 和当前level 中的sst key-range 有重叠,则直接填充 file_to_ingest->picked_level = 0,逻辑是在 函数
AssignLevelAndSeqnoForIngestedFile
中。感谢 @东东儿 同学指出了这里的错误描述!!!
-
找到了合适的level的同时会记录一个
assigned_seqno
,是在当前last_sequence
的基础上+1得到的。函数AssignLevelAndSeqnoForIngestedFile
之中。 -
为当前ingest_file 写入一个global seq no, 并执行fsync/sync。函数
AssignGlobalSeqnoForIngestedFile
之中。 -
最后就是将当完成更新的ingest file的元信息更新到
VersionEdit
之中。
接下来就进入尾声了:
- 将更新的
VersionEdit
写入到MANIFEST文件之中 - 更新每个ingest file对应的cf信息,并且调度compaction/flush, 因为之前ingest file时找的是有重叠key的一层。
- 恢复db的写入
// 将`VersionEdit`写入到MANIFEST文件之中
status =
versions_->LogAndApply(cfds_to_commit, mutable_cf_options_list,
edit_lists, &mutex_, directories_.GetDbDir());
}
if (status.ok()) {
for (size_t i = 0; i != num_cfs; ++i) {
auto* cfd =
static_cast<ColumnFamilyHandleImpl*>(args[i].column_family)->cfd();
if (!cfd->IsDropped()) {
//更新每个ingest file对应的cf信息,并且调度compaction/flush, 因为之前ingest file时找的是有重叠key的一层
InstallSuperVersionAndScheduleWork(cfd, &sv_ctxs[i],
*cfd->GetLatestMutableCFOptions());
...
}
}
}
// 恢复db的写入,唤醒db的其他所有的writer
write_thread_.ExitUnbatched(&w);
到此,整个ingest就算是结束了。
总结
通过ingest的实现,我们能够看到rocksdb通过ingest的方式支持离线数据导入确实能够极大得降低系统资源的开销。不需要一个key在LSM中被反复的写入、读取。