前言
前面大概了解了下LSM-Tree的合并。了解到了分层合并的核心理念,是让每一层的数据之间尽量减少重叠区域,这种方式主要是为了减少读放大的影响。也就是在读写吞吐之间做了取舍,尽量兼顾了读的性能。本文将介绍LevelDB中的合并操作。
MemTable的合并
MemTable写入到Level0 的时候,是肯定会有重叠区域的,在前面的MemTable中就可以看到,数据只会被追加,而不会被替换,所以Level0 层的数据重叠概率是最大的,但是也有存在极端的情况,比如当前的写入非常的离散,导致level0 基本上没有重叠的部分,甚至和level1 都没有,最好的办法就是直接往上推送,但是一直推送存在的问题就是每次合并都需要一个compact,甚至触发高level的多次compact,这是得不偿失的。LevelDB做了一取舍,在当前的Version中选择努力多往上层投递。
MemTable到level0的合并称之为 Minor Compaction
SSTable的合并
已经写入SSTable中的合并分为三种情况:
- Size compaction 大小触发合并条件后进行合并
- Manual compaction 人为执行合并
- Seek compaction 查找Miss后合并
这三种称之为Major compaction。
具体的合并操作
前面提到,每一个文件变化就会触发一个version的变化,合并过程本身就是一个文件读写的操作,所以合并操作的实现都是在Version的文件中。
Minor Compaction
前面在MemTable中已经了解到了如何将MemTable变成level0 的SStable。但是前文也提到过,如果说当前的Level0 的重叠区域较少的时候,可以尽量往上合并。
arduino
int Version::PickLevelForMemTableOutput(const Slice& smallest_user_key,
const Slice& largest_user_key) {
int level = 0;
if (!OverlapInLevel(0, &smallest_user_key, &largest_user_key)) {
// Push to next level if there is no overlap in next level,
// and the #bytes overlapping in the level after that are limited.
InternalKey start(smallest_user_key, kMaxSequenceNumber, kValueTypeForSeek);
InternalKey limit(largest_user_key, 0, static_cast<ValueType>(0));
std::vector<FileMetaData*> overlaps;
while (level < config::kMaxMemCompactLevel) {
if (OverlapInLevel(level + 1, &smallest_user_key, &largest_user_key)) {
break;
}
if (level + 2 < config::kNumLevels) {
// Check that file does not overlap too many grandparent bytes.
GetOverlappingInputs(level + 2, &start, &limit, &overlaps);
const int64_t sum = TotalFileSize(overlaps);
if (sum > MaxGrandParentOverlapBytes(vset_->options_)) {
break;
}
}
level++;
}
}
return level;
}
如果上一个version不为空,也就是持续写入db的情况下,会触发选择一个Level作为当前MemTable的level。具体实现就是上面的代码。
- 判断当前文件的最大最小值是否和其他level0 的文件有重叠
- 使用当前的最大最小key,写入到最多kMaxMemCompactLevel-1的层级,kMaxMemCompactLevel 默认值为2,因为合并是将当前层级合并到下一层级,也就是说MemTable最多投递到第二层
- 判断是否和下一层级有重叠,有就退出循环。如果当前level为0,但是他和第一层也有重叠部分,那么就不推送到level1,而是仍然使用level0。
- 如果没有,则判断是否接近最大的level,如果不是就判断是否和他的祖父级别的层级有较多的重叠部分,如果重叠部分超过了10*(max_file_size 默认2MB)。那么就不往上级合并。
- 以上场景都没有,即level0 既和level1 没有合并区间,也和level2 的合并区间少于20MB,则继续将当前level++
- 继续判断上面的3 到 5
所以一个MemTable写入到Level2 的条件有
- MemTable 和其他的level0文件没有重叠
- MemTable 和level1 ,level2 没有重叠
- MemTable 和level3的重叠的文件数少于10。
为什么需要加上这几个判断,原文对只升级到第2层的解释为:
// Maximum level to which a new compacted memtable is pushed if it // does not create overlap. We try to push to level 2 to avoid the // relatively expensive level 0=>1 compactions and to avoid some // expensive manifest file operations. We do not push all the way to // the largest level since that can generate a lot of wasted disk // space if the same key space is being repeatedly overwritten.
翻译下就是: 为了避免level0 到level1 中相对昂贵的合并操作和避免写入manifest 文件,所以尝试将level0 推送到更上层,为什么不一致推送到更高的层级,是为了避免浪费空间,存在浪费空间的场景就是大量的重复Key不断更新。假设,我们直接推送到最高层级,那么最高层级可能会存在大量的重复的key,这些key其实是可以在低层级进行合并的。这也间接说明其实越高层级的合并相对是越少执行的。
个人觉得这里也是一个可以根据业务优化的点,比如某些业务场景下,就是间隔某个时间或者什么时候有大量的相同的key,那么完全可以放在最上层,进行一次合并就可以去掉所有重复的数据,还相对减少了其他层的合并,场景必须是间隔一段时间(间隔key可能会被写到最高层级)。
或者就是长时间一直是大批量数据顺序数据写,但是Level2 可以存放的数据已经到了100MB了。
Major Compaction
minor compaction的合并时机只是出现在MemTable写入SSTable,尽管会往level2 写,但是本质上并没有涉及到和存量的SSTable的文件合并。Major 则不同,他是真正会对SSTable文件进行合并。所以虽然他分为Size 和 Seek,我还是合在一起说下。
触发时机
什么时候触发Major合并呢?在db_impl.cc
中有个方法MaybeScheduleCompaction
方法
arduino
void DBImpl::MaybeScheduleCompaction() {
mutex_.AssertHeld();
if (background_compaction_scheduled_) {
// Already scheduled
} else if (shutting_down_.load(std::memory_order_acquire)) {
// DB is being deleted; no more background compactions
} else if (!bg_error_.ok()) {
// Already got an error; no more changes
} else if (imm_ == nullptr && manual_compaction_ == nullptr &&
!versions_->NeedsCompaction()) {
// No work to be done
} else {
background_compaction_scheduled_ = true;
env_->Schedule(&DBImpl::BGWork, this);
}
}
这个方法最终可能通知后台线程进行合并操作,也就是env_->Schedule(&DBImpl::BGWork, this);
,但是合并过程中都是获取了锁的,而且设置了background_compaction_scheduled_ 的判断,所以同一个时间只会有一个线程在合并。
MaybeScheduleCompaction
方法被调用的地方有两种类型,对应的就是Major compaction的合并方式
- size_compaction 每次MakeRoomForWrite ,即可能创建了新的SSTable的时候
- seek_compaction 每次读取数据后,直到触发了seek_compaction的条件
他们的优先级是先size_compaction 后seek_compaction ,也就是只有size_compaction 不符合条件的时候才进行seek_compaction。
先看下Compaction 类的成员变量,便于后续分析:
arduino
class Compaction {
public:
// 删减成员变量获取数据的方法
private:
friend class Version;
friend class VersionSet;
Compaction(const Options* options, int level);
// 合并的level
int level_;
//最大输出文件大小,默认等于2 * 1024 * 1024
uint64_t max_output_file_size_;
// 合并的Version,一般为current
Version* input_version_;
// 合并过程的version,后续转换为current
VersionEdit edit_;
// Each compaction reads inputs from "level_" and "level_+1"
// 每个合并都是从当前层合并到下一层,所以将需要合并的文件放到一个vertor中
std::vector<FileMetaData*> inputs_[2]; // The two sets of inputs
// 和当前key有重叠区域的祖父文件
// State used to check for number of overlapping grandparent files
// (parent == level_ + 1, grandparent == level_ + 2)
std::vector<FileMetaData*> grandparents_;
// 重叠开始
size_t grandparent_index_; // Index in grandparent_starts_
// 用于判断祖父文件中和当前文件合并过程中的文件大小累加
bool seen_key_; // Some output key has been seen
// 祖父文件和当前文件合并的大小
int64_t overlapped_bytes_; // Bytes of overlap between current output
// and grandparent files
//主要使用在IsBaseLevelForKey方法中的state
// State for implementing IsBaseLevelForKey
//level_ptrs_ 这个主要是用来判断当前key是否存在更上层的文件,用于删除某个key,如果当前key在更上层文件中不包含,则可以在本层直接将这个删除的key丢弃
// level_ptrs_ holds indices into input_version_->levels_: our state
// is that we are positioned at one of the file ranges for each
// higher level than the ones involved in this compaction (i.e. for
// all L >= level_ + 2).
size_t level_ptrs_[config::kNumLevels];
};
所以一个compaction对象中,是通过vector 的数据,放在inputs中,该数组只包含两个vector,inputs[0] 是当前的level,inputs_[1]是需要合并的level
最后BGWork 中会执行到BackgroundCompaction
方法
scss
void DBImpl::BackgroundCompaction() {
mutex_.AssertHeld();
// 合并当前的Mematable
if (imm_ != nullptr) {
CompactMemTable();
return;
}
Compaction* c;
bool is_manual = (manual_compaction_ != nullptr);
InternalKey manual_end;
if (is_manual) {
// 删减如果是人为操作的合并
} else {
// 1 选择合并的文件
c = versions_->PickCompaction();
}
Status status;
if (c == nullptr) {
// Nothing to do
// 2 如果不是人为,而且选择的过程中IsTrivialMove为True,IsTrivialMove为True的条件是
// 当前的input[0]是1,input[1]是0,也就是说下层没有和当前区间有重叠部分的数据,那么就简单的将当前的文件移动到下一层,所以是简单的移动。
} else if (!is_manual && c->IsTrivialMove())
// Move file to next level
assert(c->num_input_files(0) == 1);
FileMetaData* f = c->input(0, 0);
c->edit()->RemoveFile(c->level(), f->number);
c->edit()->AddFile(c->level() + 1, f->number, f->file_size, f->smallest,
f->largest);
status = versions_->LogAndApply(c->edit(), &mutex_);
if (!status.ok()) {
RecordBackgroundError(status);
}
// 日志打印
} else {
// 如果有重叠区域
CompactionState* compact = new CompactionState(c);
// 执行合并操作
status = DoCompactionWork(compact);
if (!status.ok()) {
RecordBackgroundError(status);
}
// 清理合并操作
CleanupCompaction(compact);
c->ReleaseInputs();
// 清理没有引用的文件
RemoveObsoleteFiles();
}
delete c;
// 删减人为合并和状态结果处理
}
}
上面的代码一句话说就是找到需要合并的文件,执行合并和清理合并产生的中间态,创建新的Version。下面一一介绍下:
找到需要合并的文件
也就是上文中的c = versions_->PickCompaction();
具体实现:
scss
Compaction* VersionSet::PickCompaction() {
Compaction* c;
int level;
// We prefer compactions triggered by too much data in a level over
// the compactions triggered by seeks.
const bool size_compaction = (current_->compaction_score_ >= 1);
const bool seek_compaction = (current_->file_to_compact_ != nullptr);
// 这里的if else 也说明了优先级问题,即size的优先级高于seek
if (size_compaction) {
// 找到需要合并的level,这个数据下面会说如何得到的
level = current_->compaction_level_;
assert(level >= 0);
assert(level + 1 < config::kNumLevels);
c = new Compaction(options_, level);
// Pick the first file that comes after compact_pointer_[level]
// 找到当前需要合并的第一个文件,文件是按照大小存放在数组中,所以后续都需要合并。
for (size_t i = 0; i < current_->files_[level].size(); i++) {
FileMetaData* f = current_->files_[level][i];
// compact_pointer_ 是用来标识在当前level中需要合并的起始位置,可能为空
// 如果起始位置为空,或者当前层的起始位置比当前文件要大,则说明需要合并当前文件,写入到input_[0]中,这里会产生一个问题,就是我们当前找到第一个文件就退出了,但是可能当前key在本层还有更老的相同key的文件,所以会有下面的扩展边界的出现
if (compact_pointer_[level].empty() ||
icmp_.Compare(f->largest.Encode(), compact_pointer_[level]) > 0) {
c->inputs_[0].push_back(f);
break;
}
}
// 如果当前的起始为空,或者起始的key特别的大,就直接从最开始的位置进行合并
if (c->inputs_[0].empty()) {
// Wrap-around to the beginning of the key space
c->inputs_[0].push_back(current_->files_[level][0]);
}
} else if (seek_compaction) {
level = current_->file_to_compact_level_;
c = new Compaction(options_, level);
c->inputs_[0].push_back(current_->file_to_compact_);
} else {
return nullptr;
}
c->input_version_ = current_;
c->input_version_->Ref();
// 对于level0的文件,因为他们本身就包含了需要的重叠区间,所以需要将所有的文件中最大最小的都找出来,什么情况下这里的level是0呢,就是当前其他的level都不需要进行数据的合并
// Files in level 0 may overlap each other, so pick up all overlapping ones
if (level == 0) {
InternalKey smallest, largest;
GetRange(c->inputs_[0], &smallest, &largest);
// Note that the next call will discard the file we placed in
// c->inputs_[0] earlier and replace it with an overlapping set
// which will include the picked file.
current_->GetOverlappingInputs(0, &smallest, &largest, &c->inputs_[0]);
assert(!c->inputs_[0].empty());
}
// 找到inputs[1]
SetupOtherInputs(c);
return c;
}
PickCompaction
整个还是比较简单的,就是从current的version中找到合并的inputs数据,SetupOtherInputs 则是找inputs[1],这里先回到Version中的部分实现。即current中的compaction_level和compaction_score 这些是怎么得到的。
在Version的Finalize 中,会选择出当前version的上诉数据:
ini
void VersionSet::Finalize(Version* v) {
// Precomputed best level for next compaction
int best_level = -1;
double best_score = -1;
for (int level = 0; level < config::kNumLevels - 1; level++) {
double score;
if (level == 0) {
// 对于0层,他的合并选择和其他层不一样,0 层是按照文件个数来判断的,默认是4,所以是当前的size/4 就是获取得分
score = v->files_[level].size() /
static_cast<double>(config::kL0_CompactionTrigger);
} else {
// 其他层则是按照文件大小进行判断,比如level1 是10mb,如果超过10mb,那么他的得分就超过了level0,当然如果level0 迟迟不被合并,他的得分肯定会更高,
// Compute the ratio of current size to size limit.
const uint64_t level_bytes = TotalFileSize(v->files_[level]);
score =
static_cast<double>(level_bytes) / MaxBytesForLevel(options_, level);
}
// 更新得分
if (score > best_score) {
best_level = level;
best_score = score;
}
}
// 将下次合并最优先级别的level和得分写到version中
v->compaction_level_ = best_level;
v->compaction_score_ = best_score;
}
所以上一个版本在apply的时候就确定了下次触发合并操作的level。而comact_pointer 的值是在SetupOtherInputs中写入的。先整体看下如何写入inputs[2]
scss
void VersionSet::SetupOtherInputs(Compaction* c) {
const int level = c->level();
InternalKey smallest, largest;
// 填充input[0]的边界
AddBoundaryInputs(icmp_, current_->files_[level], &c->inputs_[0]);
// 写入最大最小值
GetRange(c->inputs_[0], &smallest, &largest);
// 找到比当前最大最小值范围内的文件,并且写入到inputs_[1]中
current_->GetOverlappingInputs(level + 1, &smallest, &largest,
&c->inputs_[1]);
// 填充input[2]的边界
AddBoundaryInputs(icmp_, current_->files_[level + 1], &c->inputs_[1]);
// 获取整体上的最大最小值,这里处理的结果是可能当前level中包含,但是level+1中不包含的数据也可以被合并,从而直接将部分数据直接合并了,减少下次合并
// Get entire range covered by compaction
InternalKey all_start, all_limit;
GetRange2(c->inputs_[0], c->inputs_[1], &all_start, &all_limit);
// See if we can grow the number of inputs in "level" without
// changing the number of "level+1" files we pick up.
// 看我们是否能够新增inputs[0]的数量,而不需要修改inputs[1]的数量。
// 这个注释的意思就是,比如当前的level的key是a-g,然后inputs[1]中的数量是a-d,那么从e-g其实也可以放入到本次合并,减少下次level的合并,
if (!c->inputs_[1].empty()) {
std::vector<FileMetaData*> expanded0;
// 将当前level中所有的符合[all_start,all_limit]的文件写入到expanded0 中,作为level的合并文件
current_->GetOverlappingInputs(level, &all_start, &all_limit, &expanded0);
// 扩展level的边界
AddBoundaryInputs(icmp_, current_->files_[level], &expanded0);
const int64_t inputs0_size = TotalFileSize(c->inputs_[0]);
const int64_t inputs1_size = TotalFileSize(c->inputs_[1]);
const int64_t expanded0_size = TotalFileSize(expanded0);
//如果扩展后的文件比原来的大,而且中的合并文件数小于ExpandedCompactionByteSizeLimit(25个文件)。则需要继续扩大
if (expanded0.size() > c->inputs_[0].size() &&
inputs1_size + expanded0_size <
ExpandedCompactionByteSizeLimit(options_)) {
// 创建新的其实和结束
InternalKey new_start, new_limit;
// 获取新的开始和结束
GetRange(expanded0, &new_start, &new_limit);
std::vector<FileMetaData*> expanded1;
// 在level+1 获取到新的开始和结束,写入到expanded1
current_->GetOverlappingInputs(level + 1, &new_start, &new_limit,
&expanded1);
// 扩展expanded1
AddBoundaryInputs(icmp_, current_->files_[level + 1], &expanded1);
// 如果扩展失败了
if (expanded1.size() == c->inputs_[1].size()) {
// 将当前的最大最小值写入,并且赋值给compaction
smallest = new_start;
largest = new_limit;
c->inputs_[0] = expanded0;
c->inputs_[1] = expanded1
// 重新获取总体的最大最小值
GetRange2(c->inputs_[0], c->inputs_[1], &all_start, &all_limit);
}
}
}
// 查看和祖父节点的数据重叠,写入到compaction中
// Compute the set of grandparent files that overlap this compaction
// (parent == level+1; grandparent == level+2)
if (level + 2 < config::kNumLevels) {
current_->GetOverlappingInputs(level + 2, &all_start, &all_limit,
&c->grandparents_);
}
// Update the place where we will do the next compaction for this level.
// We update this immediately instead of waiting for the VersionEdit
// to be applied so that if the compaction fails, we will try a different
// key range next time.
// 将本次level的最大值写入到compact_pointer_ 中,作为下次合并的起始位置
compact_pointer_[level] = largest.Encode().ToString();
c->edit_.SetCompactPointer(level, largest);
}
上面一直提到一个扩展边界,这个边界是什么意思呢,为什么需要扩展呢?稍微解释下,inputs中存放的都是等带compact的文件,后文成为compaction_files,如果当前的compaction_files 中包含了一个文件b1,在对应的level的文件中有个SSTable文件b2,其中b2的文件比b1老。比如当前文件中存在两个不同sequence的key为hello的文件
hello_11111 在b1 中,还有一个文件在hello_00000没有放在compaction_files ,如果我们直接将b1的文件合并到level+1层,那么可能存在的情况就是老的hello_00000 的数据还存在level层,导致读取数据的出现获取到旧文件的情况。出现这种情况是因为在PickCompaction 方法中,是找到第一个和compact_pointer_ 做比较的文件,而不是最旧的那个文件。input[1] 也是同理,说白了就是希望将存在相同key的文件都合并了。不过越往上层存在这种情况的可能性越低,因为每次合并都有不断扩展边界的操作。
所以AddBoundaryInputs的方法实现就好理解了
c
// Extracts the largest file b1 from |compaction_files| and then searches for a
// b2 in |level_files| for which user_key(u1) = user_key(l2). If it finds such a
// file b2 (known as a boundary file) it adds it to |compaction_files| and then
// searches again using this new upper bound.
//
// If there are two blocks, b1=(l1, u1) and b2=(l2, u2) and
// user_key(u1) = user_key(l2), and if we compact b1 but not b2 then a
// subsequent get operation will yield an incorrect result because it will
// return the record from b2 in level i rather than from b1 because it searches
// level by level for records matching the supplied user key.
//
// parameters:
// in level_files: List of files to search for boundary files.
// in/out compaction_files: List of files to extend by adding boundary files.
void AddBoundaryInputs(const InternalKeyComparator& icmp,
const std::vector<FileMetaData*>& level_files,
std::vector<FileMetaData*>* compaction_files) {
InternalKey largest_key;
// 如果当前的compaction_files文件为空,直接返回,我没有想到这个场景,因为pick的方法中,总是会往里面塞值的,除非原本就没有值
// Quick return if compaction_files is empty.
if (!FindLargestKey(icmp, *compaction_files, &largest_key)) {
return;
}
bool continue_searching = true;
while (continue_searching) {
// 从当前level中找到最小的边界值
//// Finds minimum file b2=(l2, u2) in level file for which l2 > u1 and
// user_key(l2) = user_key(u1)
//在当前level中查找,满足的条件是,level中某个文件的最小值比当前的最大值要大,并且最小值的user-key和当前的userkey相同的文件,
FileMetaData* smallest_boundary_file =
FindSmallestBoundaryFile(icmp, level_files, largest_key);
// If a boundary file was found advance largest_key, otherwise we're done.
if (smallest_boundary_file != NULL) {
compaction_files->push_back(smallest_boundary_file);
largest_key = smallest_boundary_file->largest;
} else {
continue_searching = false;
}
}
}
我对这块的理解不是很透彻,感觉是当前小存在两个文件b1 包含了[key1,key2_seqN] 此时的compaction_pointer 的值比key2_seqN要大,但是可能存在key2_seqN-1 处于 b2[key2_seqN-1,key2_seq_3] 和b3[key2_seq_2,key3],这种情况的出现是因为比较的时候是按照key的升序,seq的降序,也就是相同的userkey的seq越大就越靠前。所以在FindSmallestBoundaryFile的实现中是这么找到数据的:
ini
FileMetaData* FindSmallestBoundaryFile(
const InternalKeyComparator& icmp,
const std::vector<FileMetaData*>& level_files,
const InternalKey& largest_key) {
const Comparator* user_cmp = icmp.user_comparator();
FileMetaData* smallest_boundary_file = nullptr;
for (size_t i = 0; i < level_files.size(); ++i) {
FileMetaData* f = level_files[i];
// 当前的最小的值比查询的值的seq小,即userkey相同,但是seq靠
if (icmp.Compare(f->smallest, largest_key) > 0 &&
user_cmp->Compare(f->smallest.user_key(), largest_key.user_key()) ==
0) {
if (smallest_boundary_file == nullptr ||
icmp.Compare(f->smallest, smallest_boundary_file->smallest) < 0) {
smallest_boundary_file = f;
}
}
}
return smallest_boundary_file;
}
这里的实现主要是来自一次bug
执行正式的合并
执行正式的合并本质上比较简单,但是代码很长,下面的代码是删减后的代码:
rust
Status DBImpl::DoCompactionWork(CompactionState* compact) {
const uint64_t start_micros = env_->NowMicros();
int64_t imm_micros = 0; // Micros spent doing imm_ compactions
//1 将上面选择的文件组合为一个input
Iterator* input = versions_->MakeInputIterator(compact->compaction);
// 执行合并的时候释放锁,说明这个时刻是可以和写入操作并发执行的
// Release mutex while we're actually doing the compaction work
mutex_.Unlock();
// 获取头结点
input->SeekToFirst();
Status status;
ParsedInternalKey ikey;
std::string current_user_key;
bool has_current_user_key = false;
SequenceNumber last_sequence_for_key = kMaxSequenceNumber;
while (input->Valid() && !shutting_down_.load(std::memory_order_acquire)) {
// Prioritize immutable compaction work
// ... 如果当前需要写入level0,则直接CompactMemTable,需要加锁
Slice key = input->key();
// 当前合并输出大小是否和祖父节点超过20MB的重叠区间,如果超过就需要提前退出本次合并
// 这么做的原因是下次合并的时候不会选择更多的上层文件,
if (compact->compaction->ShouldStopBefore(key) &&
compact->builder != nullptr) {
status = FinishCompactionOutputFile(compact, input);
// 状态判断...
}
// Handle key/value, add to state, etc.
// 判断当前key是否可以直接丢弃
bool drop = false;
// 解析是Internalkey
if (!ParseInternalKey(key, &ikey))
// 解析失败去掉当前的缓存值
current_user_key.clear();
has_current_user_key = false;
last_sequence_for_key = kMaxSequenceNumber;
} else {
//has_current_user_key 如果没有上次的userkey
if (!has_current_user_key ||
user_comparator()->Compare(ikey.user_key, Slice(current_user_key)) !=
0) {
// 判断当前的key是否和上次的key相等,如果不相等就设置has_current_user_key 为true
// First occurrence of this user key
current_user_key.assign(ikey.user_key.data(), ikey.user_key.size());
has_current_user_key = true;
last_sequence_for_key = kMaxSequenceNumber;
}
// 如果已经存在相等的值,而且前面的sequence 就已经比当前的snapshot的最小版本还小,则直接设置可以丢弃,即存在了更新的数据,当前数据太旧了
if (last_sequence_for_key <= compact->smallest_snapshot) {
// Hidden by an newer entry for same user key
drop = true; // (A)
} else if (ikey.type == kTypeDeletion &&
ikey.sequence <= compact->smallest_snapshot &&
compact->compaction->IsBaseLevelForKey(ikey.user_key)) {
// 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.
// 如果是删除的key,则需要确定更上层没有数据才可以直接丢弃,否则需要等到上次合并的时候才会真正丢弃数据
drop = true;
}
// 设置当前的sequence
last_sequence_for_key = ikey.sequence;
}
// 如果不丢弃就打开合并文件执行当前key的写入
if (!drop) {
// Open output file if necessary
// .... 打开文件和写入数据
// Close output file if it is big enough
// 如果当前的数据超过了2MB,则需要直接进行数据写入
if (compact->builder->FileSize() >=
compact->compaction->MaxOutputFileSize()) {
status = FinishCompactionOutputFile(compact, input);
}
}
input->Next();
}
// 后续操作,包括了确保当前数据全部刷盘和清理内存
// ....
CompactionStats stats;
// 记录本次合并状态,包括数据和大小以及消耗时间
mutex_.Lock();
stats_[compact->compaction->level() + 1].Add(stats);
// 将合并的数据写入到version中
if (status.ok()) {
status = InstallCompactionResults(compact);
}
//日志打印
return status;
}
合并的过程相对比较简单。主要是选取数据的过程。具体步骤在代码里已经写了,啰嗦着总结下:
- 将Input封装为Iterator
- Iterator 循环遍历值
- 如果当前的key和祖父文件中的key重叠超过了阈值20MB或者说10个文件,则提前退出本次合并,避免下层合并过程出现大量的文件合并
- 获取当前的key,存放在栈空间里,用于和下次循环的key对比,如果key相同,而且sequence 小于当前的最小snapshot值,当前的key是删除而且sequence小于最小的snapshoy且上层不包含其他的文件,则可以进行删除操作。
- 在写入过程中,超过2MB就执行一次文件写入
- 如果当前等待输出的缓冲区不为空,则将缓冲区写入磁盘
- 更新version 和清理活动,包含了将需要删除的文件,也就是input文件放入Version的待删除区,将本次写入的文件放入到level+1层
Seek 和Manual 的compaction
个人觉得上面的两种整体合并操作和size 差不多,主要是一个input的选择。
Seek Compaction
Seek的选择多来自于每次get操作。比如当前level1 层中的某个SSTable文件中的数据是[1,3,4,5,8,9],level2 中的文件是[4,5,6,7,8],那么可能每次获取7 都是先去level1 中找该数据,发现找不到才去下面找,这个数据是存放在version的
ini
struct GetStats {
FileMetaData* seek_file;
int seek_file_level;
};
中,每一个FileMetaData 都包含了一个int allowed_seeks;
属性,这个值是初始化在每次Version更新中,如初始化为
ini
f->allowed_seeks = static_cast<int>((f->file_size / 16384U)); //默认是2Mb的filesize,最后的值是256
if (f->allowed_seeks < 100) f->allowed_seeks = 100;//最小值不能小于100
每次Get操作都会更新文件的allowed_seeks,主要是执行减一的操作,当这个值为0的时候,则会调用一次合并操作:
ini
bool Version::UpdateStats(const GetStats& stats) {
FileMetaData* f = stats.seek_file;
if (f != nullptr) {
f->allowed_seeks--;
// allowed_seeks小于0,则会将需要合并的level和文件写入,并且返回true。然后调用MaybeScheduleCompaction
if (f->allowed_seeks <= 0 && file_to_compact_ == nullptr) {
file_to_compact_ = f;
file_to_compact_level_ = stats.seek_file_level;
return true;
}
}
return false;
}
然后在选择input的时候,就会将传入的file_to_compact_写入到input0 中,进行单个文件的合并。
Manual Compaction
这个和size 区别不大,这里就不赘述了。
总结
总的来说,LevelDB 的分层合并的实现有如下特点:
- 单线程合并
- 尽量考虑后续合并操作,每次都会执行level+2的重叠判断,如果过大会丢弃没有完成的合并
- 考虑到了特殊的场景,即因为key的排序问题带来可能存在数据不一致的情况
- 除了第一层以外,每层的单个SSTable的key值基本上不重叠,每个大小为2MB
- 考虑到读取过程可能存在某个文件key的范围和上层重叠的情况,会进行一个单文件合并的操作