Apache OpenDAL 基于范围的读取:从 RFC 4382 到无状态 Reader 的设计与实现 数据存储后端【免费下载链接】opendalApache OpenDAL: One Layer, All Storage.项目地址https://gitcode.com/gh_mirrors/op/opendal点击查看免费下载Apache OpenDALOne Layer, All Storage在早期采用类std::io::Read的有状态读取模型Reader持有内部游标通过read顺序取数、通过seek定位。RFC 4382_range_based_read 记录了这次核心读 API 的转向决策——把oio::Read改造为无状态的、基于范围range-based的读取模式。本文以该 RFC 为主体完整梳理其动机、设计方案与取舍并结合当前仓库源码core/core/src/types/read/reader.rs、core/core/src/types/bytes_range.rs、core/services/s3/src/core.rs等说明该设计最终落地成什么形态帮助使用者理解为什么每次显式给范围比隐式游标更适合对象存储。读完本文你将掌握有状态ReaderAsyncRead/AsyncSeek在对象存储上的三类具体缺陷RFC 4382 提出的无状态读取 API 草案、raw trait 设计与迁移代价当前实现中BytesRange如何映射为 HTTPRange头、Reader提供哪些零拷贝读取与流式转换 APIconcurrent/chunk/gap等构建器选项如何把范围读取扩展为并发分片、多段合并的高效读取。一、问题起点有状态读取模型为什么不适合对象存储RFC 的 Motivation 部分指出当时的oio::Read是有状态 traitpub trait Read: Unpin Send Sync { fn read(mut self, limit: usize) - impl FutureOutput ResultBytes Send; fn seek(mut self, pos: io::SeekFrom) - impl FutureOutput Resultu64 Send; }用户用read从存储取数用seek跳到指定位置游标状态由 OpenDAL 内部管理。RFC 承认这个设计对熟悉std::io::Read、futures::AsyncRead、tokio::io::AsyncRead的用户很友好OpenDAL 也早在Operator层面提供了range选项来读取指定范围典型用法是let r: Reader op.reader_with(path).range(1024..2048).await?;但观察真实用户后RFC 列出三个结构性问题。1.AsyncSeek容易被误用设计Reader时预期用户会先检查read_can_seek能力位判断底层服务是否原生支持seek。但很多用户并不了解这一点直接调用seek导致性能劣化。RFC 举了 S3 的例子S3 并不原生支持seek用户调用seek时OpenDAL 会丢弃当前 reader 并发出一个新的 GET 请求。这个行为对用户完全不可见容易造成parquet 流式读取性能诡异下降一类难以排查的问题RFC 中引用了对应的上游 issue #3725。2. 不支持并发读取有状态模型下同一个Reader内部只有一个游标天然无法并发。用户如果想做并发分段读只能自己实现类似 merge IO 的拼装逻辑RFC 引用了 issue #3675 support merge io read api by settings而 OpenDAL 本身没有任何接口支撑这件事——There is no way for opendal to support this feature。3. 无法承接 Completion-based IO完成事件驱动的 IO如 io_uring、IOCP需要接管缓冲区的所有权take the buffers ownership而接受mut [u8]的 API 把缓冲区借给底层无法完成所有权转移。有状态接口在抽象层面就与这类 IO 模型不兼容。二、RFC 的解决方案无状态、以范围为一等公民1. 从Reader上移除的 trait 实现RFC 的 Guide-level explanation 提议移除Reader上的这些 trait 实现futures::AsyncReadfutures::AsyncSeekfutures::Streamtokio::AsyncReadtokio::AsyncSeek也就是说Reader不再隐式地记住读到哪了所有定位行为必须由调用方显式给出范围。2. 新增的无状态读取 APIRFC 草案impl Reader { /// Read data from the storage at the specified offset. pub async fn read(self, buf: mut impl BufMut, offset: u64, limit: usize) - Resultusize; /// Read data from the storage at the specified range. pub async fn read_range( self, buf: mut impl BufMut, range: impl RangeBoundsu64, ) - Resultusize; /// Read all data from the storage into given buf. pub async fn read_to_end(self, buf: mut impl BufMut) - Resultusize; /// Copy data from the storage into given writer. pub async fn copy(mut self, write_into: mut impl futures::AsyncWrite) - Resultu64; /// Sink data from the storage into given sink. pub async fn sinkS, T(mut self, sink_from: mut S) - Resultu64 where S: futures::SinkT, Error Error, T: IntoBytes; }注意签名统一改为self——不再需要可变引用这正是无状态的直接体现Reader变成只读句柄任意并发调用互不干扰问题二并发读取由此在 API 层面自然消解。同时提供向既有 IO 模型的转换函数让老代码平滑过渡impl Reader { /// Convert Reader into futures::AsyncRead pub fn into_futures_io_async_read(self, range: Rangeu64) - FuturesIoAsyncReader; /// Convert Reader into futures::Stream pub fn into_futures_bytes_stream(self, range: Rangeu64) - FuturesBytesStream; }3. raw 层接口read_at与Buffer的取舍RFC 的 Reference-level explanation 给出了新的 raw traitpub trait Read: Unpin Send Sync { fn read_at( self, offset: u64, limit: usize, ) - impl FutureOutput Resultoio::Buffer Send; }该 API 类似标准库的File::read_at但有两处关键改动RFC 原文以 diff 形式给出- async fn read_at(self, buf: mut [u8], offset: u64) - Resultusize async fn read_at(self, offset: u64, limit: usize) - Resultoio::BufferOpenDAL 选择用oio::Buffer代替mut [u8]避免生命周期问题返回 owned 的oio::Buffer让存储服务自己管理缓冲区——这对流式服务尤其重要比如基于 HTTP 的 S3 是边发请求边生成数据的流无法预先把整块数据放进调用方给定的固定 buffer。4. 代价与备选破坏性变更RFC 的 Drawbacks 部分非常坦率这次变更会打破现有ReaderAPI用户必须改代码。想要迁移到范围式 API 的直接改用新接口只想继续使用futures::AsyncRead的用户可以走Reader::into_futures_read一类的转换路径。Rationale and alternatives 一栏写着 None——RFC 认为该问题没有其他可行的折中方案。三、现状实现RFC 思路在代码中的落地RFC 的草案签名与最终 API 已有差异例如 RFC 中read(buf, offset, limit)三段式现在演化为以BytesRange为输入的统一形态但核心思想——无状态、范围显式、转换函数按需派生流式接口——完整保留。下面按源码逐层说明。1. 公共Reader只读、可克隆、按范围取数实现位于 reader.rs。Reader内部只持有ArcReadContextimpl Clone自身不存任何读游标/// Reader is designed to read data from given path in an asynchronous manner. #[derive(Clone)] pub struct Reader { ctx: ArcReadContext, }主要 API源码行号见 reader.rs/// Read give range from reader into [Buffer]. /// /// This operation is zero-copy, which means it keeps the [bytes::Bytes] returned by underlying /// storage services without any extra copy or intensive memory allocations. pub async fn read(self, range: impl IntoBytesRange) - ResultBuffer /// Read all data from reader into given [BufMut]. pub async fn read_into(self, buf: mut impl BufMut, range: impl IntoBytesRange) - Resultusizeread(range)一次性把某个范围读进 ownedBuffer零拷贝内部是into_stream收集后flatten对应 RFC 中 返回oio::Buffer让服务自己管缓冲 的决策read_into(buf, range)面向BufMut会按需在分配时拷贝metadata()返回本次读取观察到的对象元数据不做额外 I/O未读取前可能为None这在 reader.rs 有明确注释。测试代码同文件mod tests覆盖了read(..)全量读、chunk(16)分块读、chunk(128).concurrent(16)并发分片读、suffix 后缀读等路径可直接作为行为参照。2. 与 RFC 草案一一对应的流式转换RFC 承诺的into_futures_*转换在现状实现中保留并扩展为三个出口reader.rs/// 转成 BufferStream零拷贝yield 底层 Buffer pub async fn into_stream(self, range: impl IntoBytesRange) - ResultBufferStream /// 转成 FuturesAsyncReader实现 futures::AsyncRead / AsyncSeek / AsyncBufRead有一次拷贝代价 pub async fn into_futures_async_read(self, range: impl IntoBytesRange) - ResultFuturesAsyncReader /// 转成 FuturesBytesStream实现 futures::Streamyield bytes::Bytes pub async fn into_bytes_stream(self, range: impl IntoBytesRange) - ResultFuturesBytesStream源码注释特意提醒FuturesAsyncReaderis not a zero-cost abstraction因为底层返回 ownedBuffer涉及额外拷贝而BufferStream是零成本抽象。这恰好呼应了 RFC 的取舍——零拷贝路径是主路径有状态流接口只是可选适配层。3.BytesRange范围抽象与 HTTP Range 头的双向桥接RFC 草案里用RangeBoundsu64现状实现升级为枚举类型 BytesRange比草案多覆盖了后缀场景pub enum BytesRange { /// Read from offset with optional size. Range { offset: u64, size: Optionu64 }, /// Read the last size bytes. Suffix { size: u64 }, }关键行为均有单测支撑见 bytes_range.rs 的tests从 Rust range 构造BytesRange::from(1024..2048)、..、10..、..10等RangeBoundsu64都可From转换..即new(0, None)表示读到文件尾序列化直接可用to_header()输出bytes0-1023、bytes1024-、bytes-1024三种合法 HTTP Range 形态零长度范围不可表示为合法 Rangeto_header()会返回空串而非 panic反向解析FromStr支持从Range请求头字符串解析回BytesRangeend start会报错。size: None与size: Some(n)语义不同bytesoffset-读到尾 vsbytesoffset-offsetn这点在 bytes_range.rs 的注释中有明确说明。4. 服务侧如何消费范围以 S3 为例RFC 反复强调HTTP 系服务是边请求边生成的流。当前 S3 后端把BytesRange落到 GET 请求的方式非常直接见 s3_get_object_requestlet mut req Request::get(url); if !range.is_full() { req req.header(http::header::RANGE, range.to_header()); }range.is_full()表示offset0 且 sizeNone全量读此时省略 Range 头避免多余开销否则直接把BytesRange序列化成Range头发出。S3 的 reader 实现在 s3 readeropen(range)按范围发起GetObject并返回流——与 RFC 中返回Reader而非直接read以便 fs 等服务省去多余open系统调用的 prior art 段落借鉴object_store的get_range设计、但保留Reader抽象完全一致。四、从 raw trait 到服务实现范围读取的中间层1. 当前 rawReadtrait 的形态api.rs 中的内部 trait 已从 RFC 草案的单点read_at演化为一对方法pub trait Read: Unpin Send Sync { /// Open a range stream for the given range. fn open( self, range: BytesRange, ) - impl FutureOutput Result(RpRead, Boxdyn ReadStreamDyn) MaybeSend; /// Read an exact bounded range into [Buffer]. fn read(self, range: BytesRange) - impl FutureOutput Result(RpRead, Buffer) MaybeSend; }open负责按范围打开一条流对应 RFC 中Reader变流式后服务的自然形态read负责精确读取一个有界范围并返回 ownedBuffer保留了 RFC 中read_at返回Buffer的核心决策。另有ReadDyn/ReadStreamDyn提供对象安全适配ReadStream定义了流式read()与默认read_all()实现。2.PositionRead/PositionReader为原生read_at预留的通道RFC 的 Future possibilities 提到Nativeread_atfor fs and hdfs——用跨平台的read_at减少不必要的open/seek系统调用。仓库中已存在对应抽象 position_read.rspub trait PositionRead: Send Sync Unpin static { type Handle: Send Sync Unpin static; /// Open the positioned read handle. fn open(self) - impl FutureOutput ResultSelf::Handle MaybeSend; /// Read up to size bytes from offset. fn read_at( handle: Self::Handle, offset: u64, size: usize, ) - impl FutureOutput ResultBuffer MaybeSend; }配套的PositionReaderR用OnceCell延迟打开句柄所有流共享max_buf_size默认 2 MiB并实现了oio::Read把基于位置的读适配进范围读取体系——这正是 RFC 里read_at思路的落点。五、范围读取的工程化concurrent、chunk、gapRFC 问题二的根治依赖多范围并发读其 Future possibilities 一节的Read Ranges与Auto Range Read在当前实现中已经部分兑现。1. 构建器选项通过Operator::reader_with(path)返回的构建器operator_futures.rs可配置选项默认值含义concurrent(n)1并发发起的 range 请求数max(1)保护chunk(n)服务默认值每个请求读取的块大小如chunk(4 * 1024 * 1024)按 4 MiB 分块gap(n)1 MiB内部合并时unwrap_or(1024 * 1024)两段范围间隔不超过n字节时合并为一个请求gap(0)关闭跨间隔合并相邻/重叠仍会合并prefetch(n)0预取的缓冲数量内部参数结构 OpReader 持有concurrent: usize、chunk: Optionusize、gap: Optionusize、prefetch: usize默认{ concurrent: 1, chunk: None, gap: None, prefetch: 0 }Fromoptions::ReaderOptions实现把构建器链上的设置透传进来。2.Reader::fetch多段范围的一次性读取fetch是 RFC Read Ranges未来支持read_ranges用更少请求读多段数据的落地实现在 reader.rs。流程去重合并merge_ranges把重叠或间隔小于gap的范围合并gap未设置时按 1 MiB 合并切块规划plan_fetch_reads按chunk把每个合并段切成有界子范围逐个成为独立读取任务并发执行ConcurrentTasks绑定 executor、concurrent上限、prefetch深度并发执行子任务零拷贝切片回拼结果按原始输入顺序返回每个输入范围从合并段结果中slice出对应子区间may share the same underlying memory without any extra copy。单测test_fetch、test_merge_ranges同文件验证了[0..10, 10..20, 21..30, 40..50, 40..60, 45..59]在gap(1)下合并为[0..30, 40..60]在gap(0)下为[0..20, 21..30, 40..60]test_fetch_plans_merged_ranges_with_chunk还验证了 chunk4、concurrent2 下的实际底层调用序列。3. 自动分范围读Auto Range Readinto_stream(range)配合chunkconcurrent即实现了 RFC 设想的把范围切分后并发读例如reader_with(hello.txt).concurrent(8).chunk(256)后以 256 B 块、8 路并发流式读取该示例直接来自 reader.rs 的文档注释。RFC 中还提到服务可以定义推荐 IO 大小作为默认用户可覆盖例如 S3 用 8 MiB、fs 用 4 KiB——当前实现中chunk: None即走服务推荐值与这一设想吻合。六、迁移指南从有状态到范围式结合 RFC Drawbacks 与现状 API老代码的迁移路径可以归纳为三类顺序全量读原来AsyncRead循环调read的代码等价改写为reader.into_stream(..)或一次性reader.read(..)零拷贝/reader.read_into(mut buf, ..)需要 seek 的代码显式化意图把seek 到 p 再读 n改成reader.read(p..pn)若确实需要游标语义如适配只认AsyncRead的第三方库用into_futures_async_read(range)获得带AsyncRead/AsyncSeek的适配层并接受其额外拷贝大对象并发读用reader_with(path).concurrent(8).chunk(4 * 1024 * 1024)建立读取器或直接用reader.fetch(vec![r1, r2, ...])表达多段读意图合并/切块/并发全部由 OpenDAL 完成。RFC 遗留的Unresolved questions切换到范围读取后 reader 内不再保有 buffer用户应使用into_async_buf_read之类的转换在现状中对应为into_futures_async_read等转换 API方向一致。七、尚未兑现的远景RFC Future possibilities 中的各项与现状对照Read Ranges已部分兑现为Reader::fetch多段合并 chunk 并发Nativeread_atfor fs and hdfsPositionRead/PositionReader抽象已就位为原生按位读服务预留通道Auto Range Readchunk/concurrent组合可实现手动分片并发服务默认 IO 大小的差异化由chunk: None时走服务推荐值体现Completion-based IOio_uring/IOCPRFC 的初衷之一是为完成事件 IO 留出所有权路径当前oio::Read仍面向流式设计该方向在仓库中尚无对应实现属于可预期的演进方向而非既有事实。结语RFC 4382 的本质判断是对象存储的操作单位天然是范围而非游标。把状态从Reader中拿掉、把范围提升到 API 签名里不仅消除了seek的隐性重建成本还让并发分片读、多段合并读、零拷贝流式读成为同一套原语的推论。理解BytesRange与 HTTP Range 头的映射bytes_range.rs、OpReader的concurrent/chunk/gap参数ops.rs、以及fetch的合并-切块-并发流水线reader.rs你就能在这套无状态模型上写出既正确又高效的读取代码。赞分享数据存储后端【免费下载链接】opendalApache OpenDAL: One Layer, All Storage.项目地址https://gitcode.com/gh_mirrors/op/opendal点击查看免费下载相关推荐Apache OpenDAL RFC-7660 解析把字节范围从 Access::read 移到 Raw ReaderApache OpenDAL RFC 7660 解析把字节范围从 Access::read 移到 Raw Reader OpenDAL 的原始读raw re数据存储后端OpenDAL RFC-2884 解读将 range_read 合并进 read统一范围读取 API 设计OpenDAL RFC 2884 解读将 range_read 合并进 read统一范围读取 API 设计 Apache OpenDAL 在 RFC 288数据存储后端Apache OpenDAL 的 Multipart 上传设计与实现从 RFC 0438 到并发写入架构Apache OpenDAL 的 Multipart 上传设计与实现从 RFC 0438 到并发写入架构 导读 本文以 OpenDAL 设计文档 core/数据存储后端创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考