WebAssembly AI 插件缓存策略:把模型权重缓存在 IndexedDB 里 WebAssembly AI 插件缓存策略把模型权重缓存在 IndexedDB 里一、每次刷新都要下载几百兆这谁顶得住最近在做一个浏览器端的 AI 插件用 Rust 编译成 WASM 在浏览器里跑推理。想法很好——用户数据不出浏览器隐私有保障。但很快就碰到了一个现实问题模型权重文件动不动就是几十兆甚至几百兆每次刷新页面都要重新下载。对于用户来说打开一个网页要等几十秒才能用这个体验基本等于劝退。解决思路其实很直接——浏览器不是有 IndexedDB 吗把模型文件缓存到 IndexedDB 里第一次加载后存起来之后直接从本地读取。但这个方案在实际落地中有不少细节需要注意WASM 怎么和 IndexedDB 打通版本更新了怎么办浏览器清理了缓存怎么处理这篇文章就聊聊我在这方面的实践。二、缓存流程设计先画一下整体的缓存流程这样在写代码之前就知道每一步要做什么flowchart TD A[用户打开页面] -- B{检查 IndexedDB} B --|缓存存在| C[读取缓存的模型版本号] C -- D{版本号与服务端一致?} D --|是| E[从 IndexedDB 加载模型到内存] E -- F[WASM 推理就绪] D --|否| G[清除旧缓存] G -- H[从 CDN 下载新模型] B --|缓存不存在| H H -- I[将模型存入 IndexedDB] I -- J[记录版本号] J -- F F -- K[用户正常使用]这个流程里有两个核心节点版本校验和存储管理。版本校验确保用户不会用到一个过时的模型存储管理确保不会无限膨胀占用磁盘空间。三、代码实现Rust 侧与 JS 侧的协作WASM 不能直接操作浏览器的 IndexedDB API所以需要 Rust 通过wasm-bindgen调用 JS 侧暴露的函数。我把缓存逻辑拆成两部分JS 侧负责 IndexedDB 的读写Rust 侧负责调用和整体流程控制。3.1 JS 侧封装 IndexedDB 操作// db_cache.js —— 封装 IndexedDB 的读写操作 const DB_NAME ai-model-cache; const DB_VERSION 1; const STORE_NAME models; /** * 打开数据库 * 如果数据库不存在会自动创建 */ function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded (event) { const db event.target.result; // 创建存储对象用模型文件 URL 作为主键 if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: url }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } /** * 将模型数据存入 IndexedDB * param {string} url - 模型文件的 URL作为唯一标识 * param {ArrayBuffer} data - 模型文件的二进制数据 * param {string} version - 模型的版本号 */ export async function storeModel(url, data, version) { const db await openDB(); const transaction db.transaction(STORE_NAME, readwrite); const store transaction.objectStore(STORE_NAME); // put 方法如果已存在则覆盖不存在则新增 store.put({ url: url, data: data, version: version, timestamp: Date.now(), // 记录存储时间方便后续清理 }); return new Promise((resolve, reject) { transaction.oncomplete () resolve(); transaction.onerror () reject(transaction.error); }); } /** * 从 IndexedDB 读取缓存的模型数据 * param {string} url - 模型文件 URL * returns {Object|null} 缓存的数据对象不存在则返回 null */ export async function loadModel(url) { const db await openDB(); const transaction db.transaction(STORE_NAME, readonly); const store transaction.objectStore(STORE_NAME); return new Promise((resolve, reject) { const request store.get(url); request.onsuccess () resolve(request.result || null); request.onerror () reject(request.error); }); } /** * 删除指定模型的缓存 */ export async function deleteModel(url) { const db await openDB(); const transaction db.transaction(STORE_NAME, readwrite); const store transaction.objectStore(STORE_NAME); store.delete(url); return new Promise((resolve, reject) { transaction.oncomplete () resolve(); transaction.onerror () reject(transaction.error); }); }3.2 Rust 侧调用 JS 函数并进行版本管理use wasm_bindgen::prelude::*; use web_sys::console; // 声明 JS 侧暴露的函数 #[wasm_bindgen(module /js/db_cache.js)] extern C { // 存储模型到 IndexedDB async fn storeModel(url: str, data: [u8], version: str); // 从 IndexedDB 加载模型 async fn loadModel(url: str) - JsValue; // 删除模型缓存 async fn deleteModel(url: str); } /// 模型加载器负责缓存管理和版本校验 pub struct ModelLoader { /// 模型文件的 CDN URL model_url: String, /// 期望的模型版本号通常从服务端 API 获取 expected_version: String, } impl ModelLoader { pub fn new(model_url: str, expected_version: str) - Self { Self { model_url: model_url.to_string(), expected_version: expected_version.to_string(), } } /// 获取模型数据优先从缓存读取 pub async fn load(self) - ResultVecu8, String { // 第一步尝试从 IndexedDB 读取缓存 let cached loadModel(self.model_url).await; // 检查缓存是否存在且版本匹配 if !cached.is_null() !cached.is_undefined() { // 从返回的 JS 对象中提取 version 字段 let cached_version js_sys::Reflect::get(cached, JsValue::from_str(version)) .map_err(|_| 无法读取缓存版本.to_string())? .as_string() .unwrap_or_default(); if cached_version self.expected_version { // 缓存命中且版本匹配直接使用 console::log_1(模型缓存命中从 IndexedDB 加载.into()); let data js_sys::Reflect::get(cached, JsValue::from_str(data)) .map_err(|_| 无法读取缓存数据.to_string())?; // 将 JS 的 ArrayBuffer 转换为 Rust 的 Vecu8 let uint8_array js_sys::Uint8Array::new(data); let mut buffer vec![0u8; uint8_array.length() as usize]; uint8_array.copy_to(mut buffer); return Ok(buffer); } else { // 版本不匹配清除旧缓存 console::log_1(模型版本已更新清除旧缓存.into()); deleteModel(self.model_url).await; } } // 第二步从 CDN 下载模型 console::log_1(从 CDN 下载模型文件....into()); let model_data self.download_model().await?; // 第三步存入 IndexedDB 作为缓存 storeModel(self.model_url, model_data, self.expected_version).await; console::log_1(模型已缓存到 IndexedDB.into()); Ok(model_data) } /// 使用浏览器的 fetch API 下载模型文件 async fn download_model(self) - ResultVecu8, String { let mut opts web_sys::RequestInit::new(); opts.method(GET); opts.mode(web_sys::RequestMode::Cors); let request web_sys::Request::new_with_str_and_init(self.model_url, opts) .map_err(|_| 创建请求失败.to_string())?; let window web_sys::window().ok_or(无法获取 window 对象)?; let resp_value wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(request)) .await .map_err(|_| 下载请求失败.to_string())?; let response: web_sys::Response resp_value .dyn_into() .map_err(|_| 类型转换失败.to_string())?; let buffer wasm_bindgen_futures::JsFuture::from( response.array_buffer().map_err(|_| 读取响应失败)?, ) .await .map_err(|_| 读取 ArrayBuffer 失败.to_string())?; let uint8_array js_sys::Uint8Array::new(buffer); let mut data vec![0u8; uint8_array.length() as usize]; uint8_array.copy_to(mut data); Ok(data) } }在实际使用时ModelLoader的调用非常简洁// 在 WASM 入口函数中使用 #[wasm_bindgen(start)] pub async fn main() - Result(), JsValue { // 从服务端获取最新的模型版本号 let current_version fetch_model_version().await?; let loader ModelLoader::new( https://cdn.example.com/models/embedding.onnx, current_version, ); match loader.load().await { Ok(model_data) { console::log_1(format!(模型加载成功大小: {} 字节, model_data.len()).into()); // 初始化推理引擎... } Err(e) { console::error_1(format!(模型加载失败: {}, e).into()); } } Ok(()) }四、边界与注意事项IndexedDB 的存储上限。不同浏览器的限制不一样但大体上可用空间在可用磁盘的 50%~60% 左右。对于几百兆的模型文件在桌面端通常没问题但在移动端需要注意。我建议在存储前调用navigator.storage.estimate()检查剩余空间。浏览器可能清理 IndexedDB。用户手动清除浏览数据、浏览器在磁盘空间不足时都可能删除 IndexedDB 中的数据。所以缓存逻辑必须假设缓存可能不存在随时准备好走下载流程。这也是为什么版本校验很重要——不能假设缓存一定是最新的。版本管理策略。我用的是一个简单的字符串版本号比如v2.3.1。更稳妥的做法是同时存储文件的 hash 值加载后用 hash 校验完整性。但如果模型文件很大计算 hash 本身也有开销需要权衡。多次打开页面的并发问题。如果用户快速打开多个标签页每个标签页可能同时尝试下载模型。可以通过一个共享的 Service Worker 或者 BroadcastChannel API 来协调让其他标签页等待第一个标签页的下载完成。IndexedDB 的写入也有性能细节。模型文件几百兆时一次性put可能让浏览器 UI 卡顿。可以分块存储每块 10-20MB写入后记录分块元信息。读取时按块拼接还能做断点续传和部分校验。存储不是一锤子买卖。五、总结把模型权重缓存到 IndexedDB 这个方案实际落地后效果相当明显——首次加载需要十几秒取决于网速但之后刷新页面几乎是秒开。对于那些需要频繁打开关闭的浏览器端 AI 应用这个优化是刚需。Rust WASM 的组合在处理这类场景时有个优势Rust 侧可以保持清晰的业务逻辑不需要在 JS 里写太多状态管理代码。wasm-bindgen把两边的桥接做得足够顺滑。当然这个方案也有它的局限性。IndexedDB 的异步 API 决定了模型加载过程不能是同步的所以你需要在应用初始化阶段就触发加载而不是等用户操作时才临时去读缓存。做好预加载体验就不错。