数据库需要一个地方来存储和检索数据。这就是 Qdrant 的键值存储所做的事情——它将键与值链接起来。
当我们开始构建 Qdrant 时,我们需要选择一个适合这项任务的现有方案。因此,我们选择了 RocksDB 作为我们的嵌入式键值存储。
它成熟、可靠且文档齐全。

随着时间的推移,我们遇到了问题。它的架构需要进行数据压缩(使用了 LSMT),这会导致随机的延迟尖峰。它处理通用键,而我们只使用它来存储顺序 ID。大量的配置选项使其用途广泛,但准确地调整它却令人头疼。最后,与 C++ 的互操作性减慢了我们的速度(尽管我们还会支持它相当长一段时间 😭)。
虽然已经有一些优秀的 Rust 编写的选项我们可以利用,但我们需要一些定制的东西。没有任何现有的方案能够完全满足我们的需求。我们不需要通用键。我们希望完全控制何时以及哪些数据被写入和刷新。我们的系统已经内置了崩溃恢复机制。在线数据压缩并不是优先事项,我们已经有优化器来处理它。调试错误的配置占用了我们大量时间。
所以我们构建了自己的存储。从 Qdrant 1.13 版本开始,我们正在使用 Gridstore 来存储负载数据和稀疏向量数据。
简单、高效,并且专为 Qdrant 设计。

在本文中,您将了解
Gridstore 如何工作——深入探讨其架构和机制。
- 我们为何这样构建它——塑造它的关键设计决策。
- 严格的测试——我们如何确保新存储引擎已为生产环境做好准备。
- 性能基准测试——展示其效率的官方指标。
- 我们的第一个挑战?找出处理顺序键和变长数据的最佳方式。
Gridstore 架构:三个主要组件
Gridstore 的架构围绕三个关键组件构建,它们实现了快速查找和高效的空间管理
组件
描述 | 数据层 |
---|---|
将值存储在固定大小的块中,并使用基于指针的查找系统进行检索。 | 掩码层 |
使用位掩码来跟踪哪些块正在使用,哪些块可用。 | 间隙层 |
在更高级别管理块的可用性,从而实现快速的空间分配。 | 1. 用于快速检索的数据层 |
Gridstore 的核心是数据层,它旨在根据键快速存储和检索值。该层使我们能够进行高效的读取,并允许存储变长数据。该层的主要两个组件是追踪器和数据网格。
由于内部 ID 始终是顺序整数(0, 1, 2, 3, 4, ...),因此追踪器是一个指针数组,其中每个指针都精确地告诉系统值从哪里开始以及它的长度是多少。
数据层使用指针数组来快速检索数据。

这使得查找速度极快。例如,查找键 3 只需跳到追踪器中的第三个位置,然后跟随指针在数据网格中找到对应的值。
然而,由于值是变长的,数据本身被单独存储在一个固定大小的块网格中,这些块被分组到更大的页面文件中。每个块的固定大小通常为 128 字节。插入值时,Gridstore 会分配一个或多个连续块来存储它,确保每个块只包含来自单个值的数据。
2. 掩码层用于空间复用
掩码层帮助 Gridstore 处理更新和删除,而无需昂贵的数据压缩。Gridstore 不为每个块维护复杂的元数据,而是使用位掩码跟踪使用情况,其中每个位代表一个块,1 表示已使用,0 表示空闲。
位掩码高效地跟踪块使用情况。

这使得确定新值可以写入的位置变得容易。删除值时,它会在其指针处被软删除,并且位掩码中对应的块被标记为可用。类似地,更新值时,新版本被写入其他位置,旧块在位掩码中被释放。
这种方法确保 Gridstore 不会浪费空间。然而,随着存储的增长,扫描整个位掩码以查找可用块可能会变得计算密集。
3. 间隙层用于有效更新
为了进一步优化更新处理,Gridstore 引入了间隙层,它提供了块可用性的更高级别视图。
Gridstore 不扫描整个位掩码,而是将位掩码分割成区域,并跟踪每个区域内最大的连续空闲空间,称为区域间隙。通过存储每个区域的头部和尾部间隙,系统可以在需要存储大值时高效地合并多个区域。
Gridstore 的完整架构

