☰
Apache OpenDAL 基于范围的读取:从 RFC 4382 到无状态 Reader 的设计与实现
2026/10/14 15:42:45 网站建设 项目流程
  • 数据存储
  • 后端

【免费下载链接】opendal

Apache OpenDAL: One Layer, All Storage.

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

Apache OpenDAL(One 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等)说明该设计最终落地成什么形态,帮助使用者理解为什么"每次显式给范围"比"隐式游标"更适合对象存储。

读完本文,你将掌握:

  • 有状态Reader(AsyncRead/AsyncSeek)在对象存储上的三类具体缺陷;
  • RFC 4382 提出的无状态读取 API 草案、raw trait 设计与迁移代价;
  • 当前实现中BytesRange如何映射为 HTTPRange头、Reader提供哪些零拷贝读取与流式转换 API;
  • concurrent/chunk/gap等构建器选项如何把"范围读取"扩展为并发分片、多段合并的高效读取。

一、问题起点:有状态读取模型为什么不适合对象存储

RFC 的 Motivation 部分指出,当时的oio::Read是有状态 trait:

pub trait Read: Unpin + Send + Sync { fn read(&mut self, limit: usize) -> impl Future<Output = Result<Bytes>> + Send; fn seek(&mut self, pos: io::SeekFrom) -> impl Future<Output = Result<u64>> + 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 buffer's ownership),而接受&mut [u8]的 API 把缓冲区借给底层,无法完成所有权转移。有状态接口在抽象层面就与这类 IO 模型不兼容。

二、RFC 的解决方案:无状态、以范围为一等公民

1. 从Reader上移除的 trait 实现

RFC 的 Guide-level explanation 提议移除Reader上的这些 trait 实现:

  • futures::AsyncRead
  • futures::AsyncSeek
  • futures::Stream
  • tokio::AsyncRead
  • tokio::AsyncSeek

也就是说,Reader不再隐式地"记住读到哪了",所有定位行为必须由调用方显式给出范围。

2. 新增的无状态读取 API(RFC 草案)

impl Reader { /// Read data from the storage at the specified offset. pub async fn read(&self, buf: &mut impl BufMut, offset: u64, limit: usize) -> Result<usize>; /// Read data from the storage at the specified range. pub async fn read_range( &self, buf: &mut impl BufMut, range: impl RangeBounds<u64>, ) -> Result<usize>; /// Read all data from the storage into given buf. pub async fn read_to_end(&self, buf: &mut impl BufMut) -> Result<usize>; /// Copy data from the storage into given writer. pub async fn copy(&mut self, write_into: &mut impl futures::AsyncWrite) -> Result<u64>; /// Sink data from the storage into given sink. pub async fn sink<S, T>(&mut self, sink_from: &mut S) -> Result<u64> where S: futures::Sink<T, Error = Error>, T: Into<Bytes>; }

注意签名统一改为&self——不再需要可变引用,这正是"无状态"的直接体现:Reader变成只读句柄,任意并发调用互不干扰,问题二(并发读取)由此在 API 层面自然消解。

同时提供向既有 IO 模型的转换函数,让老代码平滑过渡:

impl Reader { /// Convert Reader into `futures::AsyncRead` pub fn into_futures_io_async_read(self, range: Range<u64>) -> FuturesIoAsyncReader; /// Convert Reader into `futures::Stream` pub fn into_futures_bytes_stream(self, range: Range<u64>) -> FuturesBytesStream; }

3. raw 层接口:read_at与Buffer的取舍

RFC 的 Reference-level explanation 给出了新的 raw trait:

pub trait Read: Unpin + Send + Sync { fn read_at( &self, offset: u64, limit: usize, ) -> impl Future<Output = Result<oio::Buffer>> + Send; }

该 API 类似标准库的File::read_at,但有两处关键改动(RFC 原文以 diff 形式给出):

- async fn read_at(&self, buf: &mut [u8], offset: u64) -> Result<usize> + async fn read_at(&self, offset: u64, limit: usize) -> Result<oio::Buffer>
  • OpenDAL 选择用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内部只持有Arc<ReadContext>,impl Clone,自身不存任何读游标:

/// Reader is designed to read data from given path in an asynchronous manner. #[derive(Clone)] pub struct Reader { ctx: Arc<ReadContext>, }

主要 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 Into<BytesRange>) -> Result<Buffer> /// Read all data from reader into given [`BufMut`]. pub async fn read_into(&self, buf: &mut impl BufMut, range: impl Into<BytesRange>) -> Result<usize>
  • read(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 Into<BytesRange>) -> Result<BufferStream> /// 转成 FuturesAsyncReader(实现 futures::AsyncRead / AsyncSeek / AsyncBufRead,有一次拷贝代价) pub async fn into_futures_async_read(self, range: impl Into<BytesRange>) -> Result<FuturesAsyncReader> /// 转成 FuturesBytesStream(实现 futures::Stream,yield bytes::Bytes) pub async fn into_bytes_stream(self, range: impl Into<BytesRange>) -> Result<FuturesBytesStream>

源码注释特意提醒:FuturesAsyncReader"is not a zero-cost abstraction"(因为底层返回 ownedBuffer,涉及额外拷贝),而BufferStream是零成本抽象。这恰好呼应了 RFC 的取舍——零拷贝路径是主路径,有状态流接口只是可选适配层。

3.BytesRange:范围抽象与 HTTP Range 头的双向桥接

RFC 草案里用RangeBounds<u64>,现状实现升级为枚举类型 BytesRange,比草案多覆盖了"后缀"场景:

pub enum BytesRange { /// Read from `offset` with optional `size`. Range { offset: u64, size: Option<u64> }, /// Read the last `size` bytes. Suffix { size: u64 }, }

关键行为(均有单测支撑,见 bytes_range.rs 的tests):

