文章目录
前言
本文介绍过程中涉及到的源代码是基于rocksdb 6.4.6
版本的。
同时需要对rocksdb的Compaction逻辑以及源代码有较为深入的了解,相关的原理介绍之前有一些粗浅的分析,对理解不够深入的同学应该有帮助。
1. SST文件详细格式源码解析
1. 问题背景
基于LSM的存储引擎 在提供了优秀的写性能的基础上 却因为compaction带来了很多其他问题。
本文所要描述的一个场景是LSM引擎的标记删除(将对一个key的删除作为一个key-value 追加写入到引擎之中),这种删除逻辑会带来如下几个问题:
- 读放大:range scan 的时候需要扫描更多的记录,点查询Bloom Filter 的false positive概率增加。
- 空间放大:因为delete的type 也是一个key-value,而这种类型的key只有在compaction到最后一层才会被删除,中间层的很多空间就会被浪费在delete的存储之上。
- 写放大:本身LSM compaction带来的原有问题,而针对delete key的频繁调度compaction 带来的写放大问题更不友好。
- 用户隐私:用户已经标记删除的数据并没有及时删除,未到最后一层,无法真正删除。
如上图:标记D的都是含有delete 的sst文件,加入我们想要查找的key在L2,这个key其实之前已经被删除了。但是数据并没有被清理,对应sst文件的bloom filter也没有更新,所以无法准确判断改key是否还在,则需要一直读到 purpose key所在的sst文件的data block,发现这个key时delete type,最后才向客户端返回not found。
这个查找过程存在数次I/O操作,读sst文件的 filter block,index block, 整个data block,真删除的不存在key的查找 多了 两个block的IO流程(index block 和 data block),代价可想而知。
2. 问题复现
因为业务中存在这样的问题,所以直接上代码,更加直观。
复现如下简单且经典场景(sql删除了一个表中80%的数据,后来想scan剩下的一部分数据):
写入1千万的相同前缀 且字典序递增的key,删除其中的的9百万,从头遍历 直到取得未删除的前500条key。
站在用户的角度:
删除了一批key, 读取剩下的key中的100条
引擎的角度:
用户调用了删除接口,写入了大量的delete type的key。但是 delete type的key还在LSM 上层,一点一点做compaction到最后一层。所以用户调用一批scan 查找的过程相当于将已经标记delete 但是还未清理数据的key都得扫一遍。
通过实现一段rocksdb的复现代码如下:
#include <unistd.h>
#include <atomic>
#include <iostream>
#include <random>
#include <string>
#include <thread>
#include <sys/time.h>
#include "rocksdb/db.h"
#include "rocksdb/table.h"
#include "rocksdb/cache.h"
#include "rocksdb/filter_policy.h"
#include "rocksdb/write_batch.h"
#include "rocksdb/rate_limiter.h"
#include "rocksdb/perf_context.h"
#include "rocksdb/iostats_context.h"
#include "rocksdb/table_properties.h"
#include "utilities/table_properties_collectors.h"
#include "gflags/gflags.h"
#define VALUE_LEN 500
using namespace google;
DEFINE_int64(time,1200,"read threads' ratio");
DEFINE_int64(thread_num,64,"total threads ");
DEFINE_string(db_dir, "srd_delete", "db's dir is delete");
DEFINE_bool(use_fullcompaction, false, "use full compaction to db");
DEFINE_int64(num_keys, 1000000,"write keys' num");
DEFINE_int64(wait_compaction,10,"wait");
DEFINE_bool(traverse, true, "use traverse");
DEFINE_bool(use_delete_collector, false, "use traverse");
std::atomic<long> g_op_W;
rocksdb::DB* src_db;
rocksdb::Options options;
std::mt19937_64 generator_; // 生成伪随机数
// 返回微秒
inline int64_t now() {
struct timeval t;
gettimeofday(&t, NULL);
return static_cast<int64_t>(t.tv_sec)*1000000 + t.tv_usec;
}
// 打开rocksdb
void OpenDB() {
options.create_if_missing = true;
options.compression = rocksdb::kNoCompression;
options.disable_auto_compactions = FLAGS_use_fullcompaction;
if(FLAGS_use_delete_collector) {
options.table_properties_collector_factories.emplace_back(rocksdb::NewCompactOnDeletionCollectorFactory(1000000, 1000));
}
std::shared_ptr<rocksdb::Cache> cache = rocksdb::NewLRUCache(10737418240);
rocksdb::BlockBasedTableOptions bbto;
bbto.whole_key_filtering = true;
bbto.cache_index_and_filter_blocks = true;
bbto.filter_policy.reset(rocksdb::NewBloomFilterPolicy(16,false));
bbto.block_cache = cache;
options.table_factory.reset(rocksdb::NewBlockBasedTableFactory(bbto));
options.max_background_compactions = 32;
auto s = rocksdb::DB::Open(options, FLAGS_db_dir, &src_db);
std::cout << "open src_db" << s.ToString() << std::endl;
}
// 配置手动执行full compaction
void FullCompaction() {
rocksdb::Slice begin("");
rocksdb::Iterator *it = src_db->NewIterator(rocksdb::ReadOptions());
it->SeekToLast();
std::string end_str = it->key().ToString();
delete it;
rocksdb::Slice end(end_str);
auto s = src_db->CompactRange(rocksdb::CompactRangeOptions(), &begin, &end);
if (!s.ok()) {
std::cout << "CompactRange failed " << s.ToString() << std::endl;
}
}
// 写入 + 删除
void DOWrite(int num) {
int count = FLAGS_num_keys;
std::string value(VALUE_LEN, 'x');
while(count > 0) {
src_db -> Put(rocksdb::WriteOptions(), std::string("test_compaction_129_")+std::to_string(count),value);
++ g_op_W;
count --;
}
// 删除90% 的key
std::cout << "Begin delete "<< std::endl;
for (int i = 0;i < FLAGS_num_keys - FLAGS_num_keys / 10; ++i) {
src_db->Delete(rocksdb::WriteOptions(), std::string("test_compaction_129_")+std::to_string(i));
}
src_db->SetOptions(src_db->DefaultColumnFamily(),{{"diable_auto_compactions","true"}});
std::cout << "End delete \n";
// 开启full compaction
if(FLAGS_use_fullcompaction) {
FullCompaction();
sleep(FLAGS_wait_compaction);
}
}
void Traverse() {
// open perf
rocksdb::SetPerfLevel(rocksdb::PerfLevel::kEnableTimeExceptForMutex);
rocksdb::get_perf_context()->Reset();
rocksdb::get_iostats_context()->Reset();
rocksdb::Iterator *it = src_db->NewIterator(rocksdb::ReadOptions());
it->Seek("");
std::cout << "Traverse begin :" << std::endl;
int64_t ts = now();
int seek_count = 0;
for(; it->Valid()&& seek_count<500; it->Next() ) {
std::cout << it->key().ToString() << std::endl;
seek_count++;
}
delete it;
// close perf,查看internal_delete_skipped_count指标
rocksdb::SetPerfLevel(rocksdb::PerfLevel::kDisable);
std::cout << "Traverse use time :" << now() - ts << std::endl;
std::cout << "internal_delete_skipped_count "
<< rocksdb::get_perf_context()->internal_delete_skipped_count
<< std::endl;
}
int main(int argc, char** argv) {
ParseCommandLineFlags(&argc,&argv, true);
OpenDB();
for(int i = 0;i < FLAGS_thread_num; i++) {
new std::thread(DOWrite,i);
}
long last_opn_W = 0;
int count = FLAGS_time;
while(count > 0) {
sleep(1);
long nopn_W = g_op_W;
std::cout << "write_speed : " << nopn_W - last_opn_W << std::endl;
last_opn_W = nopn_W;
count --;
}
if(FLAGS_traverse){
Traverse();
}
return 0;
}
编译时需要加入gflags 的链接,最终可以通过如下命令进行测试:
测试原生compaction逻辑下 traverse过程的耗时
./test_delete -use_fullcompaction=false -time=300 -db_dir=./src_delete -thread_num=1 -num_keys=10000000 -use_delete_collector=false
可以发现最终的输出:
Traverse use time :2018292
也就是在客户端看来,只获取100个存在的key就话费2s的时间。。。这简直不能忍,这样的存储引擎如何让用户安心使用。
这里尝试使用 Full Compaction 进行优化,即rocksdb自己提供的手动compaction接口CompacRange
,在删除操作执行完成之后对整个db进行了一次全量的compaction操作,再次进行遍历获取100个存在key的耗时统计。
以上的代码也有增加,使用如下命令运行
./test_delete -use_fullcompaction=true -time=300 -db_dir=./src_delete -thread_num=1 -num_keys=10000000 -use_delete_collector=false
可以看到这次的运行耗时下降了3个量级,仅仅只有364us:
Traverse use time :364
那是不是我们这个问题就解决了呢?显然不可能。
全量compaction 带来的代价是极大的,所有的sst文件不论之前有没有参与过compaction都会被调度起来,巨量的I/O 和 CPU 洪峰,且跟随本节点数据量的大小持续一段时间;这个过程可能直接让系统的可用性极低甚至掉零,更为严重的是某位不讲武德的仁兄看到优化效果如此给力,不经过严格的测试直接线上跑 ,那这位仁兄就可以准备好挂个p0 走人了。
况且存储引擎作为持久化存储的核心,这样的危险操作显然不会交给用户来做,所以本身引擎内部要有足量机制降低 未compaction清理的delete key对scan性能的影响,这也是引擎的职责所在。
总结这个问题放在引擎中处理 的几个关节点 如下:
- delete的过程是通过compaction进行的,而compaction对于用户来说是黑盒,用户很难精确控制compaction过程中的delete key清理时机。
- 实际LSM的应用场景中 用户驱动删除的workload 相对较少,除了用户主动删除之外LSM 引擎也会应用在关系数据库之中 出现drop 表等被动删除场景,所以在用户不知道的情况下引擎也会产生大范围的数据清理和迁移。
- 对非连续的key的删除调度。比如key的相同前缀是基于table id,删除时则基于某一时间戳进行删除。这个过程需要调度seek进行大量的查找,代价极大。
接下来将介绍一下 rocksdb 以及 业界对该场景的优化手段。rocksdb这里比较熟悉,会深入源代码详细介绍一番;业界对该优化的过程将介绍几篇已有的优化论文。
3. Rocksdb 的 Delete-Aware 优化
基于以上问题,接下来会介绍Rocksdb 在该场景所做的两个优化feature。
Rocksdb 在保证不增加LSM 的读代价的情况下 针对delete type的key增加了两方面的优化逻辑:
- 用户态可配置的delete 密集型 sst文件的compaction优先级调度。
- compaction本身针对 delete key 逻辑的 处理优化。
3.1 可配置的 Delete-Aware调度
OpenDB
函数中增加如下rocksdb配置:
options.table_properties_collector_factories.emplace_back(rocksdb::NewCompactOnDeletionCollectorFactory(10000, 1000));
同时需要包含头文件:
#include "rocksdb/table_properties.h"
#include "utilities/table_properties_collectors.h"
查看以上函数声明:
std::shared_ptr<CompactOnDeletionCollectorFactory>
NewCompactOnDeletionCollectorFactory(
size_t sliding_window_size,
size_t deletion_trigger) {
return std::shared_ptr<CompactOnDeletionCollectorFactory>(
new CompactOnDeletionCollectorFactory(
sliding_window_size, deletion_trigger));
}
// A factory of a table property collector that marks a SST
// file as need-compaction when it observe at least "D" deletion
// entries in any "N" consecutive entires.
//
// @param sliding_window_size "N"
// @param deletion_trigger "D"
CompactOnDeletionCollectorFactory(size_t sliding_window_size,
size_t deletion_trigger)
: sliding_window_size_(sliding_window_size),
deletion_trigger_(deletion_trigger) {}
总结一下:
NewCompactOnDeletionCollectorFactory
函数声明一个用户配置的表格属性收集器,这里的表格指的是sstable。
该函数需要传入的两个参数sliding_window_size
和 deletion_trigger
表示删除收集器 的滑动窗口 和 触发删除的key的个数。
在每个sst文件内维护一个滑动窗口,滑动窗口维护了一个窗口大小,在当前窗口大小内出现的delete-key的个数超过了窗口的大小,那么这个sst文件会被标记为need_compaction_
,从而在下一次的compaction过程中被优先调度。
达到将delete-key较多的sst文件快速下沉到底层,delete的数据被真正删除的目的,可以理解为这是一种更加激进的compaction调度策略。
使用者仅仅需要调整两个参数的比例,即可决定该sst文件是否应该被优先调度compaction。
接下来详细说一下Rocksdb的这个配置如何实现deletes 超过一定比例之后被compaction优先调度:
用户配置了table_properties_collector_factories
在Compaction的核心处理key-value的函数CompactionJob::ProcessKeyValueCompaction
中调度 BlockBasedTableBuilder::Add
函数 构造sst文件中不同存储区域的builder,这里面会使用用户构造的collector 进行 指定key的收集,通过如下函数NotifyCollectTableCollectorsOnAdd
进入 InternalAdd
。
bool NotifyCollectTableCollectorsOnAdd(
const Slice& key, const Slice& value, uint64_t file_size,
const std::vector<std::unique_ptr<IntTblPropCollector>>& collectors,
Logger* info_log) {
bool all_succeeded = true;
for (auto& collector : collectors) {
Status s = collector->InternalAdd(key, value, file_size);
all_succeeded = all_succeeded && s.ok();
if (!s.ok()) {
LogPropertiesCollectionError(info_log, "Add" /* method */,
collector->Name());
}
}
return all_succeeded;
}
因我我们reset的是NewCompactOnDeletionCollectorFactory
collector,所以会通过UserKeyTablePropertiesCollector::InternalAdd
进入CompactOnDeletionCollector::AddUserKey
collector的AddUserKey
函数:
- 增加滑动窗口内 真正key的数量,并根据是否达到了窗口阈值 来及时更新内部key的数量 以及 降低旧的delete key的数量
- 根据传入的key的类型来 增加滑动窗口内 delete key的数量,达到删除阈值时会打入
need_compaction_
标记(这个标记会在后续的compaction逻辑中将当前sst文件标记为优先级较高的sst文件)
Status CompactOnDeletionCollector::AddUserKey(const Slice& /*key*/,
const Slice& /*value*/,
EntryType type,
SequenceNumber /*seq*/,
uint64_t /*file_size*/) {
...
// 这里的bucket_size_可以理解为我们设置的滑动窗口大小
// 第一个if语句中做的事情主要是更新滑动窗口中 key的数量 以及 旧的delete key的数量
if (num_keys_in_current_bucket_ == bucket_size_) {
// When the current bucket is full, advance the cursor of the
// ring buffer to the next bucket.
current_bucket_ = (current_bucket_ + 1) % kNumBuckets;
// Update the current count of observed deletion keys by excluding
// the number of deletion keys in the oldest bucket in the
// observation window.
assert(num_deletions_in_observation_window_ >=
num_deletions_in_buckets_[current_bucket_]);
num_deletions_in_observation_window_ -=
num_deletions_in_buckets_[current_bucket_];
num_deletions_in_buckets_[current_bucket_] = 0;
num_keys_in_current_bucket_ = 0;
}
num_keys_in_current_bucket_++;
// 根据key的类型来调整是否达到触发删除的阈值,从而打入need_compaction_标记
if (type == kEntryDelete) {
num_deletions_in_observation_window_++;
num_deletions_in_buckets_[current_bucket_]++;
if (num_deletions_in_observation_window_ >= deletion_trigger_) {
need_compaction_ = true;
}
}
...
}
在collector中达到触发删除的标记之后会在BlockBasedTableBuilder::NeedCompact()
中体现,而这个函数则会被CompactionJob::FinishCompactionOutputFile
调用
bool BlockBasedTableBuilder::NeedCompact() const {
for (const auto& collector : rep_->table_properties_collectors) {
if (collector->NeedCompact()) {
return true;
}
}
return false;
}
CompactionJob::FinishCompactionOutputFile
同样是在compaction的核心逻辑ProcessKeyValueCompaciton
中调用,以sst文件为单位 将构造好的一个个builder写入到具体的sst文件中的数据区域,写入过程中对要写入的sst文件打入标记(如果collector中确认delete keys超过了设置的阈值)
meta->marked_for_compaction = sub_compact->builder->NeedCompact();
marked_for_compaction
标记会在函数VersionStorageInfo::ComputeFilesMarkedForCompaction
被使用,其中将有marked_for_compaction标记文件添加到一个动态数组之中files_marked_for_compaction_
,这个数组会在当前compaction完成之后再次被调度,将动态数组中的sst文件优先进行compaction的调度。
void VersionStorageInfo::ComputeFilesMarkedForCompaction() {
files_marked_for_compaction_.clear();
int last_qualify_level = 0;
// Do not include files from the last level with data
// If table properties collector suggests a file on the last level,
// we should not move it to a new level.
for (int level = num_levels() - 1; level >= 1; level--) {
if (!files_[level].empty()) {
last_qualify_level = level - 1;
break;
}
}
for (int level = 0; level <= last_qualify_level; level++) {
for (auto* f : files_[level]) {
if (!f->being_compacted && f->marked_for_compaction) {
// 将被打入标记的文件添加到动态数组之中
files_marked_for_compaction_.emplace_back(level, f);
}
}
}
}
后续的调度过程会在DBImpl::BackgroundCompaction
中,通过如下逻辑调用 LevelCompactionPicker
的NeedsCompaction
来进行判断,判断的过程主要是确认files_marked_for_compaction_
不为空即返回true,从而再次将文件添加到当前column family的待compaction队列,最后通过MaybeScheduleFlushOrCompaction
再次调度。
if (cfd->NeedsCompaction()) {
// Yes, we need more compactions!
AddToCompactionQueue(cfd);
++unscheduled_compactions_;
MaybeScheduleFlushOrCompaction();
}
整个链路相对来说比较长且复杂的,需要对Compaction的调度代码较为熟悉的话会更容易理解。
最后能够在rocksdb的LOG日志来确认是否触发了Marked的逻辑:
"compaction_reason": "ManualCompaction",
这样的字段,且后续的Compaction job的输出中能够看到records in
的数据 和records dropped
的数据的比例满足我们设置的collector中的 sliding-window-size 和 delete-trigger的比例即可。
最后的性能可以通过上一章节中的 问题复现 的代码看到。
运行如下命令:
./test_delete -use_fullcompaction=false -time=300 -db_dir=./src_delete -thread_num=1 -num_keys=10000000 -use_delete_collector=true
可以看到相同的delete场景,最后的traverse性能相比于原来能够提升4倍,这个数据在不同的delete 比重以及collector设置的参数下有差异的。
Traverse use time :674629
3.2 Compaction 逻辑对 delete key的优化
Compaction调度过程不再深入描述了,直接到上小节说过的ProccessKeyValueCompaction
函数中会对key-value一个一个处理,会通过Compaction迭代器的移动(基本的SeekToFirst,Next等)来达到一个一个处理的目的。
处理的过程通过调用NextFromInput
函数来进行,这个函数会处理不同的key-type,其中关于delete类型的主要处理逻辑如下代码(这一部分全部粘贴到下面了,可以简单看看):
我们知道原本LSM 中的delete key 只能在最后一层将之前所有的写入包括自己都清理掉,因为到了最后一层就知道之前已经没有人再使用这个key了。
这里可能有人会问,用户已经删除了,为什么还一定要到最后一层才将key的数据清理掉呢?
因为rocksdb提供了snapshot功能,即在当前delete之前可能存在用户打入的snapshot,即rocksdb需要保证那个时刻的key能够被读到,所以删除的过程中都需要确认当前key之前版本是否有snapshot的sequence number,如果没有才允许后续的删除判断。
} else if (compaction_ != nullptr && ikey_.type == kTypeDeletion &&
IN_EARLIEST_SNAPSHOT(ikey_.sequence) &&
ikeyNotNeededForIncrementalSnapshot() &&
compaction_->KeyNotExistsBeyondOutputLevel(ikey_.user_key,
&level_ptrs_)) {
// TODO(noetzli): This is the only place where we use compaction_
// (besides the constructor). We should probably get rid of this
// dependency and find a way to do similar filtering during flushes.
//
// For this user key:
// (1) there is no data in higher levels
// (2) data in lower levels will have larger sequence numbers
// (3) data in layers that are being compacted here and have
// smaller sequence numbers will be dropped in the next
// few iterations of this loop (by rule (A) above).
// Therefore this deletion marker is obsolete and can be dropped.
//
// Note: Dropping this Delete will not affect TransactionDB
// write-conflict checking since it is earlier than any snapshot.
//
// It seems that we can also drop deletion later than earliest snapshot
// given that:
// (1) The deletion is earlier than earliest_write_conflict_snapshot, and
// (2) No value exist earlier than the deletion.
++iter_stats_.num_record_drop_obsolete;
if (!bottommost_level_) {
++iter_stats_.num_optimized_del_drop_obsolete;
}
input_->Next();
} else if ((ikey_.type == kTypeDeletion) && bottommost_level_ &&
ikeyNotNeededForIncrementalSnapshot()) {
// Handle the case where we have a delete key at the bottom most level
// We can skip outputting the key iff there are no subsequent puts for this
// key
ParsedInternalKey next_ikey;
input_->Next();
// Skip over all versions of this key that happen to occur in the same snapshot
// range as the delete
while (input_->Valid() && ParseInternalKey(input_->key(), &next_ikey) &&
cmp_->Equal(ikey_.user_key, next_ikey.user_key) &&
(prev_snapshot == 0 ||
DEFINITELY_NOT_IN_SNAPSHOT(next_ikey.sequence, prev_snapshot))) {
input_->Next();
}
// If you find you still need to output a row with this key, we need to output the
// delete too
if (input_->Valid() && ParseInternalKey(input_->key(), &next_ikey) &&
cmp_->Equal(ikey_.user_key, next_ikey.user_key)) {
valid_ = true;
at_next_ = true;
}
}
以上逻辑的优化主要体现在第一个if语句之中,即当前delete 的key可能不在最后一层,通过这个函数KeyNotExistsBeyondOutputLevel
判断后续的层中没有当前key了, 那么就可以直接删除;这个逻辑会增加一定的CPU比较代价,但是它的收益是可观的,能够在较高的层中将key直接删除。
4. Lethe: A Tunable Delete-Aware LSM Engine . SIGMOD’20
这是一篇 今年顶会 SIGMOD中发表的论文,核心就是LSM 引擎带来的delete问题。
在该篇论文中详细描述了delete 对LSM带来的问题,且现有的引擎没有很好的办法很好得解决大范围删除的问题。
Lethe通过:
- 增加统计信息和compaction策略,优化sort key大范围删除带来的问题
- 设计新的storage layout,来提升secondary key的大范围删除。
提出了了两种策略:
-
FADE(fast deletion): 更加激进的compaction调度策略。相比于rocksdb的可配置策略,这里增加了文件的TTL,优先针对达到TTL的文件进行挑选(可以根据TTL主动触发调度),调度compaction删除。
-
KiWi(Key Weaving storage layout): 提高支持非sort key的range delete,牺牲了一定的查询代价,提升了范围删除的效率。这个设计 感觉和rocksdb的DeleteRange有点类似,就是不知道性能差异大吗?
一个X-engine的大佬已经有一篇该论文的详细分享了。
论文原地址: https://cs-people.bu.edu/dstara/pdfs/Lethe.pdf
后续尝试将Lethe这种更加激进的策略集成到rocksdb中,看看效果如何,理论上在Delete 比例重的场景,应该优于Rocksdb本身的优化。