Qdrant

Universal I/O in Qdrant

8.5内容质量

TL;DR · AI 摘要

Qdrant通过Universal I/O接口实现本地和对象存储的统一读取,降低无服务器模式下的性能差距。

核心要点

  • 网络I/O读取延迟达10-100ms,是SSD的100-1000倍
  • Universal I/O通过抽象接口实现三种存储后端的统一访问
  • mmap在冷数据读取时会产生约100µs的页面故障延迟

结构提纲

按章节快速跳转。

  1. 揭示传统存储假设在无服务器模式下的失效问题

  2. 介绍Qdrant原有基于mmap的存储实现及其局限性

  3. 对比mmap、io_uring和网络I/O的性能特征

  4. 量化分析不同存储后端的读取延迟差异

  5. 阐述统一接口的抽象设计及其实现价值

  6. 说明Rust trait如何标准化不同后端的交互

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Universal I/O设计
    • 存储后端
      • mmap
      • io_uring
      • 网络I/O
    • 接口设计
      • Rust trait抽象
      • 统一合同
    • 性能优化
      • 冷热数据处理
      • 跨平台兼容

金句 / Highlights

值得收藏与分享的关键句。

#Qdrant#存储系统#Rust#I/O优化
打开原文

Qdrant 中的通用 I/O

回到 Qdrant 内部机制

Luis Cossío, Clelia Bertelli

·

2026 年 10 月 12 日

复制为 Markdown

大多数软件都基于一个未明说的假设:数据存储在磁盘上,并从磁盘中读取和写入。这个假设通常成立,直到出现新的数据存储方式(或需求)。

对于 Qdrant 来说,这种情况发生在无服务器模式中,数据存储在对象存储中,而不是进程旁边的磁盘上。这篇博客讲述了通用 I/O 的故事,这是我们构建的接口,使相同代码能够在两种环境中运行,以及保持本地路径成本低廉的设计选择。如果你是系统程序员,这篇阅读尤其相关,一些 Rust 的知识将帮助你理解代码,但这些经验教训应适用于许多其他类型的软件。

要了解这个假设在 Qdrant 中有多根深蒂固,查看无服务器模式之前我们如何读取数据会有所帮助:我们所有的磁盘数据结构(向量、HNSW 图和负载索引)都通过 mmap,这假设了实际磁盘的存在。然而,在无服务器模式下,数据存储在对象存储中,成本差异巨大:SSD 上的冷 mmap 读取大约需要 100 微秒,而通过网络的相同读取需要 10 到 100 毫秒,这比磁盘读取慢两个或三个数量级,并且暴露在磁盘读取不会遇到的故障模式中。

一种选择是重新实现所有与对象存储的交互,但这意味着维护相同逻辑的两个版本,随着时间推移这两个版本容易产生分歧,每个数据结构都需要在两个版本之间切换,这可能改变已经运行良好的本地路径的行为。

因此,我们决定构建一个单一的接口,使我们能够在相同代码下热切换存储后端,这项工作成为通用 I/O。在本博客中,我们专注于读取路径,因为这是我们优化的主要部分。

不同的后端,截然不同的成本

mmap 是本地文件的坚实选择,因为你可以将文件作为借用的字节切片(&[u8])读取,操作系统会将热页缓存在内存中,并且只加载你访问的页面。问题从来不是性能,而是实现与具体类型绑定,因此我们无法将相同组件与其他后端复用。

我们真正需要的是一个单一的抽象接口(在 Rust 中称为 trait,类似于 Go 的接口和 Python 的协议),该接口为每个后端标准化合同,而不是为每个组件提供单独的具体实现。该接口需要覆盖至少三个后端,它们的行为差异很大:

  • mmap:同步。当数据已经在 RAM 中时非常便宜,但冷数据读取会因页面错误而阻塞(在 SSD 上大约 100 微秒)。
  • io_uring:队列。可以并行运行许多读取,但仅适用于 Linux 内核 v5.1 或更高版本(2019 年引入)。
  • 网络 I/O:异步。当数据未在本地缓存时非常昂贵(每次往返需要 10 到 100 毫秒),并且暴露在磁盘读取不会遇到的故障模式中。

