news 2026/9/19 19:01:34

RocksDB 异步 IO(async_io)深度解析:Iterator 扫描与 MultiGet 的延迟优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RocksDB 异步 IO(async_io)深度解析:Iterator 扫描与 MultiGet 的延迟优化

RocksDB 异步 IO(async_io)深度解析:Iterator 扫描与 MultiGet 的延迟优化

【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb

导读

本文基于 RocksDB 官方技术博客 Asynchronous IO in RocksDB 展开,系统讲解 RocksDB 如何在迭代器(Iterator)扫描与 MultiGet 批量查询两条路径上引入异步 IO,用"并发读取 + 后台预取"的方式隐藏慢速存储(如远端闪存)带来的访问延迟。读完本文,你将掌握ReadOptions::async_ioReadOptions::optimize_multiget_for_io两个核心开关的作用与适用场景、Seek/Next 两阶段异步化的内部原理、基于 C++ 协程的 MultiGet 实现,以及异步 IO 的已知限制与适用前提,并了解与之对应的核心源码实现。

背景:存储延迟为何成为读路径的瓶颈

RocksDB 提供了多套 KV 读取 API:GetMultiGet用于点查,Iterator用于顺序扫描。这些 API 在读取时会从 SST 文件解析所需的 block(数据块、索引块、布隆过滤器块等)。实际从存储介质读取哪些 block、读取频率如何,完全取决于工作负载特征:

  • 工作集较小的负载:大部分数据都能命中 block cache,几乎不触碰磁盘;
  • 工作集较大的负载:频繁从磁盘读 block,延迟显著升高、吞吐显著下降;
  • 存储介质迁移困难:从本地闪存迁移到分离式(disaggregated)闪存等新介质时,读路径延迟特性完全不同,应用很难无痛迁移。

缓解存储延迟影响的通用思路是:尽可能多地异步、并行地发起读取,把 IO 延迟隐藏起来。RocksDB 在 Iterator 与 MultiGet 两条路径上落实了这一思路:

  • Iterator:对每个被迭代的文件在后台异步预取数据,取代以往阻塞迭代线程的同步预取;
  • MultiGet:确定一批 key 与哪些 SST 文件重叠,然后借助异步文件系统 API 并行读取这些文件中的数据块。

需要强调的是,这些优化全部位于 Iterator 与 MultiGet 的内部实现,用户 API 依旧是同步语义,现有代码无需任何改动即可受益。文档原文也预告了未来可能考虑异步用户 API。

设计总览:从 ReadOptions 到 FileSystem 层

两个关键开关:async_io 与 optimize_multiget_for_io

ReadOptions中新增的async_io标志控制异步 IO 的启用,其声明位于 include/rocksdb/options.h:

// If async_io is enabled, RocksDB will prefetch some of data asynchronously. // RocksDB apply it if reads are sequential and its internal automatic // prefetching. bool async_io = false;

要点:

  • async_io开启后,同时作用于 Iterator 与 MultiGet两条路径;
  • 源码注释明确了适用前提:只有当读取是顺序的,且走的是 RocksDB内部自动预取逻辑时才生效;
  • 默认值为false

针对 MultiGet,还有一个附加开关optimize_multiget_for_io(默认true,标记为 Experimental),声明在 include/rocksdb/options.h:

// Experimental // // If async_io is set, then this flag controls whether we read SST files // in multiple levels asynchronously. Enabling this flag can help reduce // MultiGet latency by maximizing the number of SST files read in // parallel if the keys in the MultiGet batch are in different levels. It // comes at the expense of slightly higher CPU overhead. bool optimize_multiget_for_io = true;

该开关控制异步 IO 的激进程度:

设置行为代价
optimize_multiget_for_io = false同一 LSM level 内的多个文件并行读取,但不同 level 之间串行(single-level 并行)CPU 开销较低
optimize_multiget_for_io = true(默认)解除 level 限制,尽可能多的文件(跨 level)并行读取(multi-level 并行)CPU 开销依工作负载可能更高

两个开关在 C API 中也暴露了 setter/getter,见 include/rocksdb/c.h,例如rocksdb_readoptions_set_async_iorocksdb_readoptions_set_optimize_multiget_for_io

