LevelDB二级索引实现 姚凯文(kevinyao0901) 姜嘉祺
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

27 KiB

实验报告:在 LevelDB 中构建二级索引的设计与实现


目录


一,实验目的

在 LevelDB 的基础上设计和实现一个支持二级索引的功能,优化特定字段的查询效率。通过此功能,用户能够根据字段值高效地检索对应的数据记录,而不需要遍历整个数据库。



二,项目背景概述

1. 背景与需求

LevelDB 是一个高性能、轻量级的键值存储引擎,但其查询能力仅限于主键。在许多应用场景中,需要支持基于非主键字段的高效查询(例如按用户 ID 或类别查询数据)。因此,设计并实现二级索引系统,为 LevelDB 增强多字段查询能力,成为一个核心需求。

二级索引的概念

二级索引是一种额外的数据结构,用于加速某些特定字段的查询。在 LevelDB 中,键值对的存储是以 key:value 的形式。通过创建二级索引,我们将目标字段的值与原始 key 建立映射关系,存储在独立的索引数据库中,从而支持基于字段值的快速查询。

例如,原始数据如下:

k_1 : name:Customer#000000001|address:IVhzIApeRb|phone:25-989-741-2988
k_2 : name:Customer#000000002|address:XSTf4,NCwDVaW|phone:23-768-687-3665
k_3 : name:Customer#000000001|address:MG9kdTD2WBHm|phone:11-719-748-3364

为字段 name 创建索引后,索引数据库中的条目如下:

name:Customer#000000001-k_1 : k_1
name:Customer#000000001-k_3 : k_3
name:Customer#000000002-k_2 : k_2

2. 设计目标

  • 高效性:二级索引查询性能接近主键查询。
  • 一致性:保证主数据库与二级索引的一致性,支持事务和回滚机制。
  • 灵活性:允许用户指定需要创建索引的字段,支持动态创建和删除索引。
  • 易用性:通过统一接口隐藏索引管理的复杂性,保持与原始 LevelDB 类似的用户体验。


三,LevelDB二级索引设计思路

1. 设计结构

LevelDB 的基础上扩展,补充并实现以下组件:

1.1 核心组件
  1. 主数据库(DBImpl)
    存储用户原始数据的键值对,提供 PutDeleteGet 方法。

  2. **二级索引数据库(indexDb_)**:
    专门存储索引数据,键为 fieldName:fieldValue,值为主数据库中对应的主键。

1.2 数据结构
  1. 主数据库键格式
    使用字符串表示,例如:userID:123|name:JohnDoe,包含多个字段。

  2. 索引键格式
    例如:userID:123,方便通过字段值快速查询。

  3. 映射关系
    二级索引数据库的值存储主数据库的主键,用于指向完整数据记录。

1.3 字段管理
  • fieldWithIndex_:一个集合,用于管理需要创建索引的字段,支持动态增删。

1.4 数据结构关系图

以下是主数据库和二级索引数据库的逻辑关系示意图:

lessCopy code                     主数据库 (DBImpl)
+-------------------------------------------------------+
| key   | value                                         |
+-------+-----------------------------------------------+
| k_1   | name:Customer#000000001|address:IVhzIApeRb|.. |
| k_2   | name:Customer#000000002|address:XSTf4,NCwDVaW |
+-------+-----------------------------------------------+

                     二级索引数据库 (indexDb_)
+----------------------------------------+-------------+
| indexKey                               | indexValue  |
+----------------------------------------+-------------+
| name:Customer#000000001-k_1            | k_1         |
| name:Customer#000000001-k_3            | k_3         |
| name:Customer#000000002-k_2            | k_2         |
+----------------------------------------+-------------+

                     数据关联关系
主数据库  <------------->  二级索引数据库
 (key)         映射到字段值 (fieldName:fieldValue)

2. 计划实现细节

2.1 数据插入流程 (Put)

  1. 用户调用 Put 将数据插入到主数据库。
  2. 从用户数据中解析需要创建索引的字段及其值。
  3. 构造二级索引的键值对,并插入到二级索引数据库中。
  4. 如果任意数据库的写入失败,通过事务回滚保证一致性。