既非异步也非阻塞的适配

由于这种成本差距,接口的设计似乎仅限于两种方法,每种方法都有明显的缺点:

  • 将所有操作异步化。这在网络存储中表现良好,但对mmap和已缓存数据的处理效果较差,因为Rust中的异步并非免费:需要运行时支持,而编译器对异步代码的优化效果不佳,这会导致每次读取缓存数据时都可能产生性能损耗,进而影响现有部署。
  • 将所有操作同步化。这与现有的mmap代码兼容,但对网络请求非常不友好,因为网络请求的速度比磁盘读取慢100到1000倍:当每个请求按顺序执行并阻塞直到完成时,延迟会迅速累积。

经过多次迭代后,我们最终选择了折中方案,采用基于io_uring的ReadPipeline抽象:你可以像向提交队列推送任务一样安排读取操作,然后像从完成队列读取一样等待结果,同时每个后端可以按照最适合自己的方式实现管道逻辑。

一个ReadPipeline能够在保持消费者代码不变的情况下,让每个后端自主选择最适合自己的执行模型和并发策略。

文件系统、文件与管道

通用I/O构建在一组相互依赖的Rust特性(trait)之上,其中三个特性对本文后续内容至关重要:

  • UniversalReadFs 是文件系统句柄,用于检查文件是否存在、列出文件以及打开文件。
  • UniversalRead 是文件句柄,用于执行实际读取操作。调用者需要指定元素类型T(如f32或u32),并提供访问模式(顺序或随机)的提示,当后端返回自行分配的缓冲区时,会将缓冲区对齐到T的内存边界,从而无需复制即可将字节转换为T的切片。
  • ReadPipeline 将前两者连接起来:你可以安排读取操作,等待任意一个读取完成,然后重复该过程。

剥离掉本文未涉及的部分,这三个特性看起来如下:

code
trait
UniversalReadFs
{
fn
open
(
&
self
,
path
,
options
)
->
File
;
}
trait
UniversalRead
{
fn
read
<
T
>
(
&
self
,
range
,
access_pattern
)
->
Cow
<
[
T
]
>
;
}
trait
ReadPipeline
<
UserData
>
{
fn
can_schedule
(
&
mut
self
)
->
bool
;
fn
schedule
(
&
mut
self
,
user_data
,
file
,
range
);
fn
wait
(
&
mut
self
)
->
Option
<
(
UserData
,
Cow
<
[
u8
]
>
)
>
;
}

读取操作返回一个Cow(写时复制),这是标准库中的一个类型,可以持有借用数据或拥有数据,这样mmap可以通过从映射文件中借用切片实现零拷贝,而io_uring和网络后端则返回拥有缓冲区,消费者可以以相同方式处理这两种情况。

每个后端还可以自主选择管道深度,即一次可以同时进行多少个读取操作:

  • mmap使用1,因为它在读取操作被安排后立即执行,因此表现得像直接mmap访问一样同步
  • io_uring使用16
  • 网络后端允许最多256个并发读取

一个完整的管道在wait()返回结果前不会接受新的读取请求。

由于当深度大于1时读取操作可能以任意顺序完成,每个请求还携带一个标记——用户数据,它会随结果一起返回,告诉调用者这个结果对应的是哪个请求。其工作方式类似于io_uring中的user_data,但可以是任意类型,实践中通常是一个批次中向量的位置或点ID。

默认read_batch方法的核心循环展示了这些组件如何协同工作,因为它会尽可能保持管道满载,取最先完成的读取操作,并将其与用户数据一起传递给回调函数:

code
loop
{
// 尽可能多地填充流水线
while
pipeline
.
can_schedule
()
{
let
Some
((
user_data
,
range
))
=
ranges
.
next
()
else
{
break
};
pipeline
.
schedule
(
user_data
,
file
,
range
);
}
// 取出最先完成的读取操作并交给调用者
let
Some
((
user_data
,
data
))
=
pipeline
.
wait
()
else
{
break
};
callback
(
user_data
,
data
);
}

在 mmap 的实现中,这个循环会调度一次读取、等待其完成,然后重复该过程;而在网络后端中,相同的循环可以同时保持最多 256 个请求在进行中,且两种情况下的消费者代码完全一致。

两个本地后端都实现了相同的流水线机制:mmap 后端不会排队,因为调度读取操作会切片已映射的文件,而 wait() 会将切片返回,其 MmapFile 类型也用于磁盘缓存作为远程文件的本地镜像。

io_uring 后端的队列深度为 16,它会将每个请求的用户数据存储在 slab(一个可重用插槽的向量)中,该 slab 的键会随着请求一起传递,作为 io_uring 自己的 user_data,这样完成时可以指向调用者的标签。

当页缓存已预热时,读取操作几乎立即完成,因此流水线会保留新调度的读取操作,并在结果耗尽时通过一次系统调用批量提交,而不是每个读取都触发一次系统调用。文件也可以通过 O_DIRECT 打开以完全绕过页缓存,但代价是每个读取必须对齐 4 KiB。

连接异步世界的桥梁

到目前为止描述的代码均未使用异步 Rust,但网络读取需要它,因为单次往返需要数十毫秒,按顺序执行请求会太慢。它们必须在不产生过多线程开销的情况下并发执行,这引发了一个问题:如何在请求异步完成的同时保持流水线同步?我们通过一个桥梁来解决这个问题,该桥梁使流水线像以前一样调度和等待,而异步工作则在一个专用的 Tokio 运行时中执行。

以下是读取操作通过桥梁的简化流程:

  • schedule 将调用者的用户数据存储在 slab 插槽中(与 io_uring 后端使用相同技巧),并将其提交到 Tokio 工作线程,该线程会获得回复通道发送端的克隆副本以返回结果:

