☰
OpenDAL RFC-0090 limited_reader 深度解析:如何消灭“读半份文件反而更慢”的多余读取
2026/10/14 17:11:54 网站建设 项目流程
  • 数据存储
  • 后端

【免费下载链接】opendal

Apache OpenDAL: One Layer, All Storage.

项目地址:https://gitcode.com/gh_mirrors/op/opendal
点击查看免费下载

本文围绕 OpenDAL 核心仓库中的设计提案 0090_limited_reader.md 展开,讲解为什么早期Reader在“只读文件一部分”时性能反而劣化、RFC 提出的limited_reader等四个对象级读取 API 如何从请求层面(OpRead的offset/size)解决该问题,以及这套设计如何在当前代码库中演化为统一的Reader::read(range)范围读取机制。读完本文,你可以理解 OpenDAL “按需读取、不读多余数据”的完整实现链条:从用户 API、内部游标状态机到 S3 等服务的 HTTPRange请求。

背景:object-native API 引入的 Reader

RFC-0090 的直接前置是提案 0041_object_native_api.md(object-native-api)。该提案将 OpenDAL 的操作方式从Operator::read()这类全局方法,重构为以Operator::object("path")获取对象句柄、再在Object上调用reader()/writer()/metadata()/delete()的原生对象风格 API。Reader同时实现了AsyncRead + AsyncSeek,并刻意不维护内部缓冲区、只依赖底层的read与stat操作,用户可自由决定是否套上BufReader。

正是在这套早期实现中,每次从Reader拉取数据时发出的读取请求形如:

let op = OpRead { path: self.path.to_string(), offset: Some(self.current_offset()), size: None, };

注意size字段是None:它意味着“从 offset 一直读到对象末尾”。RFC 的 Motivation 一节指出了由此带来的问题——一旦调用方停止读取,只能依赖 HTTP client 自行丢弃(drop)请求来中止传输,而此前已经从网络拉下来的超额数据已经白白消耗了带宽和时间。

问题证据:read_half 比读全量还慢

RFC 用一组 S3 基准测试(对应仓库中的基准程序,见 core/benches/vs_s3 目录)量化了该缺陷:读取整个文件约 15 MiB,与“只读一半”(read_half)对比:

s3/read/1c741003-40ef-43a9-b23f-b6a32ed7c4c6 time: [7.2697 ms 7.3521 ms 7.4378 ms] thrpt: [2.1008 GiB/s 2.1252 GiB/s 2.1493 GiB/s] s3/read_half/1c741003-40ef-43a9-b23f-b6a32ed7c4c6 time: [7.0645 ms 7.1524 ms 7.2473 ms] thrpt: [1.0780 GiB/s 1.0923 GiB/s 1.1059 GiB/s]

只读一半,耗时却和读全量几乎一样(约 7.1–7.3 ms),吞吐只有一半(约 1.08–1.11 GiB/s 对 2.10–2.15 GiB/s)。RFC 将其定性为 buggy 行为:“我们总是读了太多多余的数据”,因此需要一个更清晰的 API 来表达“我只想读这么多/从这开始读”。

核心方案:Object 上的四个读取入口

RFC 的 Guide-level 方案是:移除Reader::total_size(),转而在Object上提供四个语义明确的读取构造器:

pub fn reader(&self) -> Reader {} pub fn range_reader(&self, offset: u64, size: u64) -> Reader {} pub fn offset_reader(&self, offset: u64) -> Reader {} pub fn limited_reader(&self, size: u64) -> Reader {}

四个入口的语义边界是理解这份 RFC 的关键:

  • reader:读取整个文件的 reader;
  • range_reader:读取半开区间[offset, offset+size)的 reader;
  • offset_reader:从offset读起直到文件末尾([offset:])的 reader;
  • limited_reader:只读前size字节([:size])的 reader。

limited_reader正对应标题概念:它把“本次读取的总量上限”显式化,使底层可以精确地限制请求的数据量,而不是“发一个无界请求、读到一半再丢弃”。

