Rspack 源码解析(二十一):Cacheable 与 Storage 如何持久化编译结果

本篇是 Rspack 源码解析系列第二十一篇。第十一篇讲 Cache/Artifact/Mutation 时提到「禁用缓存不会改变构建语义」,但没展开缓存到底存在哪、怎么序列化。本文进入 rspack_cacheable 和 rspack_storage,看 Rspack 如何把内存中的编译结果变成磁盘上的字节,并在下一次构建时读回来。

前言:缓存要解决两件事

持久化缓存看似简单,实际要解决:

text 复制代码
Rust 结构体怎么变成字节
字节怎么组织成文件
文件怎么校验是否有效
过期数据怎么清理
并发读写怎么协调
配置变了缓存怎么失效

Rspack 把前一件事交给 rspack_cacheable,后一件事交给 rspack_storage。

源码入口:cacheable 的两个核心函数

crates/rspack_cacheable/src/lib.rs 暴露了 to_bytes / from_bytes:

rust 复制代码
pub fn to_bytes<T, C: CacheableContext>(value: &T, ctx: &C) -> Result<Vec<u8>>
where
  T: for<'a> Serialize<Serializer<'a>>;

pub fn from_bytes<T, C: CacheableContext>(bytes: &[u8], context: &C) -> Result<T>
where
  T: Archive,
  T::Archived: for<'a> CheckBytes<Validator<'a>> + Deserialize<T, Deserializer>;

注意 CacheableContext 这个泛型:序列化不是纯粹「值转字节」,还依赖上下文(例如项目根路径)。

序列化:rkyv 的封装

serialize.rs 的实现揭示了底层引擎是 rkyv:

rust 复制代码
pub fn to_bytes<T, C: CacheableContext>(value: &T, ctx: &C) -> Result<Vec<u8>> {
  let guard = ContextGuard::new(ctx);
  let mut arena = Arena::new();
  let mut serializer = RkyvSerializer::new(AlignedVec::new(), arena.acquire(), Share::new());
  guard.add_to_sharing(&mut serializer)?;

  serialize_using(value, &mut serializer)?;
  Ok(serializer.into_writer().into_vec())
}

几个关键点:

  1. Arena:rkyv 序列化依赖 arena 分配内存;
  2. ContextGuard:把自定义 context 注入序列化过程;
  3. Share:支持共享指针去重,避免重复序列化同一对象;
  4. 输出 AlignedVec(对齐字节缓冲),这是 rkyv 零拷贝反序列化的基础。

noop feature:编译期禁用缓存

lib.rs 里有一个特性开关:

rust 复制代码
#[cfg(not(feature = "noop"))]
pub use rspack_cacheable_macros::{
  enable_cacheable as cacheable, enable_cacheable_dyn as cacheable_dyn,
};

#[cfg(feature = "noop")]
pub fn to_bytes<T, C: CacheableContext>(_value: &T, _ctx: &C) -> Result<Vec<u8>> {
  let _ = serialize::to_bytes::<u8, C>;
  panic!("Cannot use to_bytes when noop feature is enabled")
}

当 noop feature 开启时,to_bytes/from_bytes 直接 panic。这说明缓存能力可以在编译期整体关闭------印证了「禁用缓存不改变构建语义」。

#cacheable:给结构体派发序列化

在 concatenated_module.rs 等文件里能看到大量 #[cacheable]:

rust 复制代码
#[cacheable]
#[derive(Debug)]
pub struct RootModuleContext {
  pub id: ModuleIdentifier,
  pub readable_identifier: String,
  // ...
}

#[cacheable](enable_cacheable 宏)会为结构体实现 rkyv 需要的 Serialize/Deserialize trait。这样复杂结构就能直接参与持久化缓存。

with:处理 rkyv 无法直接表达的类型

crates/rspack_cacheable/src/with/mod.rs 导出了一组 with 转换器:

rust 复制代码
pub use r#as::{As, AsConverter};
pub use as_cacheable::AsCacheable;
pub use as_map::{AsMap, AsMapConverter};
pub use as_owned::AsOwned;
pub use as_string::{AsString, AsStringConverter};
pub use as_vec::{AsVec, AsVecConverter};
pub use custom::{Custom, CustomConverter};
pub use inline::Inline;
pub use unsupported::Unsupported;