fn schedule(&mut self, user_data, file, range) { if self.in_flight == 256 { return Err(QueueIsFull) } let slot = self.slots.insert(user_data); // 记录调用者的标签 let reply_tx = self.tx.clone(); spawn_on_tokio(async move { // 捕获 panic 并转换为错误 let result = catch_panic(read_from_object_store(file, range)).await; reply_tx.send(Response { slot, result }).await; }); } 由于读取操作在另一个线程上运行,schedule 会立即返回,任务中的 panic 会转换为错误回复,因此 wait 永远不会阻塞。

  • wait 在流水线接收器上调用 blocking_recv,允许同步代码从异步工作线程接收响应,由于每个响应都携带其插槽,wait 可以按请求完成的顺序将结果与调用者的用户数据重新关联:

fn wait(&mut self) { if self.slots.is_empty() { return None } // 阻塞直到工作线程响应 let response = self.rx.blocking_recv(); // 将结果与标签重新关联 let user_data = self.slots.remove(response.slot); Some((user_data, response.result)) }

下图展示了整个流程:

同步流水线调度 A、B 和 C;Tokio 完成 B、C 后执行 A,每个结果都会与对应的请求匹配。

将远程对象存储镜像到本地磁盘

即使使用异步桥接,网络读取仍然远慢于内存或本地磁盘读取,但借助已有的通用 I/O 接口,我们可以在其基础上构建分层解决方案:一个通过将远程文件镜像到本地 mmap 文件实现数据缓存的后端,我们称之为 DiskCache。

当 DiskCache 打开远程文件时,会创建一个相同大小的空本地文件,逻辑上划分为 16 KiB 块,并维护一个 RoaringBitmap(一种压缩位图)来记录哪些块已本地化。

该设计依赖一个前提:在无服务器模式下,Qdrant 存储在对象存储中的文件是不可变或仅追加的,因此远程文件可以增长但已有字节永不改变。结果是,获取的块永远不会过时,这意味着我们无需跟踪脏页或进行任何失效处理,且当两个读取同时获取相同块时,它们会向镜像写入相同的字节,因此最后写入的顺序无关紧要。

通过 DiskCache 的读取流程如下:

  • 检查位图。如果读取所需的所有块均为本地化,则为缓存命中,返回本地 mmap 的切片;如果至少有一个块缺失,则从远程获取整个覆盖块范围。
  • 缓存未命中时尝试搭便车或直接获取。为每个未命中发送请求会浪费往返通信,因此首先检查同一批次中是否有其他读取正在获取覆盖当前块的块。例如,如果需要块 7-10,而另一个读取正在获取块 6-12,则等待该请求而非发送新请求,仅当没有覆盖整个范围的进行中请求时才会发起新请求。
  • 写入镜像后从镜像读取。当远程读取返回时,将字节写入本地 mmap,标记位图中的块,并返回从 MmapFile 借用的切片而非远程缓冲区,使命中和未命中返回相同类型的结果。

每个 DiskCache 路径都返回本地 mmap 切片;未命中时先填充镜像,覆盖读取会复用正在进行的获取。

整体流程如下所示,实际流水线将调度和等待拆分为独立调用但遵循相同逻辑:

code
fn
read
(
range
)
->
Slice
{
let
blocks
=
blocks_covering
(
range
);
// 1. 命中:所有块均已本地化(标志位在全部命中时跳过位图检查)
if
self
.
fully_populated
||
self
.
fetched
.
contains_all
(
blocks
)
{
return
self
.
mirror
.
slice
(
range
);
}
// 2. 未命中:复用覆盖块的进行中获取,或启动新获取
let
fetch
=
match
self
.
in_flight
.
find
(
|
f
|
f
.
blocks
.
contains
(
blocks
))
{
Some
(
fetch
)
=>
fetch
,
None
=>
self
.
in_flight
.
start
(
remote
.
read
(
blocks
)),
};
// 3. 将获取的字节写入镜像,标记后从镜像读取
let
bytes
=
fetch
.
wait
();
self
.
mirror
.
write
(
blocks
,
bytes
);
self
.
fetched
.
insert
(
blocks
);
self
.
mirror
.
slice
(
range
)
}

当所有块均本地化后,fully_populated 标志可让读取完全跳过位图及其锁,此时读取仅是本地 MmapFile 的切片,与 mmap 后端执行的相同类型操作。

仅追加文件也会增长,重新打开文件意味着需要再次获取所有数据,因此 DiskCache 通过两个阶段来扩展镜像:首先通过学习新的远程文件大小并预取新文件尾部来预演变更,此过程对读者没有可见影响;随后再通过扩展和重新映射本地文件来正式应用变更。这使得 Qdrant 可以同时为多个文件预演重新加载过程,只需为最慢的那个文件支付代价。

两全其美的方案(几乎)

Universal I/O 的起点是一个具体问题:如何从非本地磁盘的存储介质中读取我们已有的数据结构。最终的答案并非聚焦于某个特定后端,而是找到了不同后端之间合适的接口。

有了这个接口,存储后端真的可以实现热插拔:相同的搜索代码既可以从笔记本电脑上的本地 mmap 文件读取向量,也可以在无服务器模式下从 S3 读取数据,本地路径仍然继续使用 mmap。我们为此付出的代价是必须创建一套复杂的相互依赖的特性来泛化 I/O 接口,但我们认为这是值得的,因为现在每个新的存储后端都可以无需修改使用它的数据结构即可接入 Qdrant。

如果你想了解它是如何整合在一起的,可以从 Qdrant 代码库中的 universal_io 模块开始探索。如果你对此有任何疑问,欢迎加入我们的 Discord 社区,我们会尽力在那里解答!