kevinyao0901 1eba8fa865 | před 6 dny | ||
---|---|---|---|
Report/png | 删除 | před 1 týdnem | |
benchmarks | 删除 | před 1 měsícem | |
cmake | 删除 | před 1 měsícem | |
db | 删除 | před 1 týdnem | |
doc | 删除 | před 1 měsícem | |
helpers/memenv | 删除 | před 1 měsícem | |
include/leveldb | 删除 | před 1 měsícem | |
issues | 删除 | před 1 měsícem | |
port | 删除 | před 1 měsícem | |
table | 删除 | před 1 měsícem | |
test | 删除 | před 1 týdnem | |
third_party | 删除 | před 1 měsícem | |
util | 删除 | před 1 měsícem | |
.clang-format | před 1 měsícem | ||
.gitignore | před 1 měsícem | ||
.gitmodules | před 1 měsícem | ||
AUTHORS | před 1 měsícem | ||
CMakeLists.txt | před 1 měsícem | ||
CONTRIBUTING.md | před 1 měsícem | ||
LICENSE | před 1 měsícem | ||
NEWS | před 1 měsícem | ||
README.md | před 6 dny | ||
TODO | před 1 měsícem |
在 LevelDB 的基础上设计和实现一个支持二级索引的功能,优化特定字段的查询效率。通过此功能,用户能够根据字段值高效地检索对应的数据记录,而不需要遍历整个数据库。
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
LevelDB
二级索引设计思路在 LevelDB
的基础上扩展,补充并实现以下组件:
主数据库(DBImpl):
存储用户原始数据的键值对,提供 Put
、Delete
和 Get
方法。
**二级索引数据库(indexDb_)**:
专门存储索引数据,键为 fieldName:fieldValue
,值为主数据库中对应的主键。
主数据库键格式:
使用字符串表示,例如:userID:123|name:JohnDoe
,包含多个字段。
索引键格式:
例如:userID:123
,方便通过字段值快速查询。
映射关系:
二级索引数据库的值存储主数据库的主键,用于指向完整数据记录。
fieldWithIndex_
:一个集合,用于管理需要创建索引的字段,支持动态增删。以下是主数据库和二级索引数据库的逻辑关系示意图:
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 |
| name:Customer#000000001 | k_3 |
| name:Customer#000000002 | k_2 |
+-----------------------------------+-------------+
数据关联关系
主数据库 <-------------> 二级索引数据库
(key) 映射到字段值 (fieldName:fieldValue)
Put
)Put
将数据插入到主数据库。关键点:
Delete
)Delete
从主数据库删除数据。关键点:
CreateIndex(fieldName)
,用于动态为字段创建索引:
fieldWithIndex_
集合。DeleteIndex(fieldName)
,用于动态删除字段索引:
fieldWithIndex_
集合中移除字段名。WriteBatch
封装多个操作(如 Put
和 Delete
)。Put
操作,删除已插入的数据。Delete
操作,恢复已删除的数据。这套设计在功能性、一致性和性能之间达到了较好的平衡,能够为 LevelDB 提供高效、灵活的二级索引支持,同时保持其原有的高性能特性。
为了便于审阅和维护,在项目中对代码的所有修改均使用统一的注释格式进行标记。具体而言,所有修改的代码块均以 //ToDo
开始,并以 //ToDo end
结束。通过这种方式,审阅者可以快速定位和识别修改内容,与原始代码进行对比。
在 LevelDB 的核心类 DBImpl
中,增加了对二级索引的支持,包括:
fieldWithIndex_
保存所有已经创建索引的字段名。indexDb_
管理二级索引数据库。class DBImpl : public DB {
private:
std::vector<std::string> fieldWithIndex_; // 已创建索引的字段列表
leveldb::DB* indexDb_; // 存储二级索引的数据库
};
在 DBImpl
中实现 CreateIndexOnField
方法,用于对指定字段创建二级索引:
"fieldName:field_value-key"
,值为原始数据的键。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();
}
在查询二级索引时,基于二级索引,通过范围查询从 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
}
fieldName
(目标字段名)。results
(包含所有匹配值的列表)。ReadOptions
初始化读取配置,并创建一个迭代器。Seek(fieldName)
将迭代器定位到目标字段的起始位置。Seek
方法快速定位)。此方法通过范围查询机制提升了效率,同时确保了结果的准确性。
在 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();
}
Put
和 Delete
方法的内容以下是实验报告中对 Put
和 Delete
方法的描述,以及如何通过事务和回滚机制实现数据插入与删除的原子性。
Put
方法描述Put
方法用于向主数据库和二级索引数据库中插入或更新数据。其关键步骤如下:
fieldWithIndex_
),从新值中提取字段对应的索引键和值,并将索引插入到二级索引数据库。关键代码:
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
方法用于从主数据库和二级索引数据库中删除数据。其关键步骤如下:
关键代码:
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;
}
...
}
通过以下策略确保数据插入与删除操作的原子性:
WriteBatch
封装,并在提交前记录必要的数据以支持回滚。这种设计确保了主数据库和二级索引数据库的一致性,即便在部分写入或删除操作失败的情况下,仍能通过回滚机制保证数据的完整性和原子性。
持久化是确保数据在系统崩溃或断电后依然能够恢复的关键功能。
在二级索引设计中,我们使用 Write-Ahead Logging (WAL)
技术和日志同步来实现持久化:
主数据库持久化:
主数据库的 Put
和 Delete
操作会先记录到日志文件中,再执行磁盘写入操作。
日志结构如下:
makefileCopy codeOperation: PUT
Key: k_1
Value: name:Customer#000000001|address:IVhzIApeRb|phone:25-989-741-2988
索引数据库持久化:
对 indexDb_
的每一次 Put
和 Delete
操作也需要写入 WAL。
示例日志:
makefileCopy codeOperation: PUT
Key: name:Customer#000000001-k_1
Value: k_1
同步写入策略:
indexDb_
的写操作采用 事务 来实现同步写入,确保一致性。恢复机制在系统崩溃后发挥作用,通过解析日志重建数据:
主数据库恢复: 通过 WAL 日志文件重放,将最近一次写入操作重新应用到主数据库。
索引数据库恢复: 索引数据库的恢复流程如下:
检查 indexDb_
的日志文件,逐条读取并应用日志操作。
如果indexDb_
的日志不完整,基于主数据库的快照重新构建索引:
fieldWithIndex_
中的字段生成索引条目并写入 indexDb_
。一致性校验: 恢复完成后,通过校验主数据库和索引数据库的一致性来验证数据完整性:
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
CreateIndexOnField("name")
,索引数据库生成条目:
name:Customer#000000001-k_1 : k_1
name:Customer#000000002-k_2 : k_2
QueryByIndex("name:Customer#000000001")
,返回 ["k_1"]
。DeleteIndex("name")
,移除所有 name:
开头的索引条目。测试结果:
整体来看,该系统在性能上表现良好,适用于高吞吐量的写入操作,并能有效提升查询性能。
指标 | 有索引 | 无索引 | 对比 |
---|---|---|---|
吞吐量(OPS) | 57,646.9–69,547.3 | 75,930.8–113,652 | 无索引吞吐量更高,差距随着数据量增加缩小;有索引时写入性能受影响较大。 |
平均延迟(ms) | 0.0132–0.0162 | 0.0077–0.0119 | 无索引平均延迟低,延迟开销主要来自索引更新操作。 |
P75 延迟(ms) | 0.013–0.014 | 0.008–0.011 | 无索引 P75 延迟更优,表明其写入响应时间更快。 |
P99 延迟(ms) | 0.017–0.028 | 0.01–0.016 | 无索引高百分位延迟更优,写入尾部响应性能优于有索引场景。 |
利用多个线程并发写入10^6的数据:
在没有并发的情况下,分别测量了多个写入操作的时间和性能:
17692.5 OPS
,而最后一次测试为 10000 OPS
,这说明随着写入数量的增加,数据库的写入性能逐渐受到影响,可能是因为写入操作的负载增大,导致数据库处理每个操作的效率降低。0.0554 ms
,而最后一次测试的延迟为 0.0988 ms
。这表明随着数据量的增加,数据库写入的延迟也在逐步增加。并发测试的结果如下:
79800.3 OPS
,这个吞吐量是多线程并发的总和,比单线程测试时的吞吐量要高很多。这是因为并发写入操作有效利用了多核处理器的能力,能够提高整体的吞吐量。从数据中可以观察到二级索引查询和直接遍历在不同数据量下的性能表现差异:
无索引查询时间(直接遍历):
有索引查询时间:
无索引写入:
有索引写入:
总结:二级索引在查询密集型任务中非常高效,但需要权衡写入性能和索引维护的成本。
indexDb_
的递归调用?在实现 Put
和 Delete
方法时,由于 indexDb_
也调用了 DBImpl
的方法,可能导致递归调用的问题。具体表现为在 indexDb_
内部操作时仍会试图更新索引。
在 Put
和 Delete
方法中,添加检查逻辑。如果当前对象是 indexDb_
,则仅对主数据库进行操作,而不再更新索引。例如:
if (indexDb_ != nullptr) {
// 仅更新主数据库的事务
} else {
// 更新索引
}
在二级索引更新失败时,需要确保主数据库的修改也能回滚,以保持数据一致性。
使用 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); // 执行回滚
}
如果需要为多个字段动态创建和删除索引,可能导致额外的开销以及管理复杂性。
fieldWithIndex_
字段集中管理所有需要创建索引的字段。**Put**
和 **Delete**
方法如何确保原子性?在更新主数据库和二级索引时,如果某一步骤失败,可能导致不一致。
通过事务 (WriteBatch
) 确保多个操作要么全部成功,要么全部回滚。例如:
s = this->Write(o, &batch);
if (!s.ok()) {
return s; // 确保写入失败时停止后续操作
}
s = indexDb_->Write(o, &indexBatch);
if (!s.ok()) {
// 回滚主数据库操作
}
通过以上方案,有效解决了实验中遇到的问题,并提高了系统的稳定性和一致性。
本实验通过在 DBImpl
中集成索引管理功能,实现了对二级索引的创建、查询和删除。二级索引数据存储在独立的 indexDb_
中,通过高效的键值映射提升了字段值查询的效率。