- 数据存储
- 后端
【免费下载链接】opendal
Apache OpenDAL: One Layer, All Storage.
本文围绕 OpenDAL 的 RFC-2083(writer_sink_api)展开,系统讲解为什么对象存储抽象层需要流式写入能力、sinkAPI 的原始设计(copy_from/pipe_from)如何把用户数据直接“管道”进底层服务,以及该设计在仓库中真实落地后经历的重构:从oio::Write::sink被移除(RFC-3017),到最终演化为今天Writer::into_sink()、into_bytes_sink()、into_futures_async_write()这套零拷贝 Sink/AsyncWrite 适配器。读完本文,你将理解 OpenDAL 写入路径的完整历史脉络,并知道当前版本下应使用哪些 API 实现流式上传。
背景:为什么需要流式写入
RFC 文档(2083_writer_sink_api.md)开宗明义:当时的 OpenDAL 不支持流式上传,用户必须先把数据加载进内存,再分块调用write:
let bs = balabala(); w.write(bs).await?; let bs = daladala(); w.write(bs).await?; ... w.close().await?;这种模式有两个主要缺点(RFC Summary 与 Motivation 章节):
- 内存占用高:数据必须先完整驻留内存。RFC 明确引用了 GitHub issue #1821 中用户报告的内存问题;
- 性能低:因为用户数据必须先被缓冲到内存中,才能发送到网络。
因此 RFC 提议让 OpenDAL 提供一个 API,允许用户把一个 stream 或 reader 直接传入 writer,跳过“全量驻留内存”这一步。
RFC 原始 API 设计:copy_from与pipe_from
RFC 在 Guide-level explanation 中提出的Writer公共 API 有两个入口:
impl Writer { pub async fn copy_from<R>(&mut self, size: u64, r: R) -> Result<()> where R: futures::AsyncRead + Send + Sync + 'static; pub async fn pipe_from<S>(&mut self, size: u64, s: S) -> Result<()> where S: futures::TryStream + Send + Sync + 'static Bytes: From<S::Ok>; }二者分别面向两类数据源:
copy_from接受任意futures::AsyncRead源(例如异步文件、HTTP 响应体),size参数声明数据源的总长度;pipe_from接受任意futures::TryStream,且要求流中的项S::Ok能转换为Bytes,适合已经是分块流形态的数据(例如上游对象存储的 Reader 分块流)。
RFC 给出的使用示例展示了流式写入 5 TiB 文件的场景:
// Start writing the 5 TiB file. let w = op.writer_with( OpWrite::default() .with_content_length(5 * 1024 * 1024 * 1024 * 1024)); let r = balabala(); // Send to network directly without in-memory buffer. w.copy_from(size, r).await?; // repeat... ... // Close the write once we are ready! w.close().await?;注意OpWrite::with_content_length的用法:提前声明文件的总长度,使得底层服务(例如 S3 分片上传)可以在不缓冲整份数据的情况下规划上传策略。RFC 同时承诺:“The underlying services will handle this stream in the most efficient way possible”——即把“如何最高效地消费这个流”的决定权交给各 service 实现。
底层设计:oio::Write::sink
Reference-level explanation 给出了该 RFC 的核心机制:在原始层oio::Writetrait 上新增一个sink方法:
#[async_trait] pub trait Write: Unpin + Send + Sync { async fn sink(&mut self, size: u64, s: Box<dyn futures::TryStream<Ok=Bytes> + Send + Sync>) -> Result<()>; }(见 2083_writer_sink_api.md 的原始签名)
设计要点:
- 统一归一为字节流。OpenDAL 把用户传入的 reader 或 stream 都转换成
Box<dyn TryStream<Ok=Bytes> + Send + Sync>形式的字节流,再交给 service。这样 service 侧只需面对一种输入形态,降低了 trait 的复杂度; - 原生流式 vs 回退缓冲。RFC 说明:支持流式上传的服务可以直接消费这个流;不支持的服务则可以用反复调用
write的方式把整个流逐块写入,即 sink 对不支持的 service 有天然的回退路径。
content_length与sinksize 的区别
Rationale 章节回答了一个关键问题:OpWrite::content_length与sinkAPI 的size参数有什么差异?
OpWrite::content_length声明的是待写入文件的总长度;sink的size参数表示本次传入的 reader/stream 的长度。
当content_length恰好等于某次 sink 传入的size时,某些服务可以优化为“单次请求写完全部内容”(例如一个 PUT 请求)而不是分片上传。这两个参数的语义区分是 service 层做上传策略优化的依据。
落地情况:sink 曾真实进入 OpenDAL 的 Raw API
RFC 并非停留在纸面。从 core/CHANGELOG.md 可以确认其完整的落地轨迹:
feat(core): Add Write::sink API(PR #2440):oio::Write::sink进入核心;- 随后多个服务逐个实现原生 sink:
feat: Implement sink for services s3(PR #2508),以及 azblob、gcs、oss、obs、cos、webdav 等服务的 sink 实现(PR #2574/#2576/#2577/#2578/#2587/#2622); feat: Add retry for Writer::sink operation(PR #2896):为 sink 操作增加了重试支持,这正是 RFC “Future possibilities” 章节预告的方向;refactor: Relax bounds on Writer::{sink, copy}(PR #3027):放宽了公共 API 的 trait bound。
core/core/src/docs/upgrade.md 中 “Upgrade to v0.38” 一节也印证了这一点:OpenDAL 在 v0.38 为 Raw API 添加了Write::sink,这对依赖 raw API 的实现者是破坏性变更,官方给出的快速适配方案是让自定义Write实现对sink()返回Unsupported错误——这恰好与 RFC 中“不支持流式的服务返回错误、由框架回退”的设计相衔接。
后续演进:oio::Write::sink(copy_from)被移除
sink API 在实现中暴露了问题。后续 RFC-3017(3017_remove_write_copy_from.md,2023-09-06)记录了这一转折:sinkAPI 落地后曾更名为oio::Write::copy_from,签名变为:
pub trait Write: Unpin + Send + Sync { async fn copy_from(&mut self, size: u64, src: oio::Reader) -> Result<u64>; }RFC-3017 指出了它的两大局限:
- 与既有的缓冲(buffering)和重试(retry)机制不兼容:sink 的流式语义难以被 buffering/retry layer 统一拦截处理;
- 对 reader 强加所有权要求:
src: oio::Reader是值传递,每次写操作都需要重新创建 reader,使用上被复杂化。
理想形态copy_from(&mut self, size: u64, src: &mut impl oio::Read)在当时的 Rust 与 hyper 约束下无法做到 object-safe 且被 hyper 的impl Stream + 'static要求所限制。因此 RFC-3017 决定移除oio::Write::copy_from,保留Writer::sink()/Writer::copy()等公共入口,但内部改用futures::io::copy(&mut r, w)基于AsyncWrite实现流式传输——即在 service 内部通过通用的异步写抽象完成 reader 到 writer 的管道,而不是让 service 直接消费原始流。
当前仓库状态:sink 思想在公共 API 上的最终形态
从当前仓库源码看,oio::Writetrait(api.rs)已经没有sink方法,核心方法只剩write、copy_from(注意:此处的copy_from语义已变为“把已存在对象的某个范围原生拷贝进 writer”,受Capability::write_can_copy_from能力位控制,见 api.rs 的注释)、close和abort。
RFC-2083 想解决的“流式写入”需求,如今由Writer上的三类 Sink/AsyncWrite 适配器承载,定义于 writer.rs:
| API | 返回类型 | 定位 | 源码位置 |
|---|---|---|---|
into_sink() | BufferSink(实现futures::Sink<Buffer>) | 零拷贝,复用底层 Bytes,无额外复制 | writer.rs#L303 |
into_bytes_sink() | FuturesBytesSink(实现futures::Sink<Bytes>) | 零拷贝 | writer.rs#L418 |
into_futures_async_write() | FuturesAsyncWriter(实现futures::AsyncWrite) | 需要 ownedBuffer,存在一次额外拷贝 | writer.rs#L361 |
典型用法(源自 writer.rs 的文档示例):
use bytes::Bytes; use futures::SinkExt; use opendal_core::Operator; use opendal_core::Result; async fn test(op: Operator) -> Result<()> { let mut s = op.writer("hello.txt").await?.into_sink(); let bs = "Hello, World!".as_bytes(); s.send(Buffer::from(bs)).await?; s.close().await?; Ok(()) }use futures::io::AsyncWriteExt; use opendal_core::Operator; use opendal_core::Result; async fn test(op: Operator) -> Result<()> { let mut w = op.writer("hello.txt").await?.into_futures_async_write(); let bs = "Hello, World!".as_bytes(); w.write_all(bs).await?; w.close().await?; Ok(()) }此外Writer还提供write_from(buf: impl Buf)(writer.rs#L162)用于从bytes::Buf高效写入:对单块连续Bytes走零拷贝快路径,对Chain等多块缓冲区则逐块提取,其中Bytes块的提取只是引用计数递增、不发生数据复制。这套组合——零拷贝 Sink 适配器 + 可链式配置的 writer 构建器(如.concurrent(8).chunk(256),见 writer.rs 文档示例)——在功能上承接了 RFC-2083 “把流直接送进 writer、由底层以最高效方式处理”的初衷,同时绕开了当初 sink trait 方法与 buffering/retry 层冲突的问题。
对应的实现与单测分别位于 buffer_sink.rs、futures_bytes_sink.rs、futures_async_writer.rs,而Writer自身的行为验证见 writer.rs 的 tests 模块。
RFC 中预留的开放问题
回到 RFC 原文的 “Future possibilities” 章节,其中两项也都有了后续:
- sink 的重试:RFC 指出“sink API 本身无法重试,但可以在流被拉取(
next)遇到可重试错误时,借助backon一类 crate 重新调用next来重试”。CHANGELOG 中的feat: Add retry for Writer::sink operation(PR #2896)表明这一方向在 sink 存续期间曾落地;sink 移除后,重试职责回归到统一的 retry layer 与AsyncWrite路径上。 - blocking 支持:RFC 明确“We will add async support first”——先实现异步版本,阻塞版本后置。这一点从当前仓库结构看仍然成立:
oio写入体系与Writer适配器全部基于异步。
小结
RFC-2083 是 OpenDAL 写入路径设计史中承上启下的一份提案:它确立了“用户侧流式源 + service 侧最高效消费 + 不支持时回退缓冲”的流式写入模型,并真实经历了从oio::Write::sink落地(S3、azblob、GCS、OSS、OBS、COS、WebDAV 等服务原生支持)、增加重试,到因 buffering/retry 兼容性与 reader 所有权问题被 RFC-3017 移除、重构为基于AsyncWrite的实现。对今天的用户而言,需要流式写入时不再寻找sink/pipe_from这类 API,而是直接使用Writer::into_sink()/into_bytes_sink()/into_futures_async_write()这组零拷贝或低开销适配器;而在需要“把已有对象范围合并写入”的场景下,Writer::copy_from(path, range)(受Capability::write_can_copy_from能力位约束、单次最多 5 GiB)提供了服务端的原生拷贝路径。理解这一演进脉络,有助于在升级 OpenDAL 版本、阅读旧代码或自行实现 service 时,准确对应历史文档与当前代码之间的 API 差异。
- 数据存储
- 后端
【免费下载链接】opendal
Apache OpenDAL: One Layer, All Storage.
相关推荐
Apache OpenDAL RFC-0554 深度解读:以 BytesReader 重构 write 操作的设计与落地
Apache OpenDAL RFC 0554 深度解读:以 BytesReader 重构 write 操作的设计与落地 本篇技术指南围绕 Apache Ope
数据存储后端Apache OpenDAL RFC-3017 解析:为何移除 `oio::Write::copy_from` 及其设计演进
Apache OpenDAL RFC 3017 解析:为何移除 oio::Write::copy_from 及其设计演进 导读 本文以 Apache OpenD
数据存储后端Apache OpenDAL 服务配置统一化:从 Builder 到 Config 结构体的演进设计(RFC 3197 深度解读)
Apache OpenDAL 服务配置统一化:从 Builder 到 Config 结构体的演进设计(RFC 3197 深度解读) 导读 本文以 Apache
数据存储后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考