关键点

  • 需要先提交主数据库事务,再提交二级索引数据库事务。
  • 索引更新时要考虑覆盖旧索引的场景。

2.2 数据删除流程 (Delete)

  1. 用户调用 Delete 从主数据库删除数据。
  2. 在删除前,读取原始数据以提取相关字段的索引键。
  3. 删除主数据库中的数据。
  4. 删除对应的二级索引键。
  5. 如果任意数据库的删除失败,通过事务回滚恢复数据。

关键点

  • 删除前必须读取原始数据以提取相关索引信息。
  • 回滚时需恢复原始主数据库记录。

3.3 查询流程

  1. 用户指定查询条件(字段名和字段值)。
  2. 从二级索引数据库中获取与查询条件匹配的主键。
  3. 使用主键从主数据库获取完整记录。

3. 动态索引管理

3.1 动态创建索引

  • 提供接口 CreateIndex(fieldName),用于动态为字段创建索引:
    • 遍历主数据库的所有记录。
    • 根据指定字段生成索引键值对并插入到二级索引数据库。
    • 将字段名添加到 fieldWithIndex_ 集合。

3.2 动态删除索引

  • 提供接口 DeleteIndex(fieldName),用于动态删除字段索引:
    • 遍历二级索引数据库,删除与该字段相关的索引键。
    • fieldWithIndex_ 集合中移除字段名。

4. 事务与回滚机制

4.1 事务设计

  • 使用 WriteBatch 封装多个操作(如 PutDelete)。
  • 在主数据库和二级索引数据库上分别维护独立事务。

4.2 回滚机制

  • 在主数据库操作失败时直接返回错误,不影响索引。
  • 在二级索引操作失败时,回滚主数据库的写入或删除操作:
    • Put 操作,删除已插入的数据。
    • Delete 操作,恢复已删除的数据。

5. 设计的优势

  1. 数据一致性强:通过事务和回滚机制,确保主数据库和二级索引数据库始终保持一致。
  2. 查询高效:支持基于字段的快速查询,二级索引性能接近主键查询。
  3. 易于扩展:动态索引创建和删除机制使得系统适应性更强。
  4. 兼容性好:用户接口保持与原始 LevelDB 类似,降低学习成本。

6. 未来优化方向

  1. 多字段联合索引:支持对多个字段的联合索引,提高复杂查询的效率。
  2. 异步索引更新:通过异步任务队列优化索引构建和更新的性能。
  3. 空间优化:采用压缩技术减少二级索引数据库的存储占用。
  4. 并发支持:优化写锁机制以提高高并发场景下的性能。

这套设计在功能性、一致性和性能之间达到了较好的平衡,能够为 LevelDB 提供高效、灵活的二级索引支持,同时保持其原有的高性能特性。



四,具体实现

为了便于审阅和维护,在项目中对代码的所有修改均使用统一的注释格式进行标记。具体而言,所有修改的代码块均以 //ToDo 开始,并以 //ToDo end 结束。通过这种方式,审阅者可以快速定位和识别修改内容,与原始代码进行对比。

1. DBImpl 类的设计

在 LevelDB 的核心类 DBImpl 中,增加了对二级索引的支持,包括:

  • 索引字段管理:使用成员变量 fieldWithIndex_ 保存所有已经创建索引的字段名。
  • 索引数据库:使用成员变量 indexDb_ 管理二级索引数据库。
class DBImpl : public DB {
private:
    std::vector<std::string> fieldWithIndex_; // 已创建索引的字段列表
    leveldb::DB* indexDb_;                    // 存储二级索引的数据库
};

2. 二级索引的创建

DBImpl 中实现 CreateIndexOnField 方法,用于对指定字段创建二级索引:

  • 遍历主数据库中的所有数据记录。
  • 解析目标字段的值。
  • 在索引数据库中写入二级索引条目,键为 "fieldName:field_value-key",值为原始数据的键。

示例: error

核心代码:

Status DBImpl::CreateIndexOnField(const std::string& fieldName) {
    // 检查字段是否已创建索引
    for (const auto& field : fieldWithIndex_) {
        if (field == fieldName) {
            return Status::InvalidArgument("Index already exists for this field");
        }
    }

    // 添加到已创建索引的字段列表
    fieldWithIndex_.push_back(fieldName);

    // 遍历主数据库,解析字段值并写入索引数据库
    leveldb::ReadOptions read_options;
    leveldb::Iterator* it = this->NewIterator(read_options);

    for (it->SeekToFirst(); it->Valid(); it->Next()) {
        std::string key = it->key().ToString();
        std::string value = it->value().ToString();

        // 提取字段值
        // ...
        // 在索引数据库中创建条目
        // ...
    }

    delete it;
    return Status::OK();
}

3. 二级索引的查询

在查询二级索引时,基于二级索引,通过范围查询从 LevelDB 数据库中检索与指定字段名 (fieldName) 相关联的所有值。

核心代码:

// 查询通过字段名索引的所有值
std::vector<std::string> DBImpl::QueryByIndex(const std::string& fieldName) {
    function QueryByIndex(fieldName):
    results = []  // 用于存储查询结果

    // 初始化读取选项和迭代器
    create ReadOptions read_options
    create Iterator it from indexDb_ using read_options

    // 遍历所有键值对,从 fieldName 开始
    for it.Seek(fieldName) to it.Valid():
        key = current key from it
        value = current value from it

        // 如果键匹配并且值非空,将其加入结果列表
        if key equals fieldName and value is not empty:
            add value to results

    // 检查迭代器的状态是否正常
    if iterator status is not OK:
        log error with status message

    return results

}
  1. 输入与输出:
    • 输入:fieldName(目标字段名)。
    • 输出:results(包含所有匹配值的列表)。
  2. 逻辑流程:
    • 使用 ReadOptions 初始化读取配置,并创建一个迭代器。
    • 调用 Seek(fieldName) 将迭代器定位到目标字段的起始位置。
    • 遍历满足条件的键值对:
      • 如果键等于目标字段名,并且值非空,将值添加到结果列表。
    • 在遍历结束后,检查迭代器的状态以捕捉可能的错误。
  3. 优势:
    • 使用迭代器实现范围查询(Seek 方法快速定位)。
    • 避免全表扫描,提高查询效率。
    • 针对多值字段支持查询返回多个结果。
  4. 错误处理:
    • 如果迭代过程中出现错误,记录错误信息,便于调试。

此方法通过范围查询机制提升了效率,同时确保了结果的准确性。


4. 二级索引的删除

DBImpl 中实现 DeleteIndex 方法,通过目标字段名移除对应的所有索引条目:

  • fieldWithIndex_ 中移除字段。
  • 遍历索引数据库,删除所有以 fieldName: 开头的条目。

核心代码:

Status DBImpl::DeleteIndex(const std::string& fieldName) {
    auto it = std::find(fieldWithIndex_.begin(), fieldWithIndex_.end(), fieldName);
    if (it == fieldWithIndex_.end()) {
        return Status::NotFound("Index not found for this field");
    }

    // 从已创建索引列表中移除字段
    fieldWithIndex_.erase(it);

    // 遍历索引数据库,删除相关条目
    leveldb::ReadOptions read_options;
    leveldb::Iterator* it_index = indexDb_->NewIterator(read_options);

    for (it_index->SeekToFirst(); it_index->Valid(); it_index->Next()) {
        std::string index_key = it_index->key().ToString();
        
        // ...
    }

    delete it_index;
    return Status::OK();
}

5. PutDelete 方法的内容

以下是实验报告中对 PutDelete 方法的描述,以及如何通过事务和回滚机制实现数据插入与删除的原子性。

Put 方法描述

Put 方法用于向主数据库和二级索引数据库中插入或更新数据。其关键步骤如下:

  1. 主数据库写入:首先尝试向主数据库插入或更新数据。
  2. 二级索引更新:遍历需要创建索引的字段 (fieldWithIndex_),从新值中提取字段对应的索引键和值,并将索引插入到二级索引数据库。
  3. 提交事务
    • 提交主数据库的写入操作。
    • 提交二级索引数据库的写入操作。
  4. 回滚机制:如果二级索引数据库的写入失败,会回滚主数据库的插入操作以确保数据一致性。