这种分层方法使 Gridstore 能够快速定位可用空间,减少扫描所需的工作量,同时保持最小的内存开销。有了这个系统,为新值查找存储空间只需要扫描总元数据的极小一部分,使得更新和插入高度高效,即使在大型段中也是如此。
在默认配置下,间隙层仅占实际存储大小的百万分之一。这意味着对于每 1GB 数据,间隙层只需要扫描 6KB 的元数据。有了这个机制,其他操作几乎可以在恒定时间复杂度下执行。
Gridstore 在生产环境中的数据完整性维护
Gridstore 的架构引入了多个相互依赖的结构,它们必须保持同步以确保数据完整性
数据层存储数据并将每个键与其在存储中的位置关联起来,包括页面 ID、块偏移量及其值的大小。
- 掩码层跟踪哪些块被占用以及哪些是空闲的。
- 间隙层提供空闲块的索引视图,用于高效的空间分配。
- 每次插入新值或更新现有值时,所有这些组件都需要以协调的方式进行修改。
现实生活中的故障
现实世界的系统并非在真空中运行。故障总是会发生:软件错误导致意外崩溃、内存耗尽迫使进程终止、磁盘无法可靠地持久化数据,以及断电可能随时中断操作。
关键问题是:如果在更新这些结构时发生故障会怎样?
如果一个组件更新了而另一个没有,整个系统可能会变得不一致。更糟的是,如果一个操作只部分写入磁盘,可能会导致孤立数据、不可用空间,甚至数据损坏。
通过幂等性实现稳定性:使用 WAL 进行恢复
为了防范这些风险,Qdrant 依赖于 预写日志 (WAL)。在提交操作之前,Qdrant 会确保它至少记录在 WAL 中。如果所有更新在刷新之前发生崩溃,系统可以安全地重放日志中的操作。
这种恢复机制引入了另一个基本属性:幂等性。
存储系统必须设计成:在故障后重新应用相同的操作,会导致与该操作只应用一次相同的最终状态。
宏伟的解决方案:惰性更新
为了实现这一点,Gridstore 惰性地完成更新,优先处理写入中最关键的部分:数据本身。
👉 它不是立即更新所有元数据结构,而是先写入新值,同时在缓冲区中保留轻量级的挂起更改。
👉 系统仅在明确请求时才最终确定这些更新,确保崩溃永远不会导致在更新安全地持久化之前将数据标记为已删除。 |
👉 在最坏的情况下,Gridstore 可能需要写入两次相同的数据,导致微小的空间开销,但它永远不会通过覆盖有效数据来破坏存储。 |
我们如何测试最终产品 |
首先……模型测试
Gridstore 可以使用模型测试有效地进行测试,即将它的行为与简单的内存哈希映射进行比较。由于 Gridstore 应该像持久化的哈希映射一样工作,这种方法可以快速检测不一致性。
过程很简单
初始化一个 Gridstore 实例和一个空的哈希映射。
- 在两者上运行随机操作(放入、删除、更新)。
- 验证每次操作后结果是否匹配。
- 比较所有键和值以确保一致性。
- 这种方法提供了高测试覆盖率,暴露了诸如持久化错误或删除故障等问题。运行大规模的模型测试确保 Gridstore 在实际使用中保持可靠。
这里有一个在 Rust 中生成操作的简单方法。
模型测试是发现 bug 的高效方法,特别是当您的系统模仿一个定义明确的组件(如哈希映射)时。如果您的组件行为与另一个组件相同,使用模型测试会带来很大价值,而付出的努力相对较少。
enum Operation {
Put(PointOffset, Payload),
Delete(PointOffset),
Update(PointOffset, Payload),
}
impl Operation {
fn random(rng: &mut impl Rng, max_point_offset: u32) -> Self {
let point_offset = rng.random_range(0..=max_point_offset);
let operation = rng.gen_range(0..3);
match operation {
0 => {
let size_factor = rng.random_range(1..10);
let payload = random_payload(rng, size_factor);
Operation::Put(point_offset, payload)
}
1 => Operation::Delete(point_offset),
2 => {
let size_factor = rng.random_range(1..10);
let payload = random_payload(rng, size_factor);
Operation::Update(point_offset, payload)
}
_ => unreachable!(),
}
}
}
我们可以对照 RocksDB 进行测试,但简单性更重要。简单的哈希映射让我们能够快速运行大量的测试序列,更快地暴露问题。
为了实现更精确的调试,基于属性的测试增加了自动化测试生成和缩减。它能够用最小化的测试用例精确定位故障,使 bug 查找更快、更有效。
崩溃测试:Gridstore 能否承受压力?
设计为具备崩溃弹性是一回事,证明它在压力下工作又是另一回事。为了将 Qdrant 的数据完整性推向极限,我们构建了 Crasher,这是一个测试平台,它在 Qdrant 处理繁重的更新工作负载时野蛮地终止并重启 Qdrant。
Crasher 运行一个循环,持续写入数据,然后随机崩溃 Qdrant。每次重启时,Qdrant 都会重放其 预写日志 (WAL),我们会验证数据完整性是否保持。可能出现的异常包括
数据丢失(点、向量或负载数据)
- 负载数据值损坏
- 这种激进但简单的方法在长时间运行时发现了实际问题。虽然我们也使用混沌测试来测试分布式设置,但 Crasher 在本地环境中快速、可重复的故障测试方面表现出色。
测试 Gridstore 性能:基准测试
为了衡量我们新存储引擎的影响,我们使用了 Bustle,一个键值存储基准测试框架,将 Gridstore 与 RocksDB 进行比较。我们测试了三种工作负载
工作负载类型
操作分布 | 读密集型 |
---|---|
95% 读取 | 插入密集型 |
80% 插入 | 更新密集型 |
50% 更新 | 结果一目了然 |
所有类型工作负载的平均延迟全面降低,尤其是在插入操作中。
这表明性能明显提升。正如我们所见,对 Gridstore 的投入正在获得回报。
端到端基准测试
现在,让我们测试其对真实 Qdrant 实例的影响。到目前为止,我们只为负载数据和稀疏向量集成了 Gridstore,但即使是这种部分切换也应该显示出显著的改进。
为了进行基准测试,我们使用了内部开发的 bfb 工具来生成工作负载。我们的配置如下
此基准测试将 100 万个点插入(upsert)两次。每个点都有
bfb -n 2000000 --max-id 1000000 \
--sparse-vectors 0.02 \
--set-payload \
--on-disk-payload \
--dim 1 \
--sparse-dim 5000 \
--bool-payloads \
--keywords 100 \
--float-payloads true \
--int-payloads 100000 \
--text-payloads \
--text-payload-length 512 \
--skip-field-indices \
--jsonl-updates ./rps.jsonl
中到大型负载数据
- 微型密集向量(密集向量使用不同的存储类型)
- 一个稀疏向量
- 额外配置
我们进行的测试通过另一个请求单独更新了负载数据。
没有负载数据索引,这确保了我们测量的是纯粹的摄取速度。
最后,我们收集了请求延迟指标进行分析。
我们使用 Qdrant 1.12.6 运行此测试,在新旧存储后端之间切换。
最终结果
数据摄取速度快了两倍,并且吞吐量更平滑——这是一次巨大的胜利!😍
我们为速度进行了优化,并且取得了成效——但存储大小如何呢?
Gridstore:2333MB
- RocksDB:2319MB
- 严格来说,RocksDB 稍小一些,但与数据摄取速度快 2 倍和更稳定的吞吐量相比,这点差异可以忽略不计。为了获得巨大的性能提升,这是一个微小的权衡!
试用 Gridstore
Gridstore 代表了 Qdrant 如何管理其键值存储需求方面的重大进步。它提供了出色的性能和针对我们的用例量身定制的简化更新。我们成功实现了更快、更可靠的数据摄取,同时保持了数据完整性,即使在繁重的工作负载和意外故障下也是如此。它已被用作磁盘上负载数据和稀疏向量的存储后端。
👉 需要注意的是,Gridstore 与 Qdrant 仍然紧密集成,因此尚未作为独立 crate 发布。
其 API 仍在不断演进,我们专注于在我们的生态系统中对其进行完善,以确保最大的稳定性和性能。尽管如此,我们认识到这项创新可以为更广泛的 Rust 社区带来的价值。未来,一旦 API 稳定并且我们将其与 Qdrant 解耦足够多,我们将考虑将其作为对社区的贡献发布 ❤️。
目前,Gridstore 继续推动 Qdrant 的改进,展示了根据现代需求设计的定制存储引擎的好处。请继续关注进一步的更新和潜在的社区发布,我们将持续突破性能和可靠性的界限。
此页面有用吗?

在本文中,您将了解