重建结果缓存策略LRU 与按场景分组商品列表有 50 个 3DGS 场景用户翻来翻去看。第一次进商品详情要重建或下载预重建结果耗时 600-1200 ms。第二次进同一个商品如果还要重建用户会觉得我都看过了怎么还等。我们一开始没做缓存用户投诉慢。后来加了缓存又遇到新问题——LRU 把热门商品淘汰了用户翻一圈回来发现又要等。这篇文章记录我们试过的几种缓存策略以及最后选的混合方案。能力面四种缓存策略对比重建结果缓存要回答三个问题缓存哪些、淘汰哪些、缓存多大。我们试了四种策略。策略淘汰规则优点缺点LRU最近最少使用先淘汰实现简单通用热门商品可能被冷门挤掉LFU最少使用频率先淘汰保留高频商品长期偏置新商品难进缓存按场景分组同组一起淘汰业务语义清晰粒度太粗组内冷热不均按用户分组每用户独立缓存个性化命中率高存储成本翻倍LRU 是最常用的但在商品浏览场景有坑。用户翻列表时按顺序点开 20 个商品每个看 3 秒。LRU 会把最先看的 10 个淘汰掉最近最少使用。但用户翻完一圈往往会回到前几个做对比这时前几个已被淘汰又要等重建。LFU 解决了热门被淘汰的问题但有长期偏置。一个商品上周热门这周没人看LFU 还把它留着历史频率高新热门商品进不来。按场景分组比如陶瓷类一组、家具类一组语义清晰但粒度太粗——陶瓷类 20 个商品里只有 3 个热门淘汰时整组一起淘汰3 个热门也跟着走。按用户分组每个用户独立缓存命中率最高但 50 个用户就是 50 倍存储扛不住。约束面缓存空间预算缓存策略要在命中率和存储成本之间权衡。先看空间预算。缓存策略单用户缓存上限50 用户总占用命中率实现复杂度全局 LRU200 MB200 MB62%低全局 LFU200 MB200 MB68%中按用户 LRU50 MB/人2500 MB81%中按场景分组100 MB/组500 MB58%低混合策略200 MB 全局 30 MB/人1700 MB84%高按用户 LRU 命中率最高81%但存储成本 2.5 GB对端侧不可接受。全局 LRU 存储省但命中率只 62%。混合策略命中率 84% 且存储 1.7 GB是我们最后选的方案。混合策略的思路是全局缓存热门商品按 LFU 选 Top 20每用户再缓存自己看过的非热门商品。热门商品所有用户共享非热门商品按用户缓存。这样热门命中走全局非热门命中走用户私有命中率叠加。场景落地50 个 3DGS 场景的缓存我们用 50 个商品 3DGS 场景做了缓存实验模拟 30 个用户的浏览行为。每个场景预重建结果 8-25 MB平均 15 MB。真机数据各策略命中率与加载耗时策略缓存命中缓存未命中平均加载耗时存储占用无缓存0%100%820 ms0 MB全局 LRU 200MB62%38%380 ms198 MB全局 LFU 200MB68%32%320 ms201 MB按用户 LRU 50MB81%19%220 ms1480 MB按场景分组 100MB58%42%410 ms498 MB混合策略84%16%195 ms1680 MB混合策略平均加载耗时 195 ms比无缓存快 4 倍。但存储占用 1.7 GB对 8 GB 设备压力大。我们做了存储预算可调低端设备存储 32 GB退到全局 LRU高端设备用混合策略。真机数据缓存淘汰对热门商品的影响这是 LRU 的核心坑。我们模拟用户浏览行为先看商品 1-20再回头看商品 1-5 做对比。策略商品 1-5 第二次访问命中率原因全局 LRU 200MB0%翻 20 个后被淘汰全局 LFU 200MB100%频率高不被淘汰按用户 LRU 50MB60%用户缓存小部分被淘汰混合策略100%热门走全局 LFU不被淘汰LRU 在翻一圈回来场景下命中率 0%这是它的硬伤。用户翻 20 个商品LRU 缓存 13 个200MB / 15MB翻到第 14 个时第一个被淘汰翻完 20 个后商品 1-7 都被淘汰了。用户回头对比又要重建体验崩。LFU 在这个场景好但 LFU 的频率统计要持久化应用重启后清零就退化成 LRU。我们用preferences持久化频率统计但preferences写入有开销每次访问商品都要写一次频率写入耗时 8-15 ms累积起来不可忽略。踩坑与取舍坑一LRU 把热门商品缓存淘汰了这是 LRU 在浏览场景的通病。我们的解法是全局缓存用 LFU 不用 LRU。LFU 按历史访问频率排序热门商品频率高不被淘汰。但 LFU 有个新坑新商品进缓存时频率为 0立刻被淘汰。一个新上架商品第一次被看频率 0LFU 觉得它最不常用直接淘汰。我们的处理是给新商品一个初始频率保底——进缓存时频率设为当前 Top 20 的中位数保证它至少能留一阵子。这个 hack 不优雅但解决了新商品难进缓存的问题。坑二按场景分组缓存粒度太粗试过按场景分组陶瓷、家具、玩具等淘汰时整组淘汰。问题是组内冷热不均——陶瓷组 20 个商品里只有 3 个热门陶瓷组缓存满了要淘汰时整组淘汰把 3 个热门也干掉了。按场景分组适合组内访问一致的场景比如用户进陶瓷组就把陶瓷组全看一遍。但实际用户是跨组浏览先看陶瓷 1再看家具 2再看陶瓷 3按组淘汰和用户行为不匹配。最后我们放弃了纯按场景分组但保留了场景作为缓存优先级的辅助维度——同场景的商品在淘汰时给一点优先级加权不整组淘汰但同组内倾向保留。坑三缓存一致性——重建结果更新了怎么办商品 3DGS 重建结果会更新比如重新采集了更好的版本。缓存里存的是旧版本用户看到的是旧结果。我们一开始没处理这个用户反馈我看到的和别人不一样。解法是缓存 key 带版本号。商品重建结果有版本号缓存 key 是skuId version。版本号变了缓存自动 miss拉新版本。版本号从服务端商品信息接口拿。// entry/src/main/ets/cache/CacheKey.etsexportclassCacheKey{// 缓存 key 由 skuId 和版本号组成版本变则缓存失效staticbuild(skuId:string,version:number):string{returngs:${skuId}:v${version};}}但有个边界用户离线时拿不到版本号。我们的处理是离线时用本地缓存的版本号不检查更新。在线时才比对版本号触发更新。这意味着离线用户可能看旧版本但至少有得看比强校验导致缓存全失效好。坑四被放弃的方案——按用户分组缓存按用户分组每个用户独立缓存命中率最高81%。但 30 个用户就是 30 套缓存存储成本 2.5 GB端侧扛不住。我们试过给每用户缓存压缩重建结果用 Draco 压缩压缩比 3:130 用户 833 MB。但解压耗时 80-120 ms命中后还要解压加载耗时反而比全局 LRU 还慢。这个方案被放弃了。按用户缓存适合服务端端侧存储和算力都不够。混合策略里的每用户 30 MB是折中只缓存用户私有非热门商品不缓存所有商品。混合策略的工程实现下面是混合策略的完整实现全局 LFU 按用户 LRU。// entry/src/main/ets/cache/ReconCache.etsimport{preferences}fromkit.ArkData;import{fileIo}fromkit.FileKit;interfaceCacheEntry{key:string;// skuId:versionsize:number;// 文件大小 byteslastAccess:number;// 时间戳accessCount:number;// 访问次数LFU 用filePath:string;// 缓存文件路径}exportclassReconCache{privatestaticreadonlyGLOBAL_LIMIT:number200*1024*1024;// 200 MBprivatestaticreadonlyUSER_LIMIT:number30*1024*1024;// 30 MBprivatestaticreadonlyHOT_THRESHOLD:number5;// 访问 5 次以上算热门// 全局缓存索引热门商品LFU持久化在 preferencesprivatestaticglobalIndex:Mapstring,CacheEntrynewMap();// 用户私有缓存索引非热门LRU内存中维护privatestaticuserIndex:Mapstring,CacheEntrynewMap();staticasyncget(skuId:string,version:number):Promisestring|null{constkey:stringCacheKey.build(skuId,version);// 1. 先查全局缓存热门constglobalEntrythis.globalIndex.get(key);if(globalEntry){globalEntry.lastAccessDate.now();globalEntry.accessCount;awaitthis.persistGlobalIndex();returnglobalEntry.filePath;}// 2. 再查用户缓存非热门constuserEntrythis.userIndex.get(key);if(userEntry){userEntry.lastAccessDate.now();userEntry.accessCount;// 访问次数超过阈值晋升到全局缓存if(userEntry.accessCountthis.HOT_THRESHOLD){awaitthis.promoteToGlobal(userEntry);this.userIndex.delete(key);}returnuserEntry.filePath;}// 3. 都没命中返回 null调用方触发重建或下载returnnull;}staticasyncput(skuId:string,version:number,filePath:string,size:number):Promisevoid{constkey:stringCacheKey.build(skuId,version);constentry:CacheEntry{key,size,lastAccess:Date.now(),accessCount:1,filePath};// 新商品先进用户缓存访问次数够了再晋升全局this.userIndex.set(key,entry);awaitthis.evictUserIfNeeded();}// 用户缓存超限时按 LRU 淘汰privatestaticasyncevictUserIfNeeded():Promisevoid{lettotalSize:number0;for(consteofthis.userIndex.values()){totalSizee.size;}while(totalSizethis.USER_LIMITthis.userIndex.size0){// 找 lastAccess 最小的淘汰letoldestKey:string|nullnull;letoldestTime:numberInfinity;for(const[k,e]ofthis.userIndex){if(e.lastAccessoldestTime){oldestTimee.lastAccess;oldestKeyk;}}if(oldestKey){constevictedthis.userIndex.get(oldestKey)!;awaitfileIo.unlinkSync(evicted.filePath);this.userIndex.delete(oldestKey);totalSize-evicted.size;}}}// 全局缓存超限时按 LFU 淘汰访问次数最少先淘汰privatestaticasyncevictGlobalIfNeeded():Promisevoid{lettotalSize:number0;for(consteofthis.globalIndex.values()){totalSizee.size;}while(totalSizethis.GLOBAL_LIMITthis.globalIndex.size0){// 找 accessCount 最小的淘汰letleastKey:string|nullnull;letleastCount:numberInfinity;for(const[k,e]ofthis.globalIndex){if(e.accessCountleastCount){leastCounte.accessCount;leastKeyk;}}if(leastKey){constevictedthis.globalIndex.get(leastKey)!;awaitfileIo.unlinkSync(evicted.filePath);this.globalIndex.delete(leastKey);totalSize-evicted.size;}}}// 用户缓存中的商品晋升到全局缓存privatestaticasyncpromoteToGlobal(entry:CacheEntry):Promisevoid{this.globalIndex.set(entry.key,entry);awaitthis.evictGlobalIfNeeded();awaitthis.persistGlobalIndex();}// 全局索引持久化应用重启后热门商品还在privatestaticasyncpersistGlobalIndex():Promisevoid{constprefsawaitpreferences.getPreferences(getContext(),recon_cache);constjsonJSON.stringify([...this.globalIndex.entries()]);awaitprefs.put(globalIndex,json);awaitprefs.flush();}}实现里有几个关键点晋升机制——商品在用户缓存里访问次数超过 5 次晋升到全局缓存。这样新商品先进用户缓存被多次访问后变热门进全局避免新商品直接进全局被 LFU 淘汰。全局索引持久化——LFU 的频率统计要跨重启保留否则重启后清零退化成 LRU。用preferences持久化每次访问更新频率后写一次。写入耗时 8-15 ms我们做了防抖——1 秒内多次访问只写一次减少写入开销。淘汰策略分缓存——用户缓存用 LRU个人浏览顺序有意义全局缓存用 LFU热门商品频率高保留。两个策略各管一半互不干扰。缓存的读写路径可以画成一条判定链。命中、晋升、淘汰三个动作都在这条链上。命中未命中命中达到未达到未命中超容量未超请求 skuId 与 version拼出缓存 key全局缓存是否命中更新访问次数后直接返回用户缓存是否命中访问次数是否达到热门阈值晋升到全局缓存返回用户缓存文件触发重建或下载写入用户缓存缓存是否超容量用户缓存按 LRU 淘汰 全局缓存按 LFU 淘汰结束图里全局用 LFU、用户用 LRU两套淘汰策略各管一半这是混合方案的关键。缓存策略选择决策不同设备不同策略按存储空间自动选。// entry/src/main/ets/cache/CacheStrategySelector.etsexportclassCacheStrategySelector{staticselect():CacheStrategy{constfreeBytes:numbergetFreeStorage();consttotalBytes:numbergetTotalStorage();// 存储 32 GB纯全局 LRU省空间if(totalBytes32*1024*1024*1024){returnCacheStrategy.GLOBAL_LRU;}// 剩余 2 GB纯全局 LRU避免占满if(freeBytes2*1024*1024*1024){returnCacheStrategy.GLOBAL_LRU;}// 高端设备混合策略returnCacheStrategy.MIXED;}}低端设备退到全局 LRU命中率低但存储省。高端设备用混合策略命中率高。策略选择在应用启动时做一次运行期不变。注意一下LRU 在翻一圈回来场景命中率 0%浏览类应用不要用纯 LRU热门商品会被淘汰LFU 要持久化频率统计否则重启后清零退化成 LRU用 preferences 持久化并防抖新商品进 LFU 缓存频率为 0 会被立刻淘汰给初始频率保底或先进 LRU 再晋升按场景分组粒度太粗组内冷热不均时整组淘汰损失热门只适合组内访问一致场景按用户分组存储成本翻倍端侧扛不住适合服务端端侧用混合策略折中缓存 key 要带版本号重建结果更新时缓存自动失效否则用户看到旧版本离线时不校验版本号用本地缓存版本避免强校验导致缓存全失效缓存策略按设备存储自动选低端退全局 LRU高端用混合启动时选一次。