文件系统层的异步原语:ReadAsync

在 FileSystem 抽象层,异步读取通过FSRandomAccessFile::ReadAsync发起:调用方提供完成回调(completion callback),读操作在后台执行,完成后回调被触发。接口声明位于 include/rocksdb/file_system.h。同时,FSRandomAccessFile::Poll接口用于轮询异步 IO 的完成状态,SupportedOps中默认包含 async_io 与 prefetch 操作(include/rocksdb/file_system.h)。

从源码看,ReadAsync的实际落地与io_uring强相关:在 env/io_posix.cc 中,PosixRandomAccessFile::ReadAsync尝试向 io_uring 提交队列获取 SQE(submission queue entry),失败时返回IOStatus::NotSupported("ReadAsync: failed to init io_uring"),并在编译期依赖宏ROCKSDB_IOURING_PRESENT(见 env/io_posix.cc)。这正是文档"已知限制"一节所述"目前仅 PosixFileSystem 支持"的底层原因。

扫描路径(Scan)的异步化

一次扫描涉及哪些 IO

一次典型的 RocksDB 扫描由三步组成:分配新迭代器 → 用目标 key 执行Seek定位 → 连续执行多次Next顺序遍历。SeekNext都存在异步读取的机会。

扫描会依次遍历多个"实体"中的 key:

  1. 活跃 memtable;
  2. 已密封但未刷新的 memtable;
  3. 每个 L0 文件;
  4. 每个非空且非零的 level。

前两类完全在内存中,不受 IO 延迟影响;后两类需要读取 SST 文件。这意味着 IO 延迟的上升存在放大效应:多个 L0 文件、多个 level 都必须被迭代。block cache、前缀布隆过滤器等机制虽然能减少需要迭代的文件数与文件读取次数,但只要仍有少量磁盘读,就可能主导整体延迟。RocksDB 在SeekNext两个操作上都使用异步 IO 来缓解延迟。

Seek 的两阶段异步化

迭代器内部维护了一组子迭代器(child iterator):每个 L0 文件一个、每个非空非零 level 一个。Seek时每个子迭代器都要定位到目标 key——默认实现是串行的,所需数据块不在缓存时就同步读 SST。

开启async_io后,Seek被拆成两个阶段:

  1. 阶段一:在每个文件/level 中定位Seek所需的数据块,并发出异步读——多个文件/level 的读在阶段一并行发起;
  2. 阶段二:用相同 key 重新 Seek(reseeek),此时每个 level 等待各自的异步读完成,并定位 table iterator。

阶段一并行读取多个 block,显著降低了整体 Seek 延迟。这一两阶段逻辑对应BlockBasedTableIterator::AsyncInitDataBlock(table/block_based/block_based_table_iterator.cc),它通过is_first_pass参数区分两阶段调用,并用async_read_in_progress_成员记录第一阶段的异步读是否仍在进行(见 table/block_based/block_based_table_iterator.cc)。

Next 的双缓冲异步预取

对于Next操作,RocksDB 通过预取降低 IO 延迟:当Next需要的数据块不在缓存中时,触发文件读取 + 预取。读取与预取由FilePrefetchBuffer管理——这是一个每个表迭代器(BlockBasedTableIterator)一个的对象。

默认(同步)预取策略为:

  • 从文件的第三次读取开始预取;
  • 初始预取大小为8KB
  • 之后每次读取翻倍,上限256KB

预取量还会随用户在ReadOptionsBlockBasedTableOptions中给出的选项变化。

同步预取虽好,但仍阻塞迭代线程。开启async_io后,FilePrefetchBuffer改为维护两个预取缓冲区,把计算出的预取大小拆分到两个缓冲区上:

  1. 迭代进行中消费第一个缓冲区;
  2. 第一个缓冲区数据用完后被清空,并调度一个异步读去预取更多数据;
  3. 异步读在后台进行,迭代器继续处理第二个缓冲区中的数据;
  4. 此时两个缓冲区的角色互换。

