- 数据存储
- 后端
【免费下载链接】opendal
Apache OpenDAL: One Layer, All Storage.
Apache OpenDAL 正在通过 RFC 提案(cache_layer,起始于 2025-06-16)引入一个统一的CacheLayer:它以「source + cache + CachePolicy」三元组的方式,让用户把任意快速后端(Memory/Moka/Redis 等)挂到任意源后端之上,并通过可实现的CachePolicytrait 精确控制绕过、回填、整对象/分块等缓存行为。读完本文,你将理解该层的设计动机、完整 API 形态、best-effort 一致性模型、与RouteLayer的路由组合方式,以及它刻意不解决的问题边界——即便该层尚未合入主干,这套设计也是理解 OpenDAL Layer 体系的典型范本。
状态说明:截至当前仓库版本,
CacheLayer仍处于 RFC 提案阶段,尚不存在对应的实现代码。全文以 RFC 文档 为主体,文中代码均为提案 API 示例;文末结合仓库中已落地的相关构件(RouteLayer、Memory 服务等)作实现层面的佐证。
一、为什么需要统一的 Cache Layer
不同存储服务的访问性能差异巨大:S3、GCS 等远程对象存储的延迟远高于本地存储或内存缓存。在大量读密集型负载、或对同一数据反复访问的场景中,缓存能显著提升吞吐。OpenDAL 的 RFC 指出了没有内建缓存层时用户必须反复手写的通用模式:
- 先查缓存;
- 未命中时从 source 读取;
- 决定是否/何时/如何回填缓存;
- 自行处理 best-effort 一致性(例如写/删时使缓存失效)。
内建一个CacheLayer的收益在于:
- 提供统一且可组合的缓存抽象;
- 缓存行为由用户通过 policy 控制(bypass / fill / chunking 都归 policy 管);
- 直接复用 OpenDAL 现有服务作为缓存后端,不需要新的后端抽象。
这正是 OpenDAL 「Layer 保持薄、行为靠组合」哲学的一次具体化——层本身不膨胀,策略逻辑交给CachePolicy,淘汰/TTL/容量限制交给所选的缓存后端。
二、心智模型:source + cache + policy 三元组
CacheLayer用一个 cacheOperator包裹一个 sourceOperator。每次操作(read / stat / write / delete)发生时,层都会咨询CachePolicy,由 policy 回答三个问题:
- 本次请求是完全绕过缓存(bypass),还是使用缓存;
- 若使用缓存且发生 miss,是否回填(fill)缓存;
- 缓存布局是整对象(whole object)还是分块(chunked,即源对象与缓存条目之间的 1:M 映射,支持 range 感知)。
而缓存的淘汰策略(eviction)、TTL、容量限制等,完全由所选缓存后端自己负责,层与 policy 都不介入。
三、基本用法
RFC 给出的目标用法如下(提案 API):
use opendal::{ layers::{CacheLayer, WholeCachePolicy}, services::Memory, Operator, }; #[tokio::main] async fn main() -> opendal::Result<()> { let cache = Operator::new(Memory::default())?.finish(); let policy = WholeCachePolicy::new() .fill_on_read_miss(true) .invalidate_on_write(true) .invalidate_on_delete(true); let op = Operator::new(/* source service builder */)? .layer(CacheLayer::new(cache, policy)) .finish(); let _ = op.read("path/to/file").await?; Ok(()) }这段示例完整展示了各组件的职责边界:
CacheLayer是胶水代码:它把source + cache + policy组合成一个Operator,本身不决定任何具体缓存行为;CachePolicy决定行为:示例中的WholeCachePolicy通过三个 builder 开关表达了最常见的「读回填 + 写失效 + 删失效」整对象缓存语义;- 缓存后端决定淘汰/TTL:示例用
Memory服务充当缓存后端,换成 Moka、Foyer、Redis 等同样成立。
值得注意的是,Memory服务在当前仓库中已完整落地,实现为一个基于 BTreeMap 的内存服务(MemoryBuilder),始终可用、无需 feature gate,因此它是缓存后端最零成本的起步选择。OpenDAL 还内置了一批天然适合做缓存后端的 service,可在 core/src/lib.rs 的服务注册清单中看到,例如moka、mini-moka、foyer、dashmap、memcached、redis等。
与现有 Layer 体系的衔接
从源码结构看,CacheLayer将实现 OpenDAL 核心的Layertrait(core/core/src/raw/layer.rs#L38-L55),通过apply_service拦截服务栈、必要时通过apply_context调整操作上下文。RFC 还明确:层内部可以从传入的Operator取得底层 accessor/dispatcher 使用,Operator提供了into_parts()将自身拆分为(OperationContext, Servicer)的机制(core/core/src/types/operator/operator.rs#L246-L253)——这正是RouteLayer今天已在使用的同一套组合机制(见 RouteLayerBuilder::route),意味着CacheLayer不需要任何新的组合原语,天然融入现有Operator::layer()体系。
四、路由级缓存:CacheLayer 与 RouteLayer 的组合
CacheLayer本身不选择哪些路径应该被缓存。路径级分发由RouteLayer负责(位于 core/layers/route,可用cargo add opendal --features layers-route启用)。推荐做法是:为不同路径组各自构建独立的 operator 栈(各自持有自己的缓存与 policy),再用RouteLayer组合它们:
use opendal::{ layers::{CacheLayer, ChunkedCachePolicy, RouteLayer, WholeCachePolicy}, services::Memory, Operator, }; fn build_routed_operator() -> opendal::Result<Operator> { let default_op = Operator::new(/* source service builder */)?.finish(); let json_cache = Operator::new(Memory::default())?.finish(); let json_op = Operator::new(/* source service builder */)? .layer(CacheLayer::new(json_cache, WholeCachePolicy::new())) .finish(); let parquet_cache = Operator::new(Memory::default())?.finish(); let parquet_op = Operator::new(/* source service builder */)? .layer(CacheLayer::new( parquet_cache, ChunkedCachePolicy::new(8 * 1024 * 1024), )) .finish(); let op = default_op.layer( RouteLayer::builder() .route("**/*.json", json_op) .route("**/*.parquet", parquet_op) .build()?, ); Ok(op) }该组合保持了路由与缓存的职责分离:
RouteLayer决定哪条 operator 栈处理某个路径;CacheLayer决定被选中的栈内部如何使用缓存。
RouteLayer的路由语义已由实现代码确定:按添加顺序评估 glob 模式、首个匹配生效;未匹配任何路由的路径继续落到被该层包裹的 operator;copy/rename按源路径选择路由,不在两个路由目标之间搬数据;批量 delete 对每个路径独立路由(RouteLayer 文档注释,以及 core/layers/route/README.md 中的 Routing semantics 一节)。上例中 JSON 文件走整对象缓存、Parquet 文件走 8 MiB 分块缓存,正是分析型负载「按格式差异化缓存」的典型落地方式。
五、Reference 级 API 设计
5.1 公共 API 面
为了保持公共 API 小而一致,CacheLayer的输入统一是Operator(source 与 cache 都是),内部按需取得底层 accessor/dispatcher。RFC 提出的公共面为:
CacheLayer::new(cache: Operator, policy: impl CachePolicy) -> CacheLayerCachePolicytrait:定义评估与塑形(shaping)行为;- 最少的支撑类型:
Cache*Request、CacheReadDecision、CacheWriteDecision、CacheDeleteDecision、CacheLayout。
一个关键设计决策:不引入与 OpenDAL 平行的「缓存后端 trait」——缓存后端就是一个普通Operator,与既有 service 保持同构。这避免了创建第二套存储抽象面(第二套Builder、Service、错误体系),也让任何现有/未来的 OpenDAL 服务都能直接充当缓存后端。
5.2 CachePolicy:请求与决策类型
为了让决策与 OpenDAL 语义对齐,policy 接收的是借用对应Op*选项的类型化请求(typed request)。完整提案定义如下:
#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CacheLayout { Whole, Chunked { size: u32 }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CacheReadDecision { /// Do not consult cache and do not fill it for this request. Bypass, /// Use cache; optionally define layout and whether to fill on miss. Use { layout: CacheLayout, fill: bool }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CacheWriteDecision { /// Do not consult cache and do not fill/invalidate it for this request. Bypass, /// Best-effort invalidation after a successful write. Invalidate { layout: CacheLayout }, /// Write-through caching after a successful write. WriteThrough { layout: CacheLayout }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CacheDeleteDecision { /// Do not consult cache and do not invalidate it for this request. Bypass, /// Best-effort invalidation after a successful delete. Invalidate { layout: CacheLayout }, } pub struct CacheReadRequest<'a> { /// Path as seen by OpenDAL. pub path: &'a str, /// Read options from OpenDAL. pub op: &'a OpRead, } pub struct CacheStatRequest<'a> { pub path: &'a str, pub op: &'a OpStat, } pub struct CacheWriteRequest<'a> { pub path: &'a str, pub op: &'a OpWrite, } pub struct CacheDeleteRequest<'a> { pub path: &'a str, pub op: &'a OpDelete, } pub trait CachePolicy: Send + Sync + 'static { fn on_read(&self, req: CacheReadRequest<'_>) -> CacheReadDecision; fn on_stat(&self, req: CacheStatRequest<'_>) -> CacheReadDecision; fn on_write(&self, req: CacheWriteRequest<'_>) -> CacheWriteDecision; fn on_delete(&self, req: CacheDeleteRequest<'_>) -> CacheDeleteDecision; }从类型设计可以看出几个要点:
- 决策是
Copy枚举,policy 的四个回调是同步、纯函数式的:给定请求立即返回决策,无需异步、无需分配,评估开销可忽略; - 请求结构以引用借用
path和Op*,policy 可以读取路径与操作参数(如OpRead的 version、if-match 等条件——当前OpRead采用紧凑值编码存储这些字段,见 core/core/src/raw/ops.rs#L395-L412),从而做出逐请求的细粒度判断; on_stat复用CacheReadDecision:元数据缓存(metadata-only 场景)在决策层面与读共享同一套 bypass/use/fill 语义;- 写操作有三态——
Bypass/Invalidate/WriteThrough,删除只有两态——Bypass/Invalidate,这与「写入后要么失效要么透传,删除只需失效」的现实语义精确对应。
5.3 OpenDAL 计划提供的基础 policy
WholeCachePolicy:整对象缓存读;可选 fill-on-miss;写/删后 best-effort 失效;ChunkedCachePolicy:range 感知的分块缓存,源对象到缓存键为 1:M 映射(构造参数为块大小,如前述示例的8 * 1024 * 1024);MetadataOnlyCachePolicy:仅缓存选中路径的元数据(stat)。
用户也可以自己实现CachePolicy来匹配特定访问模式,例如 Parquet/Iceberg 的 range 扫描、内容不可变(immutable content)假设下的激进缓存等。
一个值得注意的实现细节:从当前源码结构看,range 选项已从OpRead移向 Reader 一侧(相关提案见 RFC 7660),OpRead现仅携带条件字段(if-match、version、override 头等)。因此分块 policy 要感知 range 时,很可能需要从 Reader 层或请求上下文中获取 range 信息——这也正是 RFC 未决问题之一(见第八节)。
六、Best-effort 一致性模型
CacheLayer提供的是缓存与 source 之间的best-effort一致性,规则如下:
- read:policy 允许时可从缓存提供数据;miss 时是否回填由 policy 决定;
- write:写成功后层应失效相关缓存条目(best effort)。若采用 write-through,则缓存中已包含新内容;若 policy 关闭 write-through,则靠失效来避免提供过期数据;
- delete:删除成功后层应失效相关缓存条目(best effort),避免缓存继续提供已删除内容。
对于返回有状态对象的操作,「成功」的定义遵循 OpenDAL 的操作生命周期。例如delete返回的Deleter(core/core/src/types/delete/deleter.rs)可以批量排队条目、并在close()时提交——因此缓存失效必须发生在底层删除真正成功提交之后,而不是路径仅仅被排进队列时。这条约束对实现者是个容易踩坑的点:失效时机挂错位置会导致「已删除对象仍从缓存读到」的窗口期扩大。
另一条重要的失败语义:缓存后端故障(如 Redis 不可用)默认不应导致 source 操作失败,它只会降低缓存有效性。这与 OpenDAL 中 retry/throttle 等层的错误处理思路一致——缓存是加速器,不是正确性依赖。
七、缓存键的塑形(Cache Key Shaping)
缓存条目的键不要求与源路径完全一致:
- 整对象缓存可以用
{path}(可加命名空间前缀); - 分块缓存可以用
{path}@{offset}-{len}或等价方案。
塑形由CachePolicy驱动(通过CacheLayout与请求中的 range 等字段)。RFC 还提出两条约束:
CacheLayer应使用一个不会与用户正常路径冲突的命名空间,避免缓存条目与缓存 operator 中用户真实存放的对象互相覆盖;- 当底层操作涉及对象版本(versioning)或分块布局时,版本与 range 信息应编码进键,保证不同版本/不同块之间不串键。
八、明确的范围外事项、缺点与未决问题
8.1 Out of scope(明确不做)
- 缓存
list的结果; - 对外部写者提供强一致性保证;
- 强制特定的淘汰/TTL/容量策略(留给缓存后端)。
8.2 已知缺点(Drawbacks)
- 缓存正确性依赖用户的 policy 选择(bypass/fill/chunking/invalidation 组合错误会直接影响数据新鲜度);
- best-effort 失效无法处理 source 被外部修改的情形(旁路 OpenDAL 直接写 S3 的第三方写入不会被感知);
- 分块缓存引入额外的键映射与失效复杂度。
8.3 未决问题(Unresolved questions)
- 缓存键命名空间策略:是否标准化一个前缀(如
__opendal_cache__/)来防止冲突; - 分块缓存下的 range 读:部分命中(partial hit)与 range 对齐(alignment)应如何表现;
- 元数据缓存边界:从缓存提供 stat 时,哪些元数据字段应被视为可信/必填。
8.4 未来可能的演进方向
- 分层缓存(L1/L2,每层不同 policy,可选提升策略 promotion);
- 内建 metrics(hit/miss 率、fill 延迟、失效失败计数);
- 更高级指令,如 “metadata-only”、“write-back”(在有明确用例时);
- 可配置的错误处理策略(best-effort vs 严格模式);
- 针对支持对象版本化的后端的 version-aware 缓存键。
九、设计决策复盘:为什么是「policy 而非布尔开关」「Operator 而非新 trait」
RFC 的 Rationale 一节给出了两个核心取舍,值得单独复盘:
为什么用CachePolicy而不是布尔选项?如果直接在CacheLayer上堆布尔开关(fill_on_miss: bool、chunk_size: Option<u32>……),那么随着 range、分块、metadata-only、bypass 规则等场景涌入,缓存层会不断「长出」策略逻辑,公共 API 为每个新场景扩张。CachePolicy把这块变化整体外移:层保持薄,行为可被用户无限制扩展(自定义 policy),而层的公共 API 面不再增长。
为什么接受Operator而不是新造一个 CacheService trait?用Operator作为缓存后端,避免创建平行的存储抽象面,缓存后端与现有 OpenDAL 服务保持完全一致;层内部把Operator转成底层 accessor 即可(Operator::into_parts提供了现成机制,见第五节)。这一决策让「缓存后端」与「源后端」在能力(capability)、配置(Builder)、错误语义上完全同构,用户心智成本最低。
先例(prior art)方面:数据库与文件系统的缓存层通常都把「存储后端」与「策略/策略选择器」分离;range/分块缓存在分析型格式(Parquet/Iceberg)中也很常见,核心目的是避免整对象读的读放大(read amplification)。
十、延伸阅读:当前仓库中的相关路径
| 内容 | 路径 |
|---|---|
| CacheLayer RFC 提案(本文主体) | core/core/src/docs/rfcs/6297_cache_layer.md |
核心Layertrait(apply_service/apply_context) | core/core/src/raw/layer.rs |
Operator::into_parts(层组合拆分机制) | core/core/src/types/operator/operator.rs |
RouteLayer实现(glob 路由、first-match-wins) | core/layers/route/src/lib.rs |
RouteLayer使用说明(feature 开启方式) | core/layers/route/README.md |
| Memory 服务(BTreeMap 内存后端) | core/core/src/services/memory/backend.rs |
| 服务注册清单(moka/foyer/redis 等缓存后端候选) | core/src/lib.rs |
OpRead紧凑值编码(policy 可读的条件字段) | core/core/src/raw/ops.rs |
Deleter类型(删除失效时机依据) | core/core/src/types/delete/deleter.rs |
| range 移入 Reader 的提案(影响分块 policy 设计) | core/core/src/docs/rfcs/7660_move_read_range_to_reader.md |
适用前提小结:CacheLayer/CachePolicy目前是提案中的 API(跟踪于对应 tracking issue),在正式合入前,生产项目若需要读缓存能力,可行的过渡方案是用现有的 service 组合手工实现「查缓存 → miss 读源 → 回填」模式,或关注后续版本是否落地本 RFC;而文中涉及的RouteLayer、Memory 服务及各类缓存型 service 均已在当前仓库中可用,可直接组合使用。
- 数据存储
- 后端
【免费下载链接】opendal
Apache OpenDAL: One Layer, All Storage.
相关推荐
devenv Eval Cache 设计深度解析:基于 FFI 的 Nix 求值透明缓存层
devenv Eval Cache 设计深度解析:基于 FFI 的 Nix 求值透明缓存层 本文以 devenv eval cache/DESIGN.md ht
开发工具CLI终极Next.js缓存策略组合:打造高性能多级缓存设计方案
终极Next.js缓存策略组合:打造高性能多级缓存设计方案 Next.js作为React框架中的佼佼者,其缓存机制是提升应用性能的核心武器。本文将深入探讨Nex
前端后端Web框架SSR前端构建Grafana Tempo 基于 Apache Parquet 的列式块存储格式设计解析
Grafana Tempo 基于 Apache Parquet 的列式块存储格式设计解析 本指南以 Grafana Tempo 官方设计文档《Parquet》(
后端可观测性链路追踪
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考