关键代码:

Status DBImpl::Put(const WriteOptions& o, const Slice& key, const Slice& val) {
  // 创建读写锁,放在函数内部
  static std::mutex rw_mutex_; // 替换为 std::mutex,适用于 C++11 或更低版本
  
  // 加写锁,确保写操作的原子性
  std::unique_lock<std::mutex> lock(rw_mutex_);
    
  ...
  // 在主数据库写入新数据
  batch.Put(key, val);

  // 遍历字段并更新索引
  for (const auto& field : fieldWithIndex_) {
      ...
      indexBatch.Put(Slice(indexKey), Slice(indexValue));
  }

  // 提交主数据库事务
  s = this->Write(o, &batch);
  if (!s.ok()) {
      return s;
  }

  // 提交二级索引数据库事务
  s = indexDb_->Write(o, &indexBatch);
  if (!s.ok()) {
      // 如果二级索引写入失败,回滚主数据库写入
      for (const auto& insertedKey : keysInserted) {
          batch.Delete(insertedKey);
      }
      this->Write(o, &batch);
      return s;
  }
  ...
}

Delete 方法描述

Delete 方法用于从主数据库和二级索引数据库中删除数据。其关键步骤如下:

  1. 获取原始数据:在删除前从主数据库读取原始值,确保在删除失败时可以回滚。
  2. 主数据库删除:从主数据库中删除目标键。
  3. 二级索引删除:遍历字段,计算对应的索引键,并将其从二级索引数据库中删除。
  4. 提交事务
    • 提交主数据库的删除操作。
    • 提交二级索引数据库的删除操作。
  5. 回滚机制:如果二级索引数据库的删除失败,会尝试将主数据库的删除操作回滚为原始状态。

关键代码:

Status DBImpl::Delete(const WriteOptions& options, const Slice& key) {
  // 创建读写锁,放在函数内部
  static std::mutex rw_mutex_; // 替换为 std::mutex,适用于 C++11 或更低版本
  
  // 加写锁,确保写操作的原子性
  std::unique_lock<std::mutex> lock(rw_mutex_);  
    
  ...
  // 从主数据库删除目标键
  batch.Delete(key);

  // 遍历字段并删除索引
  for (const auto& field : fieldWithIndex_) {
      ...
      indexBatch.Delete(Slice(indexKey));
  }

  // 提交主数据库事务
  s = this->Write(options, &batch);
  if (!s.ok()) {
      return s;
  }

  // 提交二级索引数据库事务
  s = indexDb_->Write(options, &indexBatch);
  if (!s.ok()) {
      // 如果二级索引删除失败,回滚主数据库删除
      if (!originalValue.empty()) {
          batch.Put(key, originalValue);
      } else {
          batch.Put(key, "");
      }
      this->Write(options, &batch);
      return s;
  }
  ...
}

6. 数据插入与删除的原子性实现

通过以下策略确保数据插入与删除操作的原子性:

  1. 事务机制
    • 主数据库和二级索引数据库的写入操作分别使用 WriteBatch 封装,并在提交前记录必要的数据以支持回滚。
  2. 错误处理与回滚
    • 如果二级索引数据库的写入或删除操作失败,主数据库的写入或删除操作将被回滚。
    • 在回滚过程中,主数据库会恢复为操作前的状态(插入操作时删除新数据,删除操作时恢复原始数据)。

实现意义

这种设计确保了主数据库和二级索引数据库的一致性,即便在部分写入或删除操作失败的情况下,仍能通过回滚机制保证数据的完整性和原子性。



7. 持久化与恢复机制

1. 持久化机制

持久化是确保数据在系统崩溃或断电后依然能够恢复的关键功能。 在二级索引设计中,我们使用 Write-Ahead Logging (WAL) 技术和日志同步来实现持久化:

  • 主数据库持久化: 主数据库的 PutDelete 操作会先记录到日志文件中,再执行磁盘写入操作。 日志结构如下:

    makefileCopy codeOperation: PUT
    Key: k_1
    Value: name:Customer#000000001|address:IVhzIApeRb|phone:25-989-741-2988
    
  • 索引数据库持久化:indexDb_ 的每一次 PutDelete 操作也需要写入 WAL。 示例日志:

    makefileCopy codeOperation: PUT
    Key: name:Customer#000000001-k_1
    Value: k_1
    
  • 同步写入策略:

    • 对主数据库和 indexDb_ 的写操作采用 事务 来实现同步写入,确保一致性。
    • 如果主数据库操作失败,回滚索引的更新;反之亦然。