这套机制并不能 100% 隐藏 IO 延迟——内存数据耗尽后,迭代器仍需等待一次异步读完成;但它通过重叠 CPU 与 IO隐藏了部分延迟,且多个 level 上的异步预取可以并行进行,进一步降低延迟。

从源码看,双缓冲正是FilePrefetchBuffernum_buffers_ > 1模式。构造时num_buffers_(readahead_params.num_buffers)(file/file_prefetch_buffer.h),free_bufs_保存空闲缓冲区、bufs_保存含预取数据的缓冲区(file/file_prefetch_buffer.h);PrefetchAsync负责异步预取(file/file_prefetch_buffer.h),每个缓冲区通过async_read_in_progress_标记异步读状态。num_buffers_ == 1时走同步顺序读流程,num_buffers_ > 1时走异步后台预取(file/file_prefetch_buffer.h)。

下图展示了双缓冲区异步预取的完整流程(图中深蓝/浅蓝分别代表两个预取缓冲区,箭头标注各数据块位置的异步预取路径):

MultiGet 的协程化异步并行

从 MultiRead 到文件级并行

MultiGet接受一批 key,相比循环调用Get更高效。其基础效率来自:对落在同一个 SST 文件的多个 key,批量读取多个数据块,这通过 FileSystem 层的MultiReadAPI 实现。

但即便有MultiRead散落在不同文件的 key 子集仍然串行读取。为了把并行度再推进一步——多个文件同时读——MultiGet 实现做了三处根本性改造:

  1. 协程(Coroutines)MultiGet 的完整流程是:确定批内与某个 SST 文件重叠的 key 集合 → 调用TableReader::MultiGet完成实际查找。TableReader内部依次:探测布隆过滤器 → 遍历索引块 → 查询 block cache → 从 SST 读缺失的数据块 → 在数据块中搜索 key。每个阶段都会累积大量上下文,若让多个TableReader交错进行数据块读取,实现会非常复杂。为此 RocksDB 采用C++ 协程 + 异步 IOTableReader::MultiGet实现为协程,在发出缺失数据块的异步读后被挂起(suspend);顶层 MultiGet 借此先遍历完所有 key 对应的TableReader,再等待读取完成、恢复(resume)协程。

  2. 过滤(Filtering)协程的 CPU 开销不容忽视,应尽量少用。一个可以完全避免调用TableReader::MultiGet协程的场景是:预先知道与文件重叠的 key 在文件中并不存在。这通过探测布隆过滤器即可确定。旧实现把布隆过滤嵌入TableReader::MultiGet内部;改造后,过滤被提前为独立步骤,在调用TableReader::MultiGet之前执行。

  3. 拆分批次(Splitting batches)MultiGet 的默认策略是查完一个 level(或 L0 文件)再进入下一个,这限制了 IO 并行度——例如批内 key 可能分散在多个文件中,即使 key 空间上聚集,也可能不在同一 level。优化思路是:先确定"很可能位于某个 level"的 key 子集,把 MultiGet 批次拆成两份——该 level 的子集 + 剩余部分,后者可并行处理;"很可能位于某 level"的判断正来自第 2 步的过滤结果。

这三项改造共同实现了两种异步延迟优化:

  • Single-level:并行读取同一 LSM level 内多个文件的数据块;
  • Multi-level:并行读取多个 level 中多个文件的数据块。

从源码看,TableReader::MultiGet确实存在协程版本:在 table/block_based/block_based_table_reader.cc 中可以看到通过模板同时生成普通方法与协程方法(folly::coro::Task<Status>folly::coro::Task<T*>),并在 table/block_based/block_based_table_reader_sync_and_async.h 通过co_await batch->context()->reader().MultiReadAsync(...)挂起等待批量异步读——这印证了"协程挂起在发出异步读之后"的设计描述。底层MultiReadAsync同样基于ReadAsync体系,见 include/rocksdb/file_system.h 的转发实现。

下图展示了 MultiGet 异步 IO 的全貌:批内 5 个 key(K1~K5)跨 L0~L3 多个 level,映射到多个 SST 文件并行的异步查询路径:

实测结果:延迟随并行度提升逐步下降