  • 从 Rust range 构造:BytesRange::from(1024..2048)、..、10..、..10等RangeBounds<u64>都可From转换,..即new(0, None)表示读到文件尾;
  • 序列化直接可用:to_header()输出bytes=0-1023、bytes=1024-、bytes=-1024三种合法 HTTP Range 形态;零长度范围不可表示为合法 Range,to_header()会返回空串而非 panic;
  • 反向解析:FromStr支持从Range请求头字符串解析回BytesRange,end < start会报错。

size: None与size: Some(n)语义不同(bytes=<offset>-读到尾 vsbytes=<offset>-<offset+n>),这点在 bytes_range.rs 的注释中有明确说明。

4. 服务侧如何消费范围:以 S3 为例

RFC 反复强调"HTTP 系服务是边请求边生成的流"。当前 S3 后端把BytesRange落到 GET 请求的方式非常直接,见 s3_get_object_request:

let mut req = Request::get(&url); if !range.is_full() { req = req.header(http::header::RANGE, range.to_header()); }

range.is_full()表示offset=0 且 size=None(全量读),此时省略 Range 头,避免多余开销;否则直接把BytesRange序列化成Range头发出。S3 的 reader 实现在 s3 reader,open(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 Future<Output = Result<(RpRead, Box<dyn ReadStreamDyn>)>> + MaybeSend; /// Read an exact bounded range into [`Buffer`]. fn read(&self, range: BytesRange) -> impl Future<Output = 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.rs:

pub trait PositionRead: Send + Sync + Unpin + 'static { type Handle: Send + Sync + Unpin + 'static; /// Open the positioned read handle. fn open(&self) -> impl Future<Output = Result<Self::Handle>> + MaybeSend; /// Read up to `size` bytes from `offset`. fn read_at( handle: &Self::Handle, offset: u64, size: usize, ) -> impl Future<Output = Result<Buffer>> + MaybeSend; }

配套的PositionReader<R>用OnceCell延迟打开句柄(所有流共享),max_buf_size默认 2 MiB,并实现了oio::Read,把"基于位置的读"适配进范围读取体系——这正是 RFC 里read_at思路的落点。

五、范围读取的工程化:concurrent、chunk、gap

RFC 问题二的根治依赖"多范围并发读",其 "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: Option<usize>、gap: Option<usize>、prefetch: usize,默认{ concurrent: 1, chunk: None, gap: None, prefetch: 0 };From<options::ReaderOptions>实现把构建器链上的设置透传进来。

2.Reader::fetch:多段范围的一次性读取

fetch是 RFC "Read Ranges"(未来支持read_ranges,用更少请求读多段数据)的落地,实现在 reader.rs。流程:

  1. 去重合并:merge_ranges把重叠或间隔小于gap的范围合并(gap未设置时按 1 MiB 合并);
  2. 切块规划:plan_fetch_reads按chunk把每个合并段切成有界子范围,逐个成为独立读取任务;
  3. 并发执行:ConcurrentTasks(绑定 executor、concurrent上限、prefetch深度)并发执行子任务;
  4. 零拷贝切片回拼:结果按原始输入顺序返回,每个输入范围从合并段结果中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还验证了 chunk=4、concurrent=2 下的实际底层调用序列。

3. 自动分范围读(Auto Range Read)

into_stream(range)配合chunk+concurrent即实现了 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..p+n);若确实需要游标语义(如适配只认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 hdfs:PositionRead/PositionReader抽象已就位,为原生按位读服务预留通道;
  • Auto Range Read:chunk/concurrent组合可实现手动分片并发,服务默认 IO 大小的差异化由chunk: None时走服务推荐值体现;
  • Completion-based IO(io_uring/IOCP):RFC 的初衷之一是为完成事件 IO 留出所有权路径,当前oio::Read仍面向流式设计,该方向在仓库中尚无对应实现,属于可预期的演进方向而非既有事实。

结语

RFC 4382 的本质判断是:对象存储的操作单位天然是"范围"而非"游标"。把状态从Reader中拿掉、把范围提升到 API 签名里,不仅消除了seek的隐性重建成本,还让并发分片读、多段合并读、零拷贝流式读成为同一套原语的推论。理解BytesRange与 HTTP Range 头的映射(bytes_range.rs)、OpReader的concurrent/chunk/gap参数(ops.rs)、以及fetch的合并-切块-并发流水线(reader.rs),你就能在这套无状态模型上写出既正确又高效的读取代码。

  • 数据存储
  • 后端

【免费下载链接】opendal

Apache OpenDAL: One Layer, All Storage.

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

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

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

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

立即咨询