朱维清 10215300402 ### commitID——VirgilZhu 谷杰 10222140408 ### commitID——GUJIEJASON Github 仓库地址:https://github.com/VirgilZhu/LevelDB-course-Lab
TTL(Time To Live,生存时间):
LevelDB 中,根据 TTL 设置了死亡时间的 KV 数据对应在被读取、合并过程中,判断其是否存活(数据对有效),若超过死亡时间,则进行丢弃处理。 对所有 KV 数据对加入 TTL 属性后,能使 LevelDB 的 SStable 所占空间有效降低,存储数据回收机制进一步合理化。
Q:测试用例在传入 TTL 参数后,Put() 函数应将死亡时间deadtime
信息存入键值对的key
还是value
部分?
A:我们选择将deadtime
存在value
中,好处是存在value
中我们可以更容易地实现不同的过期策略,也可以更方便地修改过期时间,同时也不需要更新更加复杂的key
匹配和查找机制,更重要的是在value
中添加会有更好的扩展性,未来也可以在value
中添加更多其他的信息。
/* TODO: Add TTL Version Put() */
Status DBImpl::Put(const WriteOptions& opt, const Slice& key, const Slice& value, uint64_t ttl){
WriteBatch batch;
auto now = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
auto end = now + ttl;
Slice value_timestamp = Slice(value.ToString() + "_ts_" + std::to_string(end));
batch.Put(key, value_timestamp);
return Write(opt, &batch);
}
/* --------------------------- */
对于Put
操作,我们选择在保留原有的Put
接口的基础上,增加一个新的Put
接口,其包含TTL
参数,单位为秒。如果我们调用原本的Put
接口,则该键值对不会有TTL
,如果调用新的Put
接口,则会在value
部分的末段加上_ts_
字段和该键值对的deadtime
。
同时,对于如何获取时间,我们选择的是调用 C++ 标准库中的**std::chrono::steady_clock
**这一时钟类型,它的特点是单调递增,不会受到系统时间调整的影响。我们先是使用now()
这一静态成员函数返回当前时间点的time_point
对象,再调用time_point
中的一个成员函数time_since_epoch()
返回从纪元开始到当前时间点的时间间隔,这是一个duration
对象,接着通过std::chrono::duration_cast
将时间间隔的单位转换为秒,最后调用duration的count()
函数返回一个表示秒数的整数。
这样,我们就成功地得到了当前时间的时间戳,单位为秒,接着只需要加上我们传入的ttl
,便是该键值对的deadtime
,最后将带有deadtime
的value
和key
封装成Slice
,使用 WriteBatch
对象的 Put
方法将带有时间戳的新键值对加入到批处理操作中,再调用 Write
方法将 WriteBatch
中的所有操作应用到数据库中,并返回操作的状态。这样便完成了带有ttl
的写入操作。
同时,由于我们是重新定义了一个Put
接口,别忘了在头文件中加入新Put
接口的声明。
/* TODO: Add TTL Version Put() */
Status Put(const WriteOptions&, const Slice& key,
const Slice& value, uint64_t ttl) override;
/* --------------------------- */
/* TODO: Add TTL Version Get() */
if (mem->Get(lkey, value, &s)) {
isLive(key, value, s, ttl);
// Done
} else if (imm != nullptr && imm->Get(lkey, value, &s)) {
isLive(key, value, s, ttl);
// Done
} else {
s = current->Get(options, lkey, value, &stats);
if (s.ok()) isLive(key, value, s, ttl);
have_stat_update = true;
}
/* --------------------------- */
Get
即读取操作,我们在原本的基础上只是增加了一个判断是否过期的功能,即对读取的键值对加一个判断,如果存在ttl
且已经过期则不再读取。代码上在原本的基础上只是增加了一个isLive
的判断函数。/* TODO: Add TTL Version isLive() */
Status isLive(const Slice& key, std::string* value, Status& s) {
if (value->empty()) {
s = Status::NotFound(key);
return s;
}
uint64_t now = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
size_t pos = value->rfind("_ts_");
if (pos != std::string::npos){
std::string timestampStr = value->substr(pos + 4);
if (isAllDigits(timestampStr)) {
uint64_t deadtime = std::stoull(timestampStr);
if (now >= deadtime) {
s = Status::NotFound(key);
}
}
}
return s;
}
/* --------------------------- */
/* TODO: Check if a string consists entirely of digits */
bool isAllDigits(const std::string& str) {
for (char c : str) {
if (!isdigit(c)) {
return false;
}
}
return true;
}
/* --------------------------- */
首先是先判断value
是否为空,若为空则表示该键不存在或已被删除,返回NotFound
。接着便是同Put
操作中一样通过调用std::chrono::steady_clock
来获取当前时间的时间戳。
通过rfind
函数从后向前找到第一个_ts_
字段的pos
。若存在_ts_
字段,则通过pos
和substr
函数来获取_ts_
字段后面的内容,类型为字符串。接着这里有一个isAllDigits
函数来判断一个字符串的内容是否全为数字,用到的是isdigit()
函数。在确认全为数字后便通过std::stoull
函数转换为unsigned long long
整数,再与当前时间戳比较,若已过期,则返回NotFound
。
⭐std::stoull
只能解析字符串中有效的数字部分,直到遇到第一个无法解析为数字的字符为止。例如如果我们调用原本的Put
接口,设置value
为“aaaaaa_ts_5678aaaaaaa”
,对于获取的字符串timestampStr
"5678aaaaaaa"
,std::stoull
会解析出前缀的数字部分 "5678"
,然后停止解析,忽略后续的非数字字符,这样本来一个无ttl
的键值对被当成了有ttl
,会导致数据丢失(在有无 TTL 参数与有 TTL 参数的 Put()
函数同时随机调用来插入数据对的情况下可能发生的情况)。
LevelDB 触发一次 Compaction 流程的函数调用关系:
- 参阅源码可知,每次 Compaction 流程的调用源头均为
DBImpl::MaybeScheduleCompaction()
函数,该函数判断当前是否可以调度一次 Compaction 流程后,若符合条件,会在“background”调度DBImpl::BGWork
,触发DBImpl::BackgroundCall()
函数,再通过加锁与判断,触发BackgroundCompaction()
函数,正式进行一次 Compaction 流程;BackgroundCompaction()
函数详细解释了一次完整的 Compaction 流程,包括:
- 当前 LevelDB 并不存在 immutable memtable 的情况下会进行一次
CompactMemtable()
,使 memtable 转变为一个 immutable table;- 判断是否为手动合并(manual compaction),即传入的键值对合并范围
begin
、end
是否均为nullptr
,若是,则调用确定合并范围的函数CompactionRange()
;- 实现一些 Compaction 流程的 Log 工作与回收工作;
- 在记录 Log 的同时,调用
DBImpl::DoCompactionWork()
函数,该函数是对 KV 键值对粒度进行实际读取与处理的函数。在经过详细的判断流程后,通过drop
这一变量,实时记录当前访问的键值对是否需要丢弃,而本次 Lab 需要实现的 Compaction 流程中丢弃过期数据对这一工作正是在此处实现——在原本的判断是否丢弃的条件(如已经遍历到最后有数据对存储的 SStable 层;上一层存有写入数据库更晚的相同 key 数据对;上一层有正要合并到此层的更新的相同 key 数据对)之后,加入判断该键值对是否过期,若过期则将drop
置为True
。
DBImpl::DoCompactionWork
函数中多加一个if
判断:/* TODO: Add TTL Version Compaction Drop Condition */
else {
std::string user_value = input->value().ToString();
uint64_t now = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
size_t pos = user_value.rfind("_ts_");
if (pos != std::string::npos){
std::string timestampStr = user_value.substr(pos + 4);
if (isAllDigits(timestampStr)) {
uint64_t deadtime = std::stoull(timestampStr);
if (now >= deadtime) {
drop = true;
}
}
}
}
/* ----------------------------------------------- */
首先先将value
转换为一个字符串类型,再用之前同样的方法,调用std::chrono::steady_clock
来获取当前时间的时间戳,后面便是与isLive
函数类似,若存在_ts_
字段并且成功获取deadtime
来与当前时间戳进行比较从而判断是否过期,若过期则调用drop
将其删除。
TestTTL.ReadTTL 测试样例始终报错的情况:
TestTTL.CompactionTTL 测试样例,打印信息ApproximateSizes after TTL
始终不为0,且数值仅比ApproximateSizes before TTL
小一部分:
查看测试样例可知,测试样例调用的 CompactRange(nullptr, nullptr)
函数仅触发了手动合并,而 LevelDB 内部的自动合并可能在手动合并前就已经把部分数据合并为有序并存入更深层的 SStable 中,对此的解决方案采取了强迫每次手动合并都要遍历、合并数据对直到存有数据对的最深层Level(或直接合并所有SStable);
经查阅源码,确定每次合并的最大范围(即end
参数)的设置在DBImpl::CompactRange()
函数中,由max_level_with_files
参数决定。该参数意为存有数据对的 SStable 所在的最深level
,而该level
参数将传入Test_CompactRange()
函数中,被设置为ManualCompaction manual
变量的字段,而该变量manual
将被赋值到manual_compaction_
中,决定了该次手动合并的最深层和最大key
值——只需要修改max_level_with_files
为测试样例插入的数据经自动合并后存储了 SStable 的最深层,或是 LevelDB 的 SStable 存储最深层config::kNumLevels - 1
(需要 pass assert(level + 1 < config::kNumLevels)
判断条件,即每次合并的最深层和更深一层是最后一次 compaction 涉及的 SStables 所在的两层):
void DBImpl::CompactRange(const Slice* begin, const Slice* end) {
int max_level_with_files = 1;
{
MutexLock l(&mutex_);
Version* base = versions_->current();
for (int level = 1; level < config::kNumLevels; level++) {
if (base->OverlapInLevel(level, begin, end)) {
max_level_with_files = level;
}
}
}
TEST_CompactMemTable(); // TODO(sanjay): Skip if memtable does not overlap
for (int level = 0; level <= max_level_with_files; level++) {
// for (int level = 0; level < config::kNumLevels - 1; level++) {
//Original: for (int level = 0; level < max_level_with_files; level++) {
TEST_CompactRange(level, begin, end);
}
}