# 代码设计 ## 1.项目概述 ### 1.1 实现字段查询功能 LevelDB 的基本数据结构是由一个 key 和对应的 value 组成,其中 value 是一个简单的字节序列(可以是字符串或二进制数据)。默认情况下,LevelDB 不支持像关系型数据库那样的字段查询功能。然而,在实际应用中,用户可能需要对存储的数据进行更加精细的操作,特别是当值包含多个逻辑字段时,直接使用现有的 LevelDB 接口难以满足需求。 在本实验中,我们的目标是扩展 LevelDB 的功能,使其 value 支持多字段结构,并实现通过字段值查询对应的 key 的功能。 ### 1.2 KV 分离 在 LevelDB 及其采用的 LSM 树结构中,性能挑战之一在于 Compaction 操作的效率。Compaction 是指将内存中的数据合并到磁盘上的过程,此过程中涉及大量的读写操作,对于系统的整体性能有着重要影响。在 Compaction 时,所有涉及到的旧 sstable 中的键值对都将被写入到新 sstable 中,而 Value 通常比 Key 大得多。如果将 Key 和 Value 分离存储,合并时只涉及 key 写入 sstable 的过程,可以显著减少 Compaction 的开销,从而提升性能。 基于此我们计划实施键值分离策略。具体而言,键将保持原有的排序方式,而值将被独立存储。这样做可以在不影响查询性能的前提下,大幅降低 Compaction 过程中的数据迁移量,进而减少不必要的磁盘 I/O ,提升系统的合并效率。 ## 2.功能设计 Andy Pavlo在15445课程中说,完成一个项目,应先写出能够完成正确性要求的代码,再在此基础上提升性能,避免不成熟的优化方式。 因此,我们的项目流程将保持每周推进代码进度,在完成目标要求的代码的基础上,不断迭代优化性能。 ![image-20241125122706607](设计文档.assets/image-20241125122706607.png) ### 2.1.字段设计 - **设计目标**: - 将 LevelDB 中的 `value` 组织成字段数组,每个数组元素对应一个字段(字段名:字段值)。 - 字段会被序列化为字符串,然后插入LevelDB。 - 这些字段可以通过解析字符串得到,字段名与字段值都是字符串类型。 - 允许任意调整字段。 - 实现通过字段值查询对应的 `key`。 - **实现思路**: 函数 `Put_with_fields` 负责插入含字段的数据。原字段数据经过序列化函数 `SerializeValue` 处理后,函数 `Put_with_fields` 调用 `Put` 将序列化后的字段插入 leveldb。 函数 `Get_with_fields` 负责获得含字段的数据。使用 `Get` 从 leveldb 中获取 `key` 和序列化后的 `value`,调用 `ParseValue` 可以将字段反序列化。 函数 `Get_keys_by_field` 使用iterator遍历数据库中的所有键值对,解析每个 `Value`,提取字段数组 `FieldArray`。检查字段数组中是否存在目标字段,如果匹配,则记录其对应的 `Key`。将所有匹配 `key` 汇总到 `keys` 中返回。 **初步实现**(第一周 已完成):在 leveldb 内部实现以上功能。内部实现会导致读取时无法区分多字段类型和原生 kv 对,扩展性不足。 **后续改进_1**(第二周 已完成):为了解决无法区分多字段类型和原生 kv 对的问题,将以上函数功能实现在用户层级,使 leveldb 内部对多字段类型无感知。 **后续改进_2**(第三周 已完成):在完成其余功能过程中,发现leveldb在coding类中有一些编码实现,因此也做了将字段设计放入coding类实现的版本。 ### 2.2.KV分离 #### 设计思路 - **KV 分离设计** a. 将LevelDB的key-value存储结构进行扩展,分离存储key和value。(当Value长度不大时不进行分离) b. 将Key和指向Value的元数据存储在LSM-tree中。 c.将Value和其余需要额外存储在ValueLog的元数据存储于ValueLog文件。通过LSM-tree中的元数据可查询到对应的Value所处的ValueLog。 - **读取操作。** KV分离后依然支持点查询与范围查询操作。 - **Value log的管理** a.通过GC操作释放Value log中的无效数据。(对于我们的不同实现版本,GC操作有所不同) b.GC能把旧Value log中没有失效的数据写入新的Value log,并在有必要时更新LSM-tree。 c.GC过程不能过度阻塞用户的Put和Get操作。(过度阻塞则和原本消耗大量资源的Compaction无区别,没有得到期望的性能提升) - **确保操作的原子性** ​ a.Write时先写ValueLog再写WAL,从而保证操作的原子性。 ​ b.对不同版本的GC实现,均需保证GC操作过程中发生dump后,恢复数据仍能保证正确性。 #### 实现思路 #### 初步实现:Single ValueLog(第一周 已完成) 使用单一Value Log简单的实现KV分离,该实现较为简单,仅需在Put/Get函数内部进行简单修改,但在大数据量场景下性能极差。 ##### 优点:实现简单,合并时开销小 ##### 缺点:大数据量下性能极差,不能作为最终方案。 #### 第二种实现:ValueLog per table(第二周 已完成) 对每个SSTable和MemTable建立一个Value Log。该实现相比于初步实现更加复杂,需要在合并时查询所有相关Value Log,并建立新Value Log。此外还要考虑在合并结束后将废弃的Value Log异步删除。 **Trick.为什么要在Put到MemTable时就放入Value Log而非dump至SS Table时才放入Value Log?** 原因:将写ValueLog推迟至SSTable并没有减少Put时写入磁盘的总数据量(写ValueLog:ValueLog中写Value,WAl中写Key和Value元数据;不写ValueLog:WAL中写Key和Value),优点是将两次无法并行的写文件操作变为一次写文件操作。但该方法有一个缺陷,即leveldb原生的管理数据的方式是MemTable和SSTable大小相等。而经过这样改变后,MemTable在dump成SSTable后其大小会突然减少(Value全部转移至ValueLog),导致一个SSTable中存储的数据量过少。而原本valuelog的优势(一个SSTable可以放更多键值对使得table cache命中率变高)也将不存在了。我们将两个做法都进行了实现,通过对比性能发现后者不如前者,因此选择保留前者设计。 ##### 优点:随合并自动GC,无需考虑GC。 ##### 缺点:合并时开销未能减小。 #### 第三种实现:Fixsize ValueLog(第三周 已完成) **该实现参考TiTanDB实现** 使用相对固定大小的Value Log,例如每个Value Log大小约为16MB。新添加的键值对依次将值与其长度(其实还有键和键长度)存入最新Value Log,当Value Log大小满了之后就创建新Value Log。需要设计一种异步的GC方法,该方法不会对Compaction,Get或Put造成明显的延迟。 ##### 优点:合并时开销小。 ##### 缺点:需要设计一种优秀的异步GC方式。 ## 3. 数据结构设计 ### KV分离后 Value 结构设计 一个Value,开头是使用Varint64存储的FieldNum,表示有FieldNum个Field组成。然后是使用Varint64存储的Field X name size,表示该field的字段名长度,然后是字段名,然后是使用Varint64存储的Field X Value size,表示该field的值长度,然后是值。 ![leveldb_values](设计文档.assets/leveldb_values.png) ### ValueLog结构设计 #### Single ValueLog设计 使用一个Value Log文件的设计中,我们只需记录Value在Value Log中对应的偏移量和Value长度即可。 Value Log中只记录Value值,无需记录元信息。 ![ValueLog](设计文档.assets/ValueLog-17325302026895.png) #### ValueLog per table设计 Value设计为:1字节标志位+Varint64文件ID+Varint64偏移量+Varint64长度。 在存储时根据Value大小是否较大选择进行KV分离。若分离则标志位为true,否则标志位为false。 日志文件中仍然只需记录Value值即可,无需记录元信息。 ![ValueLog](设计文档.assets/ValueLog-17325304026617.png) #### Fixsize ValueLog设计(第一版,有漏洞) ![ValueLog](设计文档.assets/ValueLog-17325310409799.png) 这一版设计有些复杂,但存在漏洞。我们的最终设计并非如此。 在Value开头使用一字节标志位表示是否KV分离。 如果KV分离,则接下来是Varint64的文件ID和Varint64的文件内offset。 在ValueLog中,在开头记录当前会索引到该Value Log的键值对数量Using count。如果Using count==0,则表示该ValueLog不被任何键值对使用,可以删除。 **Using count在Value Log添加键值对时进行+1**。 **Using count**在**其中任意键值对被合并**,**并且 该键值对由于合并时被更加新的键值对覆盖 或者 该键值对的True using Sign=False**时,进行**-1**。 在一个Value通过SSTable索引到Value Log后,其索引到的开头是一个**Value True Using Sign**。该标志位同样是一字节,标志了当前该Value是否是真正的Value。 **若标志位为True表示是真正的Value,那么标志位后是Varint64的Value长度+Value本身。** **若标志位为False表示不是真正的Value,那么标志位后是Varint64的下一个可能存在有真实对应Value的Value Log文件ID和Varint64的在下一个Value Log文件中的offset。** **Value True Using Sign发生变化有两种情况:** **1** 一个键值对由于合并时被更加新的键值对覆盖时,不仅将Using count进行-1,同时也将其Value True Using Sign设置为False。 **2** 在键值对加入到Value Log时,其Value True Using Sign设置为True。 当后台异步GC过程检测到一个Value Log的Using count较小时,将对其中Value True Using Sign仍为True的Value做以下处理: 1.将Value对应的数据(设为True的标志位,Value len和Value本身)像新数据一样写入最新的Value Log中。 2.将原Value True Using Sign置为False 3.将原Value Log中标志位后的数据修改为新写入的Value Log的ID和数据所处的Offset。 (保证原Value大小大于16,以防Varint64(Value len)+Value len [!CAUTION] > > **!注意!在实现过程中发现性能缺陷** 当合并时由于需要扫描合并的SSTable,要对其中每个Value做读ValueLog操作(因为可能可以更新Value指向的ValueLog),导致一次合并会涉及很多次的ValueLog文件读写,性能过于低效,因此想到了新的操作方法。 #### fixsize_valuelog实际设计 ![d85456f5f58abb55d9e83f3c020f0b7](设计文档.assets/d85456f5f58abb55d9e83f3c020f0b7.png) #### 新的`valuelog` 文件的组织方式: `valuelog` 文件存储了 **键值对(KV)** 数据,每条记录按照以下格式组织: 1. **键的长度**(`key_len`):`uint64_t`,标识键的字节长度。 2. **键**(`key`):实际的键数据,长度为 `key_len`。 3. **值的长度**(`value_len`):`uint64_t`,标识值的字节长度。 4. **值**(`value`):实际的值数据,长度为 `value_len`。 在sstable中key对应的value位置存储了对应valuelog文件的id和在文件中的offset。 **注1:在valueLog中重复存key会导致写方法,但是是有必要的,详见GC过程** **注2:将Key放在Value后是一个潜在的优化,可以加速Get** #### gc过程: 垃圾回收的核心思想是扫描所有非最新的 `valuelog` 文件(最新的ValueLog还会被插入新的数据,因此不对其进行垃圾回收),检查文件中的记录是否有效。如果记录的键值对已失效(比如键在 `sstable` 中不存在或元数据不匹配),则该记录会被忽略;如果记录的键值对是该键对应最新的键值对,则将该键值对重新Put进MemTable。在对整个旧的`ValueLog`做完上述操作后删除整个旧的的 `valuelog` 文件。 #### 详细过程 1. **扫描数据库目录**: - 遍历 `valuelog` 文件。 2. **处理每个 `valuelog` 文件**: - 打开文件,逐条读取记录。 3. **读取每条记录**: - 按文件结构读取 `key_len`、`key`、`value_len`、`value`。 - 检查 sstable是否包含该键: - 如果键不存在,忽略此条记录。 - 如果键存在,验证元数据是否有效(包括 `valuelog_id` 和 `offset`)。 - 有效的键值对会被重新 put 进入数据库(在此操作中自然的被插入到最新的ValueLog中),SSTable中无效的键值对则会在compaction过程中被自然的回收。 4. **清理无效文件**: - 对ValueLog扫描完毕后,删除该ValueLog。 #### 验证元数据/重新Put的正确性保证 在发现当前键值对有效后会将该键值对重新调用Put插入数据库,然而在此过程中如果有用户新插入了对应的Key的键值对,则有概率覆盖用户插入的键值对,这是错误的。因此我们要保证在此过程中不会新插入新的该Key对应的键值对。 我们使用了三个新的操作来实现该操作: 1.一个新的全局互斥锁GC_mutex 2.一个新的全局变量inserting_key,使用GC_mutex管理的conditional variable进行访问保护 3.更改write逻辑,可以访问当前writer队列的末尾write请求,并在其做完时获得其对应的conditional variable的提醒 下图是实现整个操作的模拟,左侧是图标,Lock和UnLock都指对GC_mutex的操作 ![lock_for_GC](设计文档.assets/lock_for_GC.png) ## 4. 接口/函数设计 #### 4. 1Value多字段设计 ##### **4.1.1 数据序列化与反序列化** **序列化字段数组为字符串值** ```cpp std::string SerializeValue(const FieldArray& fields); ``` - **输入**:字段数组 `fields`。 - **输出**:序列化后的字符串。 **反序列化字符串值为字段数组** ```c++ void DeserializeValue(const std::string& value_str, FieldArray* res); ``` - **输入**:序列化字符串 `value_str`。 - **输出**:字段数组 `res`。 ##### **4.1.2 数据查询接口** **按字段查找键** ```c++ Status DB::Get_keys_by_field(const ReadOptions& options, const Field field, std::vector* keys); ``` - **输入**: - 读取选项 `options`。 - 字段值 `field`。 - **输出**: - 操作状态 `Status`。 - 符合条件的键列表 `keys`。 ##### **4.1.3 判断文件是否为 `valuelog` 文件** **判断给定文件是否为 `.valuelog` 格式的文件。** ```c++ bool IsValueLogFile(const std::string& filename) ; ``` **输入**: - 文件名 `filename`,可以是完整路径或纯文件名。 **输出**: - 布尔值 `true`:文件是 `valuelog` 文件。 - 布尔值 `false`:文件不是 `valuelog` 文件。 ##### **4.1.4 解析 `sstable` 中的Value元信息(用于Fixsize ValueLog)** **解析 `sstable` 中存储的值,提取 `valuelog_id` 和 `offset` 信息。** ```c++ void ParseStoredValue(const std::string& stored_value, uint64_t& valuelog_id, uint64_t& offset); ``` - **输入**: - 存储值 `stored_value`,格式为 `"valuelog_id|offset"`。 - **输出**: - `valuelog_id`:解析出的 ValueLog 文件 ID。 - `offset`:解析出的记录偏移量。 ##### **4.1.5 获取 `ValueLog` 文件 ID** **从文件名中提取 ValueLog 文件的 ID(假设文件名格式为 `number.valuelog`)。** ``` uint64_t GetValueLogID(const std::string& valuelog_name); ``` - **输入**: - 文件名 `valuelog_name`,可以是完整路径或仅文件名,格式需符合 `number.valuelog`。 - **输出**: - `uint64_t` 类型,返回提取的文件 ID。 #### 4.2 Value Log设计 ##### **4.2.1 WriteValueLog** 将一堆键值对的值顺序写入Value Log,用于writebatch写入数据库,以及Value Log GC的时候(valuelog per table)。两者都会对多个键值对同时操作,因此设计为批处理。 该函数被mutex_保护,保证不会多线程调用。 ```cpp std::vector> WriteValueLog(std::vector value); ``` - **输入**:一个Slice vector,表示要写入Value Log的Value们。 - **输出**:一个std::pair vector,每个pair中:第一个uint64_t是Value Log文件ID,第二个uint64_t是处在Value Log中的偏移量。 > [!NOTE] > > 在第三版设计中,valuelog中会存储key,所以还需要传入key ##### **4.2.2 ReadValueLog** 通过Value Log读取目标键值对的值。 ```cpp Status ReadValueLog(uint64_t file_id, uint64_t offset,Slice* value); ``` - **输入**:第一个uint64_t是Value Log文件ID,第二个uint64_t是处在Value Log中的偏移量,第三个是指向要传回的value的指针。 - **输出**:一个Status,表示是否成功传回对应Value。 > [!NOTE] > > 在第三版设计中,valuelog中会存储key,所以还会多一个返回参数Key ##### **4.2.3 测试GC** 调用`MaybeScheduleGarbageCollect()`来安排一个后台线程执行垃圾回收任务。它会等待所有已安排的垃圾回收任务完成,这通过循环检查`background_garbage_collect_scheduled_`标志,并在该标志为真时等待`background_gc_finished_signal_`信号来实现。 ```cpp void DBImpl::TEST_GarbageCollect() ``` ##### **4.2.4 调用线程进行GC** 启动一个新的后台线程执行`BGWorkGC`方法。使用互斥锁gc_mutex_确保同时最多只有一个GC后台线程会进行。 ```cpp void DBImpl::MaybeScheduleGarbageCollect() ``` ##### **4.2.5 调用负责GC函数** 调用`BackgroundGarbageCollect()`进行实际的垃圾回收工作。 ```cpp void DBImpl::BGWorkGC(void* db) ``` ##### **4.2.6 后台GC函数** 负责执行后台垃圾回收任务(即调用GarbageCollect)。确保在完成任务后通知等待的线程。 (当前等待的函数仅两个:Test与DBImpl的析构函数) ```cpp void DBImpl::BackgroundGarbageCollect() ``` ##### **4.2.7 后台GC函数** 垃圾回收的核心实现。在目前的设计下,它遍历数据库目录中的所有valuelog文件,并尝试回收不再需要的数据。 ```cpp void DBImpl::GarbageCollect() ``` ##### 4.2.8 改动TableMeta(用于valuelog per table) 对TableMeta新增一个uint64_t的属性valuelog_id,表示该SSTable所对应的valuelog id。如果该SSTable内所有Value长度均小于ValueLog要求长度,则该属性值为0。 这个设计对versionEdit以及compact的各个函数均有影响。 ##### 4.2.9 class ValueLogInserter : public WriteBatch::Handler{} 用于将一个WriteBatch里的所有键值对插入到ValueLog里,并生成将Value指向ValueLog的新WriteBatch。 ##### 4.2.10 Status WriteBatchInternal::ConverToValueLog(WriteBatch* b,DB* db_) 使用ValueLogInserter将b中的键值对更新至ValueLog,并将更新后的WriteBatch放回b。 该函数用于Write中的队首线程build batch后且写WAL日志/写Memtable前。 ------ ## 5. 功能测试 ### 5.1**单元测试(测试用例)**: #### 依据我们的设计,每周的工作内容完成后,都将对当前完成的功能进行正确性检验。以下以第一周我们完成的功能为例: #### 第一周(已完成) **字段数组的存储与读取:** 验证了 `Put_with_fields` 和 `Get_with_fields` 的正确性,确保字段数组可以正确序列化存储并反序列化读取。 **基于字段的键查询:** 验证了 `Get_keys_by_field` 的逻辑,确保能够根据字段值查找所有匹配的键。 **Key Value分离:** 并未额外设计,通过上两个功能的正确运行能够证明Key Value分离的初步实现大体是正确的。 #### 第二、三周(已完成) **对多字段Value实现更严谨的测试** 针对范围查询进行更严格的测试(因为范围查询难于单点查询),进行了乱序插入后测试范围查询能否查询到分布在lsm-tree各处且key完全随机的目标value。 ``` std::vector keys; std::vector target_keys; for(int i=0;i<10000;i++){ std::string key=std::to_string(rand()%10000)+"_"+std::to_string(i);//random for generate nonincreasing keys FieldArray fields={ {"name", key}, {"address", std::to_string(rand()%7)}, {"phone", std::to_string(rand()%114514)} }; if(rand()%5==0){ fields[0].second="special_key"; target_keys.push_back(key); } keys.push_back(key); db->Put(WriteOptions(),key,SerializeValue(fields)); } std::sort(target_keys.begin(),target_keys.end()); std::vector key_res; Get_keys_by_field(db,ReadOptions(),{"name", "special_key"},&key_res); ASSERT_TRUE(CompareKey(key_res, target_keys)); ``` 2.向表内插入大量value较大的键值对后检验正确性(该测试被leveldb原benchmark完美取代。使用leveldb原benchmark获得了正确性保障) 对于for(int j=0;j<5000;j++)这一段,我们也尝试过将5000改成rand()%1000,同样通过了测试。 ``` std::vector values; for(int i=0;i<500000;i++){ std::string key=std::to_string(i); std::string value; for(int j=0;j<5000;j++){ value+=std::to_string(i); } values.push_back(value); db->Put(writeOptions,key,value); } for(int i=0;i<500000;i++){ std::string key=std::to_string(i); std::string value; Status s=db->Get(readOptions,key,&value); assert(s.ok()); if(values[i]!=value){ std::cout< values; for(int i=0;i<5000;i++){ std::string key=std::to_string(i); std::string value; for(int j=0;j<1000;j++){ value+=std::to_string(i); } values.push_back(value); db->Put(writeOptions,key,value); } std::cout<<"start gc"<TEST_GarbageCollect(); std::cout<<"finish gc"<Get(readOptions,key,&value); assert(s.ok()); if(values[i]!=value){ std::cout<