12 万条消息撑爆 chrome.storage.local:浏览器扩展的本地存储到底该选谁
Unchecked runtime.lastError: QUOTA_BYTES quota exceeded
一个负责聊天记录本地留存的浏览器扩展,跑到第 11 周吐出这行报错,之后再没成功写进去过一条新消息。糟糕的地方在于用户毫无察觉——界面照常,旧记录还在,只有新数据在静默丢失。
一家家政公司是怎么把存储层压垮的
吉隆坡有家做居家保洁的小公司,26 名保洁员,480 户长期客户,日常两小时上门 RM 130,深度清洁 RM 380。所有排班、门禁说明、验收照片都跑在 WhatsApp 上:6 个作业群按片区分,加上跟客户的一对一单聊。
老板 Wendy 一年下来积了差不多 12 万条消息,其中 3.4 万条带图片。这些消息不是聊天记录,是她的生产资料。
她被三件事反复折磨。一是保洁员离职就等于客户流失——阿姨用自己的号跟熟客沟通,人一走,那户人家下次预约打给谁全看运气。二是客诉举证——客户投诉「浴室镜子没擦」,Wendy 需要翻出 3 个月前的验收照片和当时那句「镜柜内侧本次不含」,在群聊里往上滚 40 分钟也未必找得到。三是返单提醒,每月哪 60 户该做定期清洁,全靠她本人记。
这类商户的共同点很明确:数据量不大不小,卡在最难受的位置。几百户客户、十几万条消息、几万张图片,上一套字段严整的排班系统嫌重,纯靠人肉又已经超出记忆力上限。
商户实际怎么把数据攥回自己手里
Wendy 的解法分三层。
跟客户的单聊按客户名归档,导出成带原生气泡排版的 HTML,验收照片和文字说明保持在同一份文件里,客诉时直接甩整段上下文;6 个作业群的成员名单定期导成 Excel,保洁员流动的时候能立刻看出哪些熟客号码只存在于某个人的手机里;每月返单名单从最近 90 天的活跃对话里筛出来,按最末一次服务日期排序。
她用的是 WAExport 这类纯客户端运行的浏览器扩展,数据全程留在本机,不经过任何云端中转。
关键变化不是「能导出」,而是「持续留存」。一次性导出解决的是当下这份文件,Wendy 需要的是后台静默跟进——新消息进来就落到本地,随时可以按客户、按日期、按群组切一份出来。
而这个「持续」,恰恰是把存储层压垮的那件事。
chrome.storage.local 的天花板在哪
MV3 扩展开发者的第一反应几乎都是chrome.storage.local。API 简单,Service Worker 里能直接调,配置和数据一把梭。
它的容量上限写在常量里:chrome.storage.local.QUOTA_BYTES从 Chrome 114 起是 10,485,760 字节,也就是 10 MB(更早的版本是 5 MB)。作为对比,chrome.storage.sync只有 102,400 字节,单条 8,192 字节,那是给用户偏好设置准备的,跟消息存档没有关系。
12 万条消息,平均每条含发送方、时间戳、正文、消息类型、引用关系,JSON 序列化后单条 300 到 600 字节,总量落在 45 MB 到 70 MB 之间。10 MB 的天花板在第 11 周被撞穿,一点都不意外。
manifest 里加上unlimitedStorage权限确实能解除这个硬顶,但容量问题解除的同时,性能问题会立刻接管。
先把家底摸清楚,两个 API 要一起看:
// storage-probe.js —— 落盘前先探清两套存储的真实余量exportasyncfunctionprobeStorage(){// 1) chrome.storage.local 的已用量(字节)。传 null 表示统计全部 keyconstusedBytes=awaitchrome.storage.local.getBytesInUse(null);// QUOTA_BYTES 在声明 unlimitedStorage 后依然返回 10MB 这个名义值,// 真实上限由浏览器配额系统接管,所以它只能当"是否接近默认档"的参考constnominalQuota=chrome.storage.local.QUOTA_BYTES;// 10485760// 2) IndexedDB 走的是 Storage Quota 体系,用 StorageManager 估算letquota=0,usage=0,persisted=false;if(navigator.storage?.estimate){({quota=0,usage=0}=awaitnavigator.storage.estimate());}if(navigator.storage?.persisted){persisted=awaitnavigator.storage.persisted();}return{kvUsedMB:+(usedBytes/1048576).toFixed(2),kvNominalMB:+(nominalQuota/1048576).toFixed(2),idbUsageMB:+(usage/1048576).toFixed(2),idbQuotaMB:+(quota/1048576).toFixed(2),// 通常是磁盘可用空间的一大部分persisted,// 是否已置为持久化、免于配额压力驱逐};}navigator.storage.estimate()返回的quota在桌面 Chrome 上往往是几十甚至上百 GB 量级,因为它按磁盘剩余空间的比例分配。两套存储根本不在一个数量级上,这是选型的起点,但远不是全部。
真正的杀手是写放大
假设不管容量,硬把消息数组塞进chrome.storage.local,代码大概长这样:
// ❌ 反面教材:每来一条新消息,重写整个数组asyncfunctionappendMessageBad(msg){const{messages=[]}=awaitchrome.storage.local.get('messages');messages.push(msg);awaitchrome.storage.local.set({messages});// 整个数组重新序列化 + 落盘}这段代码的问题不在语法,在复杂度。chrome.storage的值是按 key 整体读写的,没有部分更新这回事。已经存了 5 万条,第 50,001 条进来时,运行时要做的事情是:把 5 万条从磁盘读出来 → 反序列化成 JS 对象 → push 一条 → 重新序列化 → 整块写回。
单条插入的成本是 O(n),n 条消息全量导入的总成本是O(n²)。
还有一层容易被忽略的开销:chrome.storage的每次调用都要跨进程。扩展页面或 Service Worker 发起请求,经 IPC 送到浏览器进程,由那边完成序列化和落盘再回传。一次 6 MB 的set(),等于一次 6 MB 的跨进程消息传递。在一台 i5 级别的笔记本上,这一次调用的耗时在几百毫秒量级,而且随数据量线性上升。
序列化方式也有差别。chrome.storage走的是JSON 序列化,Date会变成字符串,Map、Set、ArrayBuffer、Blob一律存不进去;IndexedDB 用的是结构化克隆算法,上面这些类型原样保留,图片缩略图可以直接以Blob落库,不必先转成体积膨胀 33% 的 base64。
同样的追加操作,换成 IndexedDB 是这样:
// ✅ 正确姿势:主键定位,单条写入,与库内总量无关constDB_NAME='chat-archive';constDB_VERSION=1;functionopenDB(){returnnewPromise((resolve,reject)=>{constreq=indexedDB.open(DB_NAME,DB_VERSION);req.onupgradeneeded=(e)=>{constdb=e.target.result;if(!db.objectStoreNames.contains('messages')){// 用消息自身的稳定 ID 作主键,天然幂等:重复导入同一条会覆盖而非追加conststore=db.createObjectStore('messages',{keyPath:'id'});// 复合索引:按会话 + 时间戳查询,是导出场景里 90% 的访问模式store.createIndex('by_chat_ts',['chatId','ts'],{unique:false});// 单列索引:全局按时间范围切片(跨会话的日期筛选)store.createIndex('by_ts','ts',{unique:false});// 未保存联系人的号码索引,用于跨会话去重合并store.createIndex('by_phone','phone',{unique:false});}if(!db.objectStoreNames.contains('media')){// 缩略图单独一张表:Blob 体积大,跟消息正文放一起会拖慢正文的游标遍历db.createObjectStore('media',{keyPath:'msgId'});}};req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});}把复合索引['chatId', 'ts']建对,是整个存储层最值钱的一行代码。「导出张女士家 3 月 1 日到 3 月 31 日的全部消息」这种查询,靠它可以直接用IDBKeyRange.bound(['chat_88', t0], ['chat_88', t1])定位到一段连续区间,不需要扫全表。放在 KV 存储里,同样的需求只能把 12 万条全读进内存再 filter。
批量写入:把事务当成批处理单元
拿到全量历史时,逐条put再逐条await是另一个常见陷阱:
// 批量落库:一个事务塞满一批,事务本身就是最好的批处理边界asyncfunctionbulkPut(db,storeName,records,batchSize=1000){for(leti=0;i<records.length;i+=batchSize){constchunk=records.slice(i,i+batchSize);awaitnewPromise((resolve,reject)=>{consttx=db.transaction(storeName,'readwrite');conststore=tx.objectStore(storeName);// 关键:不要 await 每个 put。IDBRequest 是排队执行的,// 只等最终的 tx.oncomplete,一批 1000 条共享一次事务提交开销for(constrecofchunk)store.put(rec);tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error||newError('transaction aborted'));});// 让出主线程,避免长任务把 UI 卡死(大批量导入时肉眼可见)awaitnewPromise((r)=>setTimeout(r,0));}}这里有条 IndexedDB 的硬规则值得单独拎出来:事务在微任务队列排空的那一刻自动提交。在事务内部await一个非 IDB 的 Promise(比如fetch、chrome.storage.local.get),事务会在你回来之前就已经关掉,紧接着抛TransactionInactiveError。写批量导入时踩这个坑的概率极高。
10,000 条消息的落库耗时,事务批处理相比逐条提交能压掉一个数量级;而同样这批数据走chrome.storage.local的全量重写路径,耗时随批次线性膨胀,两者不具备可比性。
导出时的内存控制:游标而不是 getAll
数据进去了,导出又是一道坎。store.getAll()一次把 12 万条对象全部实体化到内存,配上 3.4 万个缩略图 Blob,标签页崩溃只是时间问题。
正确做法是用游标边读边写:
// 按会话 + 时间范围流式遍历,每 500 条回调一次,内存占用恒定functionstreamByChatRange(db,chatId,startTs,endTs,onBatch){returnnewPromise((resolve,reject)=>{consttx=db.transaction('messages','readonly');constidx=tx.objectStore('messages').index('by_chat_ts');// 复合索引的区间查询:锁定单个会话内的一段连续时间constrange=IDBKeyRange.bound([chatId,startTs],[chatId,endTs]);letbuffer=[];lettotal=0;idx.openCursor(range).onsuccess=(e)=>{constcursor=e.target.result;if(!cursor){if(buffer.length)onBatch(buffer);return;// 游标走完,等 tx.oncomplete 收尾}buffer.push(cursor.value);total++;if(buffer.length>=500){onBatch(buffer);buffer=[];// 立刻释放引用,让 GC 能回收这一批}cursor.continue();};tx.oncomplete=()=>resolve(total);tx.onerror=()=>reject(tx.error);});}游标模式下,内存占用只跟批次大小有关,跟库里存了多少条无关。500 条一批,峰值内存稳定在几 MB,导出 12 万条和导出 1 万条的内存曲线几乎重合。配合TransformStream或者分片Blob,能一路流到磁盘而不在中途攒出一个巨型字符串。
分层才是答案:两套存储各干各的
选型的结论不是二选一,而是按数据特征分层。
chrome.storage.local留给小而关键的状态:同步水位线、用户配置、导出任务进度、上次成功落库的消息 ID。这类数据的共同特点是体积小(几 KB)、更新频繁、需要在 Service Worker 里随手读写,而且**chrome.storage.onChanged天然是跨上下文广播的**——popup、options 页、content script、Service Worker 同时收到变更通知,这套机制 IndexedDB 没有对应物。
IndexedDB 承载主体数据:消息、联系人、群成员、媒体缩略图。大体量、需要索引、需要范围查询、需要流式读取。
// 分层落地:主体数据入 IDB,水位线入 KV,两者用同一个逻辑事务边界串起来constCKPT_KEY='sync_checkpoint';asyncfunctionsyncBatch(db,chatId,newMessages){if(!newMessages.length)return;// 1) 主体数据落 IndexedDB(幂等:主键相同即覆盖,断点续传天然安全)awaitbulkPut(db,'messages',newMessages);// 2) 水位线落 chrome.storage.local,体积恒定在几百字节const{[CKPT_KEY]:ckpt={}}=awaitchrome.storage.local.get(CKPT_KEY);constlatest=newMessages[newMessages.length-1];ckpt[chatId]={lastId:latest.id,lastTs:latest.ts,updatedAt:Date.now()};awaitchrome.storage.local.set({[CKPT_KEY]:ckpt});}// 任意上下文都能监听到水位线变化,用来驱动 UI 进度条chrome.storage.onChanged.addListener((changes,area)=>{if(area==='local'&&changes[CKPT_KEY]){// changes[CKPT_KEY].newValue 即最新的各会话进度renderSyncProgress(changes[CKPT_KEY].newValue);}});顺序不能反。先写 IndexedDB,再更新水位线——中途崩溃的话,水位线偏旧只会导致下一轮重复拉取同一批消息,而主键幂等保证重复put不产生脏数据;反过来先更新水位线,崩溃就等于永久丢数据。
边界与取舍
这套方案有几个必须说清楚的限制。
MV3 的 Service Worker 会在空闲 30 秒左右被回收,长时间的 IndexedDB 事务扛不住这个生命周期。稳妥做法是把落库放在有页面上下文的地方(WhatsApp Web 页面里的 content script),Service Worker 只做调度和状态管理。
IndexedDB 在磁盘压力下存在被驱逐的可能。扩展在 manifest 里声明unlimitedStorage后,自身 origin 的存储会被豁免,但这依赖用户授权该权限,不是无条件成立的。写代码时仍应假设「数据可能不在了」,导出关键记录时给用户一条明确的落盘路径,而不是把浏览器当唯一保险柜。
存储层再稳,也改变不了这类扩展的运行前提:它依赖 WhatsApp Web 网页版环境,脱离浏览器的纯移动端场景不在能力范围内。数据提取采用模拟原生行为、不调用异常 API 的方式,这降低的是自动化操作触发风控的概率,跟「怎么发消息、发什么内容」是两码事,账号安全不能只押在工具上。
顺带说一句号码相关的能力边界:这类工具内置的号码检测仅返回有效或无效两种状态,不做更进一步的信息查询。用它做名单初筛够用,别当成客户资质核验。
收个尾
存储选型的判断标准只有一条:这份数据要不要被索引、被范围查询、被流式读取。
要,就进 IndexedDB,代价是 schema 设计和事务生命周期的心智负担;不要,只是几 KB 的状态位,就用chrome.storage.local,享受它跨上下文广播的便利。混着用不是妥协,是正解。
现在去翻一下你手上扩展的代码,全局搜chrome.storage.local.set。凡是往里塞数组的地方,都是潜在的 O(n²) 写放大点——把它们挑出来,一个一个搬进 IndexedDB。