文档给出了在远端闪存(env_uri=ws://ws.flash.ftw3preprod1)上的基准测试结果。

测试数据库的生成

使用rocks_db_benchfillseqdeterministic生成:

buck-out/opt/gen/rocks/tools/rocks_db_bench —db=/rocks_db_team/prefix_scan \ —env_uri=ws://ws.flash.ftw3preprod1 -logtostderr=false \ -benchmarks="fillseqdeterministic" -key_size=32 -value_size=512 -num=5000000 \ -num_levels=4 -multiread_batched=true -use_direct_reads=false \ -adaptive_readahead=true -threads=1 -cache_size=10485760000 -async_io=false \ -multiread_stride=40000 -disable_auto_compactions=true -compaction_style=1 \ -bloom_bits=10

生成的数据库结构(共 4 个 level):

Level[0]: /000233.sst(size: 24828520 bytes) Level[0]: /000232.sst(size: 49874113 bytes) Level[0]: /000231.sst(size: 100243447 bytes) Level[0]: /000230.sst(size: 201507232 bytes) Level[1]: /000224.sst - /000229.sst(total size: 405046844 bytes) Level[2]: /000211.sst - /000223.sst(total size: 814190051 bytes) Level[3]: /000188.sst - /000210.sst(total size: 1515327216 bytes)

MultiGet 基准

MultiGet 测试命令(multireadrandom,batch_size=8,4 线程,async_io=true):

buck-out/opt/gen/rocks/tools/rocks_db_bench -use_existing_db=true \ —db=/rocks_db_team/prefix_scan -benchmarks="multireadrandom" -key_size=32 \ -value_size=512 -num=5000000 -batch_size=8 -multiread_batched=true \ -use_direct_reads=false -duration=60 -ops_between_duration_checks=1 \ -readonly=true -threads=4 -cache_size=300000000 -async_io=true \ -multiread_stride=40000 -statistics —env_uri=ws://ws.flash.ftw3preprod1 \ -logtostderr=false -adaptive_readahead=true -bloom_bits=10
场景配置平均延迟(micros/op)吞吐(ops/sec)
Single-file(基线)默认实现,一次读一个文件1291.9923095
Single-levelasync_io=trueoptimize_multiget_for_io=false774.5875163
Multi-level所有优化全部开启507.5337881

对应rocksdb.db.multiget.micros百分位(P50/P95/P99)数据:

  • Single-file:P50 9664.42 / P95 20757.10 / P99 29329.44 / P100 46162.00
  • Single-level:P50 6029.60 / P95 10727.47 / P99 13986.68 / P100 47466.00
  • Multi-level:P50 3923.82 / P95 7356.18 / P99 10880.73 / P100 28511.00

可见:从 Single-file 到 Single-level,延迟下降约 40%;再到 Multi-level,延迟再降约 34%,相对基线整体下降约 60%,吞吐提升约 2.5 倍。

Scan 基准

Scan 测试使用seekrandom并设置-seek_nexts=65536(每次 Seek 后连续 Next 65536 次,模拟长扫描):

buck-out/opt/gen/rocks/tools/rocks_db_bench -use_existing_db=true \ —db=/rocks_db_team/prefix_scan -benchmarks="seekrandom" -key_size=32 \ -value_size=512 -num=5000000 -batch_size=8 -multiread_batched=true \ -use_direct_reads=false -duration=60 -ops_between_duration_checks=1 \ -readonly=true -threads=4 -cache_size=300000000 -async_io=true \ -multiread_stride=40000 -statistics —env_uri=ws://ws.flash.ftw3preprod1 \ -logtostderr=false -adaptive_readahead=true -bloom_bits=10 -seek_nexts=65536
场景平均延迟(micros/op)吞吐(ops/sec)带宽
开启异步扫描(async scan)414442.3039326.2 MB/s
关闭异步扫描848858.6694158.1 MB/s

开启异步扫描后,seekrandom 延迟下降约 51%,带宽翻倍。

说明:以上所有数字均来自原文档在特定测试环境(远端闪存ws://ws.flash.ftw3preprod1、4 线程、batch_size=8)下的实测输出,性能增益高度依赖存储介质与工作负载特征,实际部署请以自身环境的基准测试为准。

已知限制与适用前提

文档明确列出以下限制,使用前务必对照:

  1. 仅适用于 block-based table SST:Plain Table、Cuckoo Table 等其它表格式不受益。
  2. 文件系统必须支持ReadAsyncPoll接口:目前仅PosixFileSystem提供支持(底层依赖 io_uring,见 env/io_posix.cc),其他文件系统将回退到同步路径。
  3. MultiGet 异步 IO 的额外限制
    • 依赖 folly:引入额外的构建步骤;
    • 更高的 CPU 开销:由于协程,MultiGet 的 CPU 开销可能上升6%~15%。最坏情况是单线程、批量内每个文件仅 1 个 key 重叠且 100% 缓存命中;更现实的多线程场景(每文件约 4 个 key 重叠)CPU 利用率约上升 6%;
    • 元数据读取不并行:元数据读仍会阻塞线程;
    • 部分场景仍为串行:例如 merge 操作数的额外 block 读取。
  4. async_io的适用语义:源码注释明确其为"顺序读取 + 内部自动预取"路径上的优化(include/rocksdb/options.h),随机点查为主的负载收益有限。

如何在实际项目中启用

结合ReadOptions声明(include/rocksdb/options.h),启用方式如下(C++ 示例):

rocksdb::ReadOptions read_options; // 开启异步 IO(作用于 Iterator 扫描与 MultiGet) read_options.async_io = true; // MultiGet 跨 level 并行(默认 true;如 CPU 敏感可关闭) read_options.optimize_multiget_for_io = true; // 对迭代器生效 auto* it = db->NewIterator(read_options); for (it->Seek(start_key); it->Valid(); it->Next()) { // 扫描逻辑 } // 对 MultiGet 生效 std::vector<rocksdb::Slice> keys = {...}; std::vector<std::string> values; std::vector<rocksdb::Status> statuses = db->MultiGet(read_options, keys, &values);

C API 对应写法(include/rocksdb/c.h):

rocksdb_readoptions_t* ropts = rocksdb_readoptions_create(); rocksdb_readoptions_set_async_io(ropts, 1); rocksdb_readoptions_set_optimize_multiget_for_io(ropts, 1);

实践建议:

  • 存储介质延迟越高,收益越明显:文档实测针对远端闪存,本地 NVMe 上收益相对有限;
  • CPU 敏感场景:可将optimize_multiget_for_io置为false,保留 single-level 并行,换取更低 CPU 开销;
  • 构建 MultiGet 异步优化:需要 folly 依赖;若无法引入,MultiGet 将退回同步/MultiRead 路径;
  • 迭代器扫描:长顺序扫描(大seek_nexts或长 Next 序列)场景最能体现双缓冲异步预取的价值。

深入阅读

  • 原始博客文档:docs/_posts/2022-10-07-asynchronous-io-in-rocksdb.markdown
  • ReadOptions选项定义:include/rocksdb/options.h
  • 文件系统异步接口ReadAsync/Poll:include/rocksdb/file_system.h
  • io_uring 实现:env/io_posix.cc
  • 双缓冲异步预取:file/file_prefetch_buffer.h
  • Seek 两阶段异步化:table/block_based/block_based_table_iterator.cc
  • MultiGet 协程版本:table/block_based/block_based_table_reader.cc、table/block_based/block_based_table_reader_sync_and_async.h

【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 19:01:19

GitHub热榜项目筛选与运行指南:从趋势解读到实践部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 19:01:16

Linux DRM drmModeSetCrtc底层原理与纯色显示实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 18:58:50

Android App实现开机动画替换的系统级实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 18:57:14

BrewUI:给Homebrew配上图形界面,让macOS包管理更简单

很多用 Mac 做开发的朋友&#xff0c;大概率都遇到过这样的场景&#xff1a;刚换电脑&#xff0c;或者新入职一家公司&#xff0c;光是装环境就要折腾大半天。装个 Python、Node、Git&#xff0c;打开终端一行行敲命令&#xff0c;依赖冲突了还得手动排查。倒不是说命令行有多难…

作者头像 李华