2. 恢复机制

恢复机制在系统崩溃后发挥作用,通过解析日志重建数据:

  • 主数据库恢复: 通过 WAL 日志文件重放,将最近一次写入操作重新应用到主数据库。

  • 索引数据库恢复: 索引数据库的恢复流程如下:

    1. 检查 indexDb_ 的日志文件,逐条读取并应用日志操作。

    2. 如果indexDb_的日志不完整,基于主数据库的快照重新构建索引:

      • 遍历主数据库中的每条记录,根据 fieldWithIndex_ 中的字段生成索引条目并写入 indexDb_
  • 一致性校验: 恢复完成后,通过校验主数据库和索引数据库的一致性来验证数据完整性:

    • 对每个索引字段,随机抽样检查索引值是否能正确定位主数据库中的记录。


五,性能测试

1.测试流程

单元测试:

  1. 插入原始数据:
    k_1 : name:Customer#000000001|address:IVhzIApeRb|phone:25-989-741-2988
    k_2 : name:Customer#000000002|address:XSTf4,NCwDVaW|phone:23-768-687-3665
    
  2. 创建索引:
    • 调用 CreateIndexOnField("name"),索引数据库生成条目:
      name:Customer#000000001-k_1 : k_1
      name:Customer#000000002-k_2 : k_2
      
  3. 查询索引:
    • 调用 QueryByIndex("name:Customer#000000001"),返回 ["k_1"]
  4. 删除索引:
    • 调用 DeleteIndex("name"),移除所有 name: 开头的索引条目。

测试结果:

error

性能测试:

Benchmark测试运行结果及分析: error

2.结果分析

1. 总写入时间(Total time for 100001 writes)

  • 结果: 1464.65 ms
  • 分析: 该值表示100001次写入操作所花费的总时间,接近1.46秒。这个时间段包括了所有写入操作和可能的其他处理,如日志记录和合并等。根据吞吐量(Throughput)可以看出每秒操作次数约为68277次,说明写入性能还是较为稳定。

2. 吞吐量(Throughput)

  • 结果: 68276.6 OPS (operations per second)
  • 分析: 吞吐量是每秒钟能够完成的写操作数量,68277次每秒的吞吐量表明系统在进行批量写入时性能良好,适合高负载下的写入操作。

3. 平均延迟(Average latency)

  • 结果: 0.0133881 ms
  • 分析: 平均延迟为0.0134毫秒,表示每个写入操作的平均响应时间非常低。延迟较低说明系统能够快速响应每个请求,这对需要低延迟的应用非常重要。

4. P75 和 P99 延迟

  • 结果: P75 latency: 0.012 ms, P99 latency: 0.016 ms
  • 分析:
    • P75延迟为0.012毫秒,表示75%的请求在0.012毫秒内完成。
    • P99延迟为0.016毫秒,表示99%的请求在0.016毫秒内完成。
    • 这表明绝大多数请求的延迟都非常小,延迟稳定,且尾延迟(P99)也处于可接受范围。

5. 索引创建时间(Time to create index)

  • 结果: 661884 microseconds (~661.88 ms)
  • 分析: 索引的创建时间接近660毫秒,可能由于索引数据结构的构建和填充过程。该时间相对较长,但不至于影响整体性能。

6. 索引查询时间(Time with index)

  • 结果: 97 microseconds
  • 分析: 使用索引进行查询的时间非常低,仅为97微秒。这表明通过索引可以显著提高查询性能,特别是在查询大数据量时,查询响应时间大幅降低。

7. 写放大(Write Amplification)

  • 结果: Write Amplification Factor: 2.24915
  • 分析: 写放大因子为2.25,表示系统写入的数据量是原始数据量的2.25倍。这个值较为理想,说明合并过程和磁盘上的写入是有效的,没有造成过多的额外写入。