不是所有类型都能被 rkyv 直接序列化。比如 SmolStr 需要用 AsPreset(第 26 篇 hash 里会出现),HashMap 需要用 AsMap,某些指针需要用 AsOwned。with 模块就是「类型适配层」。

dyn:trait object 怎么序列化

crates/rspack_cacheable/src/dyn/mod.rs 解决了一个难题:dyn Module、dyn Dependency 这类 trait object 怎么序列化。

它定义了:

rust 复制代码
pub trait SerializeDyn {
  fn serialize_dyn(&self, serializer: &mut Serializer) -> Result<usize>;
}

关键在 DynEntry 和 DYN_REGISTRY:

rust 复制代码
inventory::collect!(DynEntry);

static DYN_REGISTRY: std::sync::LazyLock<HashMap<u64, VTablePtr>> =
  std::sync::LazyLock::new(|| {
    let mut result = HashMap::default();
    for entry in inventory::iter::<DynEntry> {
      // dyn_id -> vtable 映射
    }
    result
  });

它用一个 dyn_id(u64)代表「这是哪个具体类型」,序列化时只存 dyn_id,反序列化时通过注册表找回 vtable。这就是 #[cacheable_dyn] 宏的作用------把具体类型的 vtable 注册进全局表。

源码入口:Storage trait

crates/rspack_storage/src/lib.rs 定义了存储抽象:

rust 复制代码
#[async_trait::async_trait]
pub trait Storage: std::fmt::Debug + Sync + Send {
  async fn load(&self, scope: &'static str) -> Result<Vec<(Arc<Vec<u8>>, Arc<Vec<u8>>)>>;
  fn set(&self, scope: &'static str, key: Vec<u8>, value: Vec<u8>);
  fn remove(&self, scope: &'static str, key: &[u8]);
  fn trigger_save(&self) -> Result<Receiver<Result<()>>>;
  async fn reset(&self);
  async fn scopes(&self) -> Result<Vec<String>>;
}

注意 load 是异步的,而 set/remove 是同步的。这反映了使用模式:读缓存必须等 IO,写缓存可以先攒起来再后台落盘。

scope:缓存的分区

storage 里有个概念叫 scope。不同类型编译结果存在不同 scope 里:

text 复制代码
模块构建结果
代码生成结果
chunk 渲染结果
module graph
chunk graph

load(scope) 按 scope 读,set(scope, key, value) 按 scope 写,scopes() 返回所有可用 scope 名。

PackStorage:scope 的落地实现

crates/rspack_storage/src/pack/mod.rs 的 PackStorage 是默认实现:

rust 复制代码
pub struct PackStorage {
  pub manager: ScopeManager,
  pub updates: Mutex<ScopeUpdates>,
}

pub struct PackStorageOptions {
  pub root: PathBuf,
  pub temp_root: PathBuf,
  pub fs: Arc<dyn FileSystem>,
  pub bucket_size: usize,
  pub pack_size: usize,
  pub expire: u64,
  pub version: String,
  pub clean: bool,
  pub fresh_generation: Option<usize>,
  pub release_generation: Option<usize>,
}

bucket_size/pack_size 控制打包粒度,expire 控制过期时间,version 隔离不同 rspack 版本,fresh_generation/release_generation 用于增量缓存的多代管理。

set 不是立即写盘

PackStorage::set 只是把更新攒到内存:

rust 复制代码
fn set(&self, scope: &'static str, key: ItemKey, value: ItemValue) {
  let mut updates = self.updates.lock().expect("should get lock");
  let scope_update = updates.entry(scope).or_default();
  scope_update.insert(key, Some(value));
}

真正写盘发生在 trigger_save:

rust 复制代码
fn trigger_save(&self) -> Result<Receiver<Result<()>>> {
  self.manager.save(std::mem::take(
    &mut *self.updates.lock().expect("should get lock"),
  ))
}

trigger_save 返回 Receiver,调用方可异步等待保存完成。这就是「先攒、再写、可等待」的模式。

save_scopes:先优化,再写文件

save_scopes 展示了写盘前完整流程:

rust 复制代码
async fn save_scopes(...) -> Result<ScopeMap> {
  scopes.retain(|_, scope| scope.loaded());
  strategy.before_all(&mut scopes).await?;
  join_all(scopes.values_mut().map(|scope| strategy.optimize_scope(scope))).await?;

  let changed = join_all(scopes.values_mut().map(|scope| async move {
    let mut res = WriteScopeResult::default();
    if scope.loaded() {
      res.extend(strategy.write_packs(scope).await?);
      res.extend(strategy.write_meta(scope).await?);
      strategy.release_scope(scope).await?;
    }
    Ok(res)
  })).await?;

  strategy.write_root_meta(root_meta).await?;
  strategy.merge_changed(changed).await?;
  strategy.after_all(&mut scopes).await?;
  strategy.clean(root_meta, &scopes, root_options).await?;
  Ok(scopes.into_iter().collect())
}

流程:保留已加载 scope → before_all → 并行 optimize → 并行 write_packs/write_meta → 写 root_meta → merge → clean。整个保存过程大量用 join_all 并行处理多个 scope。

validate_scope:缓存为什么失效

validate_scope 的失效分级很清晰。缓存失效的典型场景是配置变化。测试代码里有:

rust 复制代码
assert_eq!(
  manager.load("scope1").await.unwrap_err().to_string(),
  "validate scope `scope1` failed due to `options.bucketSize` changed"
);

当 bucketSize 这类配置变化时,旧缓存被判定无效并清空。原则是:配置是缓存 key 的一部分。

Rust 角度:缓存与编译逻辑分离

这套设计最值得学习的是边界划分:

text 复制代码
rspack_cacheable:值如何序列化(rkyv + 宏 + with + dyn)
rspack_storage:字节如何组织、校验、清理(scope + pack)

编译逻辑只需标注 #[cacheable],调用 Storage::set,不关心底层文件格式。storage 也只面对 Vec<u8>,不关心字节里是什么结构体。

这就是第十一篇说的「缓存加速」与「编译正确性」分层:缓存提供透明加速,任何时候都能通过清空缓存回到全量计算。

这一篇应该带走什么

  1. rspack_cacheable 用 rkyv 把结构体序列化成字节,#[cacheable] 派发 trait;
  2. with 模块处理 rkyv 无法直接表达的类型,dyn 模块用 dyn_id + 注册表序列化 trait object;
  3. rspack_storage 用 scope + pack 组织字节,set 只攒更新,trigger_save 才异步写盘;
  4. validate_scope 分层校验 root/meta/pack,配置变化即失效;
  5. 缓存与编译逻辑分离,禁用缓存不改变构建语义。

写在最后

缓存解决了「跨进程复用」。但还有一类更基础的能力:js 侧的 externals(例如 react、lodash)如何被识别并转换成 Rust 侧的 Module。下一篇进入 rspack_plugin_externals,看 externals 如何利用 NormalModuleFactory 的 factorize Hook 直接产出一个不同的 Module。

相关推荐
ppiero1 小时前
AI 助手聊着聊着就失忆?先分清"上下文"和"记忆"(五步装长期记忆)
前端
打呵欠的猫1 小时前
用 AI 做性能分析:一行命令找出拖慢页面的 5 个组件,优化后首屏快了 40%
前端·性能优化·ai编程
用户5080070546281 小时前
芯飞云用了两个月,说说我的真实感受
前端
青烟小生1 小时前
前端性能优化实战:从指标监控到极致体验
前端
可乐ea1 小时前
2000 个智能体把 Agent 重写成 Rust:编排流水线拆解
开发语言·后端·rust·多智能体协作·ai编程助手·智能体编排·rust重写
deli0071 小时前
0.1 加 0.2 为什么不等于 0.3?IEEE 754 双精度逐位实测
前端
Momo__1 小时前
前端 Code Review 实战:识别 AI 写代码的 15 个常见坑
前端·vibecoding
拖孩2 小时前
全程 AI 写的小程序,刚接上会员虚拟支付,上线第二天就有人真金白银买单了|双签名、定价、iOS 的坑我全踩了一遍
前端·后端·微信小程序
小蚂蚁i2 小时前
React 基础
前端