面向真实场景:parquet 列读取的改写

RFC 用 parquet 读取单个 column chunk 的真实逻辑说明了这套 API 的价值。改造前,parquet 需要先打开 reader、再seek到列的起始偏移、然后read_exact定长读取:

async fn _read_single_column_async<'b, R, F>( factory: F, meta: &ColumnChunkMetaData, ) -> Result<(&ColumnChunkMetaData, Vec<u8>)> where R: AsyncRead + AsyncSeek + Send + Unpin, F: Fn() -> BoxFuture<'b, std::io::Result<R>>, { let mut reader = factory().await?; let (start, len) = meta.byte_range(); reader.seek(std::io::SeekFrom::Start(start)).await?; let mut chunk = vec![0; len as usize]; reader.read_exact(&mut chunk).await?; Result::Ok((meta, chunk)) }

改造后,factory 直接接受(start, len),一步创建目标范围内的 reader:

async fn _read_single_column_async<'b, R, F>( factory: F, meta: &ColumnChunkMetaData, ) -> Result<(&ColumnChunkMetaData, Vec<u8>)> where R: AsyncRead + AsyncSeek + Send + Unpin, F: Fn(usize, usize) -> BoxFuture<'b, std::io::Result<R>>, { let (start, len) = meta.byte_range(); let mut reader = factory(start, len).await?; let mut chunk = vec![0; len as usize]; reader.read_exact(&mut chunk).await?; Result::Ok((meta, chunk)) }

RFC 归纳出两个收益:

  • 不再读取任何多余数据(No extra data will be read);
  • 不再需要额外的seek/stat操作——范围在创建 reader 时即已确定,无需先探测文件总长度。

参考级实现:offset、size、pos 三态游标

RFC 的 Reference-level 部分给出了Reader内部的状态机设计。Reader需要正确维护三个量:

  • offset:起始偏移,Option<u64>;为None时按0处理;
  • size:总读取长度上限,Option<u64>;为None时使用meta.content_length() - self.offset.unwrap_or_default()作为兜底(即“读到对象末尾”);
  • pos:当前已消费的位置。

由此可以以 O(1) 成本随时算出本次请求应携带的 offset 与 size:

fn current_offset(&self) -> u64 { self.offset.unwrap_or_default() + self.pos } fn current_size(&self) -> Option<u64> { self.size.map(|v| v - self.pos) }

与“永远请求整个对象内容”不同,现在每次请求都会带上精确的 size:

let op = OpRead { path: self.path.to_string(), offset: Some(self.current_offset()), size: self.current_size(), };

这正是对修复点的最小改动:请求结构不变,只是把size: None换成了size: self.current_size()。

修复后的基准结果

RFC 给出的对比数据表明,修复后read与read_half的吞吐趋于合理比例(读一半的耗时约为读全量的一半):

s3/read/6dd40f8d-7455-451e-b510-3b7ac23e0468 time: [4.9554 ms 5.0888 ms 5.2282 ms] thrpt: [2.9886 GiB/s 3.0704 GiB/s 3.1532 GiB/s] s3/read_half/6dd40f8d-7455-451e-b510-3b7ac23e0468 time: [3.1868 ms 3.2494 ms 3.3052 ms] thrpt: [2.3637 GiB/s 2.4043 GiB/s 2.4515 GiB/s]

RFC 的 Drawbacks、Rationale、Prior art、Unresolved questions 均为 “None”,说明这是一份收敛性很强的实现提案;Future possibilities 中保留了“重构 parquet 读取逻辑以充分利用range_reader”这一后续方向。

在当前代码库中的演化:从四个 API 到统一 range

阅读 RFC 之后,有必要对照当前仓库确认这套设计的最终落地形态。从源码结构看,limited_reader/offset_reader/range_reader这组 API 在后续版本中被逐步收敛,共经历了两个关键节点:

第一步:v0.4 移除三个专用 API,改用RangeBounds。升级文档 upgrade.md 中明确记载:自 v0.4 起,Object::limited_reader(size)、Object::offset_reader(offset)、Object::range_reader(offset, size)被移除,取而代之的是支持任意 range 语法的range_reader(range: impl RangeBounds<u64>):

let r = o.range_reader(1024..2048).await?;

RFC-0090 中四个入口想要表达的四种语义(全量、定长区间、尾部区间、前缀区间),由此全部由 Rust 原生 range 语法(a..b、a..、..b、..=b)统一覆盖,API 面反而更小。

第二步:RFC 2884 把range_read/range_reader合并进read/reader。提案 2884_merge_range_read_into_read.md(2023-08-20)进一步指出range_read与read的双轨 API 迫使用者学习两套接口,遂将范围读取直接内建到默认读取路径:

let bs = op.read_with("path/to/file").range(1024..2048).await?; let rs = op.reader_with("path/to/file").range(1024..2048).await?;

当前形态:Reader::read(range)。如今的核心实现在 reader.rs:Reader是一个可Clone的薄封装(内部持有Arc<ReadContext>),直接提供Reader::read(range),也可通过into_bytes_stream(range)/into_futures_async_read(range)转为字节流或AsyncRead使用。范围解析与边界校验集中在 read.rs 的parse_into_range:Range型范围在size为None时回退到对象content_length,并显式处理了offset + size溢出u64::MAX与“range 起点超出对象末尾”(返回RangeNotSatisfied)两类错误;Suffix型(读末尾 N 字节)则折算为(content_length - size)..content_length。同一文件中还可见ReadGenerator按chunk选项把一个大范围切成多个连续子请求(next_range/next_reader),这延续了 RFC-0090“每次请求都携带精确 size”的思路。

落到服务层就是标准 HTTP Range。以 S3 服务为例,s3 core.rs 中的s3_get_object_request在范围非全量时设置请求头:

if !range.is_full() { req = req.header(http::header::RANGE, range.to_header()); }

也就是说,RFC-0090 在核心层引入的“请求级 size 约束”,最终在对象存储服务侧兑现为一个Range请求头——S3 只会返回[offset, offset+size)对应的那一段字节,多余数据从源头上不再传输。此外,底层OpRead参数结构已从 RFC 时代的{path, offset, size}演化为紧凑值(CompactValues)表示(见 ops.rs),path由ReadContext单独承载,range则通过PresignOperation::Read(BytesRange, OpRead)等路径随请求传递,具体参数解析以当前源码为准。

小结

  • 问题:早期Reader发出的OpRead不带size,导致“读半份文件”与“读全量文件”耗时几乎相同,浪费网络带宽并拖慢吞吐;
  • 方案:在Object上提供reader/range_reader/offset_reader/limited_reader四个入口,Reader内部维护offset/size/pos三态游标,使每次底层请求都能携带精确的current_offset()与current_size();
  • 收益:不再读取多余数据、不再需要额外的seek/stat,parquet 这类“按列偏移定长读”的典型负载直接受益,修复后read_half吞吐从约 1.1 GiB/s 提升到约 2.4 GiB/s;
  • 演化:该设计随后被统一为RangeBounds语法的range_reader(v0.4),再被合并进read/reader(RFC 2884),最终沉淀为当前 reader.rs 中Reader::read(range)+ 服务层 HTTPRange头的完整实现。

这份 RFC 的价值在于:它把一个“读多了”的性能 bug,转化为对读取 API 语义的重新设计,并为此后 OpenDAL 的范围读取 API 演进(更简洁的 API 面、统一的 range 表达)奠定了方向。

  • 数据存储
  • 后端

【免费下载链接】opendal

Apache OpenDAL: One Layer, All Storage.

项目地址:https://gitcode.com/gh_mirrors/op/opendal
点击查看免费下载

相关推荐

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询