我们用io_uring替换了Rust查询引擎中的MMAP,结果变慢了
摘要
Conviva的这篇技术博客文章描述了一个实验:在他们的Rust查询引擎中,用io_uring替换mmap后,在高并发下由于页面缓存抖动和页面错误导致性能变慢。
暂无内容
查看缓存全文
缓存时间: 2026/09/11 08:46
# 我们在Rust查询引擎中用io_uring替换了mmap 结果性能变差了
来源:https://www.conviva.ai/resource/we-replaced-mmap-with-io_uring-in-our-rust-query-engine-it-got-slower/
发布于 2026年9月1日 | 更新于 2026年9月2日
最初,我们使用mmap。它很方便:让我们无需手动管理内存,就能从磁盘懒惰地读取海量Arrow IPC文件。它完美契合我们的文件格式——Arrow IPC的布局设计支持零拷贝随机访问,而mmap恰恰提供了这种能力。
然而,当我们将其部署到生产环境、处理真实并发查询负载时,mmap成了一个棘手的问题。
### 我们的工作负载
在Conviva (https://www.conviva.ai/),我们每天分析数万亿事件以定位和诊断终端用户体验 (https://www.conviva.ai/platform/)。我们架构的核心是基于DataFusion、Arrow、Rust、Rayon和Tokio构建的事件与模式分析引擎。原始事件被转换、编码为一种主要为数字的专有格式,然后存储在云端。我们会将数据复制到本地NVMe磁盘上,并读取大型(约3-5 GB)Arrow IPC文件。我们选择Arrow IPC是为了简单和速度——它的内存和磁盘布局相同,因此解码开销极小,并且mmap天然支持arrow-rust的零拷贝读取。一次典型查询会涉及8个批处理文件中的6列数据(每个文件一个批处理),每个批处理约1.6 GB,每天的数据总量约为13 GB。
[](https://www.conviva.ai/wp-content/uploads/2026/09/Clickstream_Data_Architecture_Flowchart.svg)
### 测试环境
硬件:192核服务器,约750 GB内存。调查期间的两种磁盘配置:2块NVMe LVM条带化(fio测试上限约5.5 GB/s)和32块NVMe RAID-0(fio测试上限约21 GB/s)。调查期间内核版本为5.15,生产环境为6.x。
### 生产环境中的症状
在负载较轻时,mmap表现良好——快速响应,能在几秒内从原始事件中服务查询。问题出现在并发压力更大的时候。负载增加导致一定的延迟上升是预期的——更多查询竞争相同的CPU资源。但我们发现p95和p99延迟飙升的幅度远超线性扩展所能预测的范围,并且即使考虑了并发因素,每个核心扫描的行数也急剧下降:
- 操作系统页面缓存缩小——每个Pod消耗更多内存作为私有分配,作为共享缓存的部分则减少
- 出现大量页面错误
- 在真实并发负载下,p95从约30秒飙升至150秒以上
- 增加Pod数量使情况恶化,而非改善
这表明在内存压力下,mmap出现了页面缓存抖动。
### 受控基准测试:1个Pod vs. 4个Pod
为了隔离影响,我们进行了一项受控测试:在同一主机上,相同并发查询负载下,比较1个Pod与4个Pod。我们原本预期4个Pod会胜出——更多的并行性和更好的隔离性。但我们错了。
对于14天周期的查询——时间足够长以填满页面缓存——1个Pod以明显的优势击败了4个Pod:**最高快41%,p95延迟也高出20%以上。** mmap页面缓存存在于主机级别,并在所有Pod间共享,因此4个Pod并非在争夺CPU——而是在争夺页面缓存。在相同的测试运行中,perf record显示内核层面的锁竞争达到100%。
核心问题在于:mmap的页面缓存是一种*隐式共享状态*。主机上的每个进程共享一个缓存、一套锁层级结构和一种淘汰策略。没有任何单个Pod能控制对读取延迟最关键的资源,而随着并发度上升,每个Pod能分配到的份额就越少。
### 页面错误风暴
我们不想仅凭猜测,因此深入研究了mmap机制和页面错误统计数据。当你对一个文件执行mmap时,应用程序会获得一块由磁盘文件支持的内存区域。访问时发生的过程如下:
1. CPU访问一个没有关联物理页面的虚拟内存地址(VMA),从而触发页面错误。
2. 内核处理异常:查找VMA,检查所有权,并获取锁(因为另一个线程可能正在并发修改或解映射该虚拟空间)。
3. Linux 6.4+版本有快速的每VMA锁;较早的内核则回退到较慢的mmap_lock。
4. 一旦内核确认该VMA是文件支持的,它会触发文件错误——如果字节已经在预读热缓存中,则为*次要错误*;如果必须触发物理I/O,则为*主要错误*。
推荐阅读:mmap_lock的可扩展性 (https://lwn.net/Articles/893906/) (LWN) 和每VMA锁 (https://lwn.net/Articles/906852/) (LWN, Suren Baghdasaryan的设计)。
在页面缓存竞争激烈时,预读空间耗尽,主要错误激增。以下是在压力测试期间对一个进程执行pidstat的示例:
``
23:26:46 RSS = 652 GB (87.88%)
23:27:47 RSS = 734 GB (98.91%) ← 峰值,几乎占满所有内存
23:27:48 RSS开始下降 ← 内核开始逐出页面
23:28:05 出现主要错误:571/s, 1352/s, 975/s
``
RSS增长到内存的98.91% → 内核别无选择,只能逐出仍需要的页面 → 被逐出的页面再次被访问 → 当它们从磁盘重新读回时,形成一场主要错误风暴。早期,预读使错误主要保持为次要且快速的;随着并发查询堆积,预读无法跟上,主要错误激增。
同时,次要错误持续高达每秒数百万次:
``
23:27:09 1,255,709 次次要错误/秒
23:27:41 2,124,327 次次要错误/秒
23:27:46 2,354,383 次次要错误/秒
``
每个次要错误都通过原子操作访问一个缓存行——每秒200万次错误足以完全搅乱L1/L2缓存,这对于依赖大型常驻缓存查找表的应用来说是致命的。错误还可能触发TLB刷新,而CPU仅保存几千个TLB条目。(你无法消除页面错误,但可以更好地管理它们——更多内容见第二部分。)
在无竞争的情况下,一个次要错误大约消耗0.5–1微秒,因此每秒200万次已接近mmap可持续的上限——而在真实竞争下,线程可见的延迟远超此数。
与此同时,虚拟地址空间因mmap了如此多的Arrow文件而增长到约3 TB:
``
起始: 3,125,750,740 KB (~2.9 TB 虚拟)
峰值:3,209,184,828 KB (~2.98 TB 虚拟)
``
现代内核能够处理大型VMA树,但这并非没有代价——每次错误都要进行VMA查找,而每次查找都需要获取mmap锁(快速路径除外)。
上下文切换也反映了同样的情况:
``
cs = 2,106,576/秒
cs = 2,025,726/秒
cs = 1,944,397/秒
cs = 1,524,279/秒
```
超过200万次上下文切换/秒,而缓存预热后的运行仅为1.4万次/秒——高出150倍。每个线程都因页面错误而不断阻塞、被调度出,然后在页面到达后重新调度。
### Perf Top与Off-CPU分析
为了确认页面错误与锁竞争之间的联系,我们比较了同一查询在冷运行和热运行时的perf top结果:
**函数****冷运行****热运行**__filemap_add\_folio (内核)**78.0%**不在前列内核自旋锁0.96%0.76%CPU/数据处理4.96%45.08%`__filemap_add_folio`负责将页面添加到页面缓存。在热运行中它几乎不出现,因为数据已经存在;而在冷运行且内存压力下,它占据主导,因为页面被不断逐出和重新插入。我们实际的查询代码从热运行时的约45% CPU占用下降到冷运行时的约5%——并非因为它做更少的工作,而是因为内核做了更多的工作。
通过bpftrace测量的Off-CPU时间(仅统计有效时间,排除空闲的Rayon线程):
- **Futex:30.9% (1,172秒)**——线程在同步原语上阻塞,排队在另一个线程的页面错误处理程序之后
- **被抢占:29.3% (1,109秒)**——对于在192核服务器上运行的12个线程来说高得惊人;内核的页面错误工作(预读k线程)正在抢占我们的工作线程
- **磁盘I/O:6.9% (262秒)**——相对于上述开销,实际的NVMe延迟很小
- **mmap_sem:0.9% (33.5秒)**——显式的VMA锁;数值较小仅因为它捕获的是等待时间,而非其后排队的线程因futex唤醒产生的级联延迟
画面清晰了:在负载下,页面缓存抖动和内核级锁竞争——而非磁盘I/O——才是瓶颈。这并非mmap独有;任何缓冲I/O路径都可能遇到类似的页面缓存和锁竞争问题。
#### **fio性能上限**
使用io_uring引擎的fio测试——4个进程,队列深度32,4 MiB块大小,O_DIRECT模式:
``
READ: bw=20.2 GiB/s (21.7 GB/s)
所有32块NVMe磁盘利用率约99.75%
md0利用率 = 99.95%
```
而mmap在压力测试期间从vmstat观察到的峰值实际传输率:**3.44 GB/s**——约为硬件能力的16%。这个差距就是我们追求的目标。
### 引入io_uring
io_uring名副其实。除了异步内核I/O,它承诺的一部分是绕过页面缓存(正是上述大多数问题的根源)的直接用户I/O。值得阅读的资料:
1. “io_uring for high performance DBMS” (https://arxiv.org/pdf/2512.04859v1)——一篇很好的优化概述,但侧重于具有4KB页面缓冲区的传统DBMS,因此许多方法不适用。IOPOLL需要特定的块设备访问,容器中通常无法使用;SQPOLL在我们基于Arrow的测试中没有可测量的效果。
2. LanceDB的io_uring文章 (https://lancedb.com/blog/one-million-iops#solution-2-io_uring)——针对小型4KB(向量搜索)读取。关键结论:没有更好的调度和并发,仅靠io_uring本身并无帮助。
计划:使用O_DIRECT绕过页面缓存,通过io_uring提交读取,与Tokio协调,内联解码Arrow。我们使用了compio,一个Rust原生的io_uring封装(围绕io_uring构建的执行器 + futures + 反应器)。第一版依赖compio的异步futures——每个Arrow列读取一个future,所有40列(8个批处理 × 5列)并发提交,等待完成以产出解码后的Arrow缓冲区。
以下是我们预期io_uring如何解决mmap问题的方式:
**特性****mmap****负载下的影响****io_uring的承诺**缓存控制内核页面缓存,主机级,所有Pod共享在负载下抖动;无法控制保留或淘汰哪些内容O_DIRECT绕过页面缓存;构建我们自己的缓存线程锁与竞争内核处理竞争Futex竞争、大量上下文切换、大量错误导致p99飙升构建自己的I/O管道,最小化或引导竞争I/O与CPU分离从内存读取简单;内核按需处理错误随着内核加载页面,CPU密集型工作出现峰值分离I/O与CPU工作;预取和流水线读取,不中断计算线程#### 初期的笔记本电脑测试并不理想
回想起来,也许我们应该在构建任何东西之前写下这篇文章——我们的第一个设计未能实现最后一列的大部分承诺。设计正确的io_uring需要大量工作,而我们希望快速迭代,因此从小处着手。
我们从macOS开始,它没有io_uring——kqueue是完全不同的东西——但它让我们可以验证compio抽象和批处理逻辑:能否编译?我们产生的页面错误是比mmap多还是少?笔记本电脑无法证明正确性,但它能在预约大型Linux服务器时间之前发现明显的回退。
**指标****io_uring分支,冷态****mmap,冷态**总查询时间0.646秒0.323秒主要错误23516,103次要错误161k32k总运行时间更慢,但主要错误骤降了近70倍。这正是绕过页面缓存的预期效果——没有操作系统管理的错误,因为没有东西可出错。我们告诉自己额外的延迟是因为macOS底层缺少真正的io_uring,而Linux将带来吞吐量的提升。
#### Linux上的现实检验
**指标****io_uring,冷态****mmap,冷态**总查询时间**21.8秒****13.6秒**物化时间17.4秒0(mmap在读取时是“免费”的)模式第一遍执行17.5秒10.0秒主要错误3,647128,957次要错误**860万**约100万主要错误从128,957降至3,647——减少了35倍。我们恰恰解决了io_uring应该解决的问题:内核不再因主要页面加载而抖动。
但次要错误增加了8倍,总查询时间从13.6秒变为21.8秒——io_uring比它本应替代的mmap基线慢了约60%。我们用一种错误换取了另一种,结果是亏本交易。网上充斥着宣称io_uring胜过mmap的文章。而我们有一个实际倒退的实现。
在讨论哪里出错之前,有必要回顾一下我们实际构建的内容——只有了解了围绕它的架构,这个错误才有意义。
### 批处理物化层
Arrow IPC将数据组织为**批处理**——连续的行块,每个块有自己的磁盘布局,列存储在各自的字节范围内。在我们的设置中,每个文件包含一个大型批处理,一天的数据大约跨越8个文件。一个查询一天数据涉及所有8个文件,从每个文件中提取约5-6列,最终发出大约40次单独的列读取。
对于mmap,读取次数几乎无关紧要——将查询引擎指向文件,它就像内存一样,内核会加载查询所访问的任何字节。对于io_uring,每次读取都必须显式提交,这意味着更多代码和更多容易出错的时序问题。因此,我们在io_uring管道和查询引擎之间构建了一层——**批处理物化层**(我们日志中称为BMT)——其API很简单:
- prefetch(batch, columns) —— 触发一组列的io_uring读取,用OnceCell式的槽位填充每列缓存
- materialize(batch, column) —— 返回缓存的字节,或等待进行中的读取
[](https://www.conviva.ai/wp-content/uploads/2026/09/BMT_Architecture_Diagram.svg)
这个设计在白板上感觉很好。预取直接映射到查询引擎希望将I/O延迟隐藏在CPU工作背后的方式:提前开始读取,在读取过程中做其他事情,需要时回来取字节。缓存将查询引擎与io_uring语义完全解耦——查询只需调用materialize,要么立即获得字节,要么等待。
我们当时没有看到的是,这单一层次承担了多少工作。所有操作都在单个BMT线程上运行:接受prefetch/materialize调用,为每个请求的列提交compio futures,等待完成,将字节解码为Arrow缓冲区,填充缓存,并将物化的列交还给查询引擎。I/O协调、Arrow解码、缓存管理和面向查询的API,全在一个调用栈和一个异步运行时中。当时感觉像是清晰的职责分离。实际上它是一个做五项任务的层次,对一个地方的修复会波及到其他地方。更多内容见第二部分。
第一版一次性启动了所有约40列读取——当查询需要一个文件时,立即产生约40个并发的compio futures。这就是我们当时理解的“预取”:全部启动,让异步来协调,然后全部等待。
### 迂回尝试
io_uring的论文都强调O_DIRECT,而我们并未使用——我们的读取仍然经过页面缓存。所以我们首先尝试开启O_DIRECT:直接DMA到我们的缓冲区,无内核缓存,没有mmap下成为主要问题的页面缓存机制。运行时间从21.8秒降至约19秒——有所改善,但幅度不大,也未达到博客文章引导我们期望的飞跃。
其次是Arrow,它在perf中占据显著位置。采样集中在`Buffer::from_slice_ref`上,这是arrow-rs从字节切片构建Buffer的默认方式——它分配新内存并将数据memcpy进去。memcpy触及的每个4 KiB目标页面都需要内核清零和映射——每个页面一次次要错误——在约13 GB读取上产生800万次要错误,这与计算几乎完全吻合。我们的Arrow层实际上迫使内核重新做了我们以为通过转向io_uring本可绕过的内存管理工作。
我们通过直接从io_uring拥有的字节构建Buffer来解决这个问题,跳过了拷贝。运行时间从19秒降至约16秒,但代码丑陋到任何审查者都会指出——手动构建Buffer...
相似文章
魔法缓冲区与io_uring注册缓冲区
本文探讨了如何使用mmap创建魔法环形缓冲区,其中两个连续的虚拟内存区域映射同一物理内存,并测试其与io_uring注册缓冲区的兼容性,发现其按预期工作。
io_uring 不使用预读
本文研究了在 Turso 数据库中 io_uring 实现预读的性能影响,表明批处理 I/O 请求可以通过合并减少设备请求来提高效率。
探索使用 io_uring 的自动缓冲区管理
本文详细介绍了在 UringMachine(一个用于异步 I/O 的 Ruby gem)中使用 io_uring 的缓冲区环实现自动缓冲区管理。它解释了缓冲区环如何通过允许内核使用应用程序提供的缓冲区来实现高效的多重读取/接收操作。
PostgreSQL 19 中的内核异步读取(io_uring)
PostgreSQL 19 通过 io_uring 引入了内核异步读取功能,实现了直接的异步缓冲 I/O,从而在不使用专用工作进程的情况下提高性能。
只改一个函数,性能提升10倍?解读一个 Rust 性能 PR
一篇深度博文,通过基准测试复现和代码分析,解读 GreptimeDB 中让 Prometheus 读取转换速度提升 10 倍的 Rust 性能 PR。