8. 总数据写入量(Total data written)

  • 结果: 7058762 bytes (~7.06 MB)
  • 分析: 这是在进行100001次写入操作过程中,系统写入到磁盘的总字节数。与写放大因子2.25结合来看,系统的写入开销较为合理。

9. 索引删除时间(Time to delete index on field 'name')

  • 结果: 1235916 microseconds (~1235.92 ms)
  • 分析: 删除索引所需的时间较长,接近1.24秒。这可能是因为索引结构的更新和删除操作涉及较多的计算和磁盘操作,影响了响应速度。

10. 数据库状态(Compactions)

  • 分析:
    • 在Level 0上有1个文件,大小为3MB,写入了7MB的数据。
    • 在Level 1上有4个文件,大小为8MB,写入了15MB的数据。
    • 这些数据说明系统在进行数据合并时,Level 0和Level 1的文件大小和写入数据量都显示出一定的合并操作,表明系统在进行正常的磁盘整理。

总结

  • 性能优点:
    • 吞吐量较高(68277 OPS),平均延迟和尾延迟较低,表明系统在高负载下能够稳定运行。
    • 索引查询时间非常低,能够有效提升查询性能。
    • 写放大因子在2.25左右,表现较为理想。
  • 需要关注的方面(代价):
    • 索引创建和删除操作时间较长,尤其是删除索引的时间较为显著,可能需要优化索引管理策略。
    • 合并操作(Compaction)虽然正常,但可以根据实际需求进一步优化以减少对性能的影响。

整体来看,该系统在性能上表现良好,适用于高吞吐量的写入操作,并能有效提升查询性能。



六,问题与解决方案

1.问题:如何避免 indexDb_ 的递归调用?

在实现 PutDelete 方法时,由于 indexDb_ 也调用了 DBImpl 的方法,可能导致递归调用的问题。具体表现为在 indexDb_ 内部操作时仍会试图更新索引。

解决方案:

PutDelete 方法中,添加检查逻辑。如果当前对象是 indexDb_,则仅对主数据库进行操作,而不再更新索引。例如:

if (indexDb_ != nullptr) {
    // 仅更新主数据库的事务
} else {
    // 更新索引
}

2. 问题:二级索引的事务回滚机制如何设计?

在二级索引更新失败时,需要确保主数据库的修改也能回滚,以保持数据一致性。

解决方案:

使用 WriteBatch 记录每次操作。在二级索引更新失败后,通过读取原始值或删除新插入的键,恢复主数据库的状态。示例代码如下:

if (!s.ok()) {
    for (const auto& insertedKey : keysInserted) {
        if (!originalValue.empty()) {
            batch.Put(insertedKey, Slice(originalValue));
        } else {
            batch.Delete(insertedKey);
        }
    }
    this->Write(o, &batch); // 执行回滚
}

3. 问题:如何高效管理多字段的动态索引?

如果需要为多个字段动态创建和删除索引,可能导致额外的开销以及管理复杂性。

解决方案:

  1. 使用 fieldWithIndex_ 字段集中管理所有需要创建索引的字段。
  2. 在动态操作中,通过扫描主数据库快速生成或删除指定字段的索引条目。
  3. 提供统一的接口,用于添加和移除字段。

4. 问题:**Put** **Delete** 方法如何确保原子性?

在更新主数据库和二级索引时,如果某一步骤失败,可能导致不一致。

解决方案:

通过事务 (WriteBatch) 确保多个操作要么全部成功,要么全部回滚。例如:

s = this->Write(o, &batch);
if (!s.ok()) {
    return s; // 确保写入失败时停止后续操作
}
s = indexDb_->Write(o, &indexBatch);
if (!s.ok()) {
    // 回滚主数据库操作
}

通过以上方案,有效解决了实验中遇到的问题,并提高了系统的稳定性和一致性。



七,总结

本实验通过在 DBImpl 中集成索引管理功能,实现了对二级索引的创建、查询和删除。二级索引数据存储在独立的 indexDb_ 中,通过高效的键值映射提升了字段值查询的效率。