LevelDB TTL Lab Report

>
> 朱维清 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 所占空间有效降低,存储数据回收机制进一步合理化。 + **实验要求:** 1. 在 LevelDB 中实现键值对的 TTL 功能,使得过期的数据在**读取时自动失效**,并在适当的时候**被合并清理**: - **Put()**:键值对插入函数,使得调用它时每个插入的键值对应包含 TTL 属性,便于读取、合并过程中被访问时,进行 TTL 相关处理; - **Get()**:获取键值对的 Key、Value,使得调用它时能触发相应 TTL 判断流程,若数据对已过期,在相应函数进行键值对丢弃处理; - **Compaction 流程**相关函数:厘清 Minor Compaction、Major Compaction 的具体实现流程,在相关函数对数据对粒度的处理部分的适当位置加入判断数据对是否过期的代码,并触发可能的相应回收处理。 2. 编写/修改测试用例,验证 TTL 功能的正确性和稳定性 ## 二、具体实现 ### Put() 函数实现 **Q**:测试用例在传入 TTL 参数后,Put() 函数应将死亡时间`deadtime`信息存入键值对的`key`还是`value`部分? **A**:我们选择**将`deadtime`存在`value`中**,好处是存在`value`中我们可以更容易地实现不同的过期策略,也可以更方便地修改过期时间,同时也不需要更新更加复杂的`key`匹配和查找机制,更重要的是在`value`中添加会有更好的扩展性,未来也可以在`value`中添加更多其他的信息。 ```c++ /* 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::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); } /* --------------------------- */ ``` 1. 对于`Put`操作,我们选择在保留原有的`Put`接口的基础上,增加一个新的`Put`接口,其包含`TTL`参数,单位为**秒**。如果我们调用原本的`Put`接口,则该键值对不会有`TTL`,如果调用新的`Put`接口,则会**在`value`部分的末段加上`_ts_`字段和该键值对的`deadtime`**。 2. 同时,对于如何获取时间,我们选择的是调用 C++ 标准库中的**`std::chrono::steady_clock`**这一时钟类型,它的特点是单调递增,不会受到系统时间调整的影响。我们先是使用`now()`这一静态成员函数返回当前时间点的`time_point`对象,再调用`time_point`中的一个成员函数`time_since_epoch()`返回从纪元开始到当前时间点的时间间隔,这是一个`duration`对象,接着通过`std::chrono::duration_cast`将时间间隔的单位转换为秒,最后调用`duration的count()`函数返回一个表示秒数的整数。 3. 这样,我们就成功地得到了当前时间的时间戳,单位为秒,接着只需要加上我们传入的`ttl`,便是该键值对的`deadtime`,最后将带有`deadtime`的`value`和`key`封装成`Slice`,使用 `WriteBatch` 对象的 `Put` 方法将带有时间戳的新键值对加入到批处理操作中,再调用 `Write` 方法将 `WriteBatch` 中的所有操作应用到数据库中,并返回操作的状态。这样便完成了带有`ttl`的写入操作。 4. 同时,由于我们是重新定义了一个`Put`接口,别忘了在头文件中加入新`Put`接口的声明。 ```c++ /* TODO: Add TTL Version Put() */ Status Put(const WriteOptions&, const Slice& key, const Slice& value, uint64_t ttl) override; /* --------------------------- */ ``` --- ### Get() 函数实现 ```c++ /* 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`的判断函数。 ```c++ /* 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::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; } /* --------------------------- */ ``` 1. 首先是先判断`value`是否为空,若为空则表示该键不存在或已被删除,返回`NotFound`。接着便是同`Put`操作中一样通过调用`std::chrono::steady_clock`来获取当前时间的时间戳。 2. 通过`rfind`函数从后向前找到第一个`_ts_`字段的`pos`。若存在`_ts_`字段,则通过`pos`和`substr`函数来获取`_ts_`字段后面的内容,类型为字符串。接着这里有一个`isAllDigits`函数来判断一个字符串的内容是否全为数字,用到的是`isdigit()`函数。在确认全为数字后便通过`std::stoull`函数转换为`unsigned long long`整数,再与当前时间戳比较,若已过期,则返回`NotFound`。 3. ⭐`std::stoull` 只能解析字符串中有效的数字部分,直到遇到第一个无法解析为数字的字符为止。例如如果我们调用原本的`Put`接口,设置`value`为`“aaaaaa_ts_5678aaaaaaa”`,对于获取的字符串`timestampStr` `"5678aaaaaaa"`,`std::stoull` 会解析出前缀的数字部分 `"5678"`,然后停止解析,忽略后续的非数字字符,这样本来一个无`ttl`的键值对被当成了有`ttl`,会导致数据丢失(在有无 TTL 参数与有 TTL 参数的 `Put()` 函数同时随机调用来插入数据对的情况下可能发生的情况)。 --- ### Compaction 流程相关函数实现 > **LevelDB 触发一次 Compaction 流程的函数调用关系:** > > 1. 参阅源码可知,每次 Compaction 流程的调用源头均为 `DBImpl::MaybeScheduleCompaction()` 函数,该函数判断当前是否可以调度一次 Compaction 流程后,若符合条件,会在“background”调度`DBImpl::BGWork`,触发`DBImpl::BackgroundCall()`函数,再通过加锁与判断,触发`BackgroundCompaction()`函数,正式进行一次 Compaction 流程; > 2. `BackgroundCompaction()`函数详细解释了一次完整的 Compaction 流程,包括: > + 当前 LevelDB 并不存在 immutable memtable 的情况下会进行一次`CompactMemtable()` ,使 memtable 转变为一个 immutable table; > + 判断是否为手动合并(manual compaction),即传入的键值对合并范围 `begin`、`end` 是否均为 `nullptr`,若是,则调用确定合并范围的函数`CompactionRange()`; > + 实现一些 Compaction 流程的 Log 工作与回收工作; > 3. 在记录 Log 的同时,调用`DBImpl::DoCompactionWork()`函数,该函数是对 KV 键值对粒度进行实际读取与处理的函数。在经过详细的判断流程后,通过`drop`这一变量,实时记录当前访问的键值对是否需要丢弃,而本次 Lab 需要实现的 Compaction 流程中丢弃过期数据对这一工作正是在此处实现——在原本的判断是否丢弃的条件(如已经遍历到最后有数据对存储的 SStable 层;上一层存有写入数据库更晚的相同 key 数据对;上一层有正要合并到此层的更新的相同 key 数据对)之后,加入判断该键值对是否过期,若过期则将`drop`置为`True`。 + TTL 在 Compaction 流程的具体处理是在`DBImpl::DoCompactionWork`函数中多加一个`if`判断: ```c++ /* TODO: Add TTL Version Compaction Drop Condition */ else { std::string user_value = input->value().ToString(); uint64_t now = std::chrono::duration_cast(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`将其删除。 ## 实验中遇到的问题和解决方案 1. TestTTL.ReadTTL 测试样例始终报错的情况: + 两次测试调用 OpenDB() 函数时,修改`dbName`为不同名字,避免出现访问持有锁的 LevelDB 的情况; + 在测试过程中打印所有 `Put()` 函数插入的数据对,以及 `Get()` 函数在读取到数据对后,打印该数据对是否过期,并打印该数据对的死亡时间与当前时钟信息: ![image-20241104234444773](./../AppData/Roaming/Typora/typora-user-images/image-20241104234444773.png) + 修改`srand()`函数传入的随机种子为固定(`srand(42)`),保证查找读取的 key 为先前插入的相同的 key。 2. 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 所在的两层): ```c++ 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); } } ``` ## 测试样例 Pass 截图 ![passtest.png](image/passtest.png)