HarmonyOS 7重建结果缓存策略:LRU 与按场景分组
重建结果缓存策略:LRU 与按场景分组
商品列表有 50 个 3DGS 场景,用户翻来翻去看。第一次进商品详情要重建(或下载预重建结果),耗时 600-1200 ms。第二次进同一个商品如果还要重建,用户会觉得"我都看过了怎么还等"。我们一开始没做缓存,用户投诉慢。后来加了缓存,又遇到新问题——LRU 把热门商品淘汰了,用户翻一圈回来发现又要等。这篇文章记录我们试过的几种缓存策略,以及最后选的混合方案。
能力面:四种缓存策略对比
重建结果缓存要回答三个问题:缓存哪些、淘汰哪些、缓存多大。我们试了四种策略。
| 策略 | 淘汰规则 | 优点 | 缺点 |
|---|---|---|---|
| LRU | 最近最少使用先淘汰 | 实现简单,通用 | 热门商品可能被冷门挤掉 |
| LFU | 最少使用频率先淘汰 | 保留高频商品 | 长期偏置,新商品难进缓存 |
| 按场景分组 | 同组一起淘汰 | 业务语义清晰 | 粒度太粗,组内冷热不均 |
| 按用户分组 | 每用户独立缓存 | 个性化命中率高 | 存储成本翻倍 |
LRU 是最常用的,但在商品浏览场景有坑。用户翻列表时按顺序点开 20 个商品,每个看 3 秒。LRU 会把最先看的 10 个淘汰掉(最近最少使用)。但用户翻完一圈往往会回到前几个做对比,这时前几个已被淘汰,又要等重建。
LFU 解决了热门被淘汰的问题,但有长期偏置。一个商品上周热门,这周没人看,LFU 还把它留着(历史频率高),新热门商品进不来。
按场景分组(比如"陶瓷类"一组、"家具类"一组)语义清晰,但粒度太粗——陶瓷类 20 个商品里只有 3 个热门,淘汰时整组一起淘汰,3 个热门也跟着走。
按用户分组每个用户独立缓存,命中率最高,但 50 个用户就是 50 倍存储,扛不住。
约束面:缓存空间预算
缓存策略要在命中率和存储成本之间权衡。先看空间预算。
| 缓存策略 | 单用户缓存上限 | 50 用户总占用 | 命中率 | 实现复杂度 |
|---|---|---|---|---|
| 全局 LRU | 200 MB | 200 MB | 62% | 低 |
| 全局 LFU | 200 MB | 200 MB | 68% | 中 |
| 按用户 LRU | 50 MB/人 | 2500 MB | 81% | 中 |
| 按场景分组 | 100 MB/组 | 500 MB | 58% | 低 |
| 混合策略 | 200 MB 全局 + 30 MB/人 | 1700 MB | 84% | 高 |
按用户 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 ms | 0 MB |
| 全局 LRU 200MB | 62% | 38% | 380 ms | 198 MB |
| 全局 LFU 200MB | 68% | 32% | 320 ms | 201 MB |
| 按用户 LRU 50MB | 81% | 19% | 220 ms | 1480 MB |
| 按场景分组 100MB | 58% | 42% | 410 ms | 498 MB |
| 混合策略 | 84% | 16% | 195 ms | 1680 MB |
混合策略平均加载耗时 195 ms,比无缓存快 4 倍。但存储占用 1.7 GB,对 8 GB 设备压力大。我们做了存储预算可调,低端设备(存储 < 32 GB)退到全局 LRU,高端设备用混合策略。
真机数据:缓存淘汰对热门商品的影响
这是 LRU 的核心坑。我们模拟用户浏览行为:先看商品 1-20,再回头看商品 1-5 做对比。
| 策略 | 商品 1-5 第二次访问命中率 | 原因 |
|---|---|---|
| 全局 LRU 200MB | 0% | 翻 20 个后被淘汰 |
| 全局 LFU 200MB | 100% | 频率高不被淘汰 |
| 按用户 LRU 50MB | 60% | 用户缓存小,部分被淘汰 |
| 混合策略 | 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,立刻被淘汰。一个新上架商品第一次被看,频率 0,LFU 觉得它"最不常用"直接淘汰。我们的处理是给新商品一个初始频率保底——进缓存时频率设为当前 Top 20 的中位数,保证它至少能留一阵子。这个 hack 不优雅,但解决了新商品难进缓存的问题。
坑二:按场景分组缓存粒度太粗
试过按场景分组(陶瓷、家具、玩具等),淘汰时整组淘汰。问题是组内冷热不均——陶瓷组 20 个商品里只有 3 个热门,陶瓷组缓存满了要淘汰时,整组淘汰把 3 个热门也干掉了。
按场景分组适合"组内访问一致"的场景,比如用户进陶瓷组就把陶瓷组全看一遍。但实际用户是跨组浏览,先看陶瓷 1,再看家具 2,再看陶瓷 3,按组淘汰和用户行为不匹配。
最后我们放弃了纯按场景分组,但保留了场景作为缓存优先级的辅助维度——同场景的商品在淘汰时给一点优先级加权,不整组淘汰但同组内倾向保留。
坑三:缓存一致性——重建结果更新了怎么办
商品 3DGS 重建结果会更新(比如重新采集了更好的版本)。缓存里存的是旧版本,用户看到的是旧结果。我们一开始没处理这个,用户反馈"我看到的和别人不一样"。
解法是缓存 key 带版本号。商品重建结果有版本号,缓存 key 是 skuId + version。版本号变了缓存自动 miss,拉新版本。版本号从服务端商品信息接口拿。
// entry/src/main/ets/cache/CacheKey.ets
export class CacheKey {
// 缓存 key 由 skuId 和版本号组成,版本变则缓存失效
static build(skuId: string, version: number): string {
return `gs:${skuId}:v${version}`;
}
}
但有个边界:用户离线时拿不到版本号。我们的处理是离线时用本地缓存的版本号,不检查更新。在线时才比对版本号触发更新。这意味着离线用户可能看旧版本,但至少有得看,比强校验导致缓存全失效好。
坑四:被放弃的方案——按用户分组缓存
按用户分组每个用户独立缓存,命中率最高(81%)。但 30 个用户就是 30 套缓存,存储成本 2.5 GB,端侧扛不住。
我们试过给每用户缓存压缩(重建结果用 Draco 压缩),压缩比 3:1,30 用户 833 MB。但解压耗时 80-120 ms,命中后还要解压,加载耗时反而比全局 LRU 还慢。
这个方案被放弃了。按用户缓存适合服务端,端侧存储和算力都不够。混合策略里的"每用户 30 MB"是折中,只缓存用户私有非热门商品,不缓存所有商品。
混合策略的工程实现
下面是混合策略的完整实现,全局 LFU + 按用户 LRU。
// entry/src/main/ets/cache/ReconCache.ets
import { preferences } from '@kit.ArkData';
import { fileIo } from '@kit.FileKit';
interface CacheEntry {
key: string; // skuId:version
size: number; // 文件大小 bytes
lastAccess: number; // 时间戳
accessCount: number; // 访问次数,LFU 用
filePath: string; // 缓存文件路径
}
export class ReconCache {
private static readonly GLOBAL_LIMIT: number = 200 * 1024 * 1024; // 200 MB
private static readonly USER_LIMIT: number = 30 * 1024 * 1024; // 30 MB
private static readonly HOT_THRESHOLD: number = 5; // 访问 5 次以上算热门
// 全局缓存索引(热门商品,LFU),持久化在 preferences
private static globalIndex: Map<string, CacheEntry> = new Map();
// 用户私有缓存索引(非热门,LRU),内存中维护
private static userIndex: Map<string, CacheEntry> = new Map();
static async get(skuId: string, version: number): Promise<string | null> {
const key: string = CacheKey.build(skuId, version);
// 1. 先查全局缓存(热门)
const globalEntry = this.globalIndex.get(key);
if (globalEntry) {
globalEntry.lastAccess = Date.now();
globalEntry.accessCount++;
await this.persistGlobalIndex();
return globalEntry.filePath;
}
// 2. 再查用户缓存(非热门)
const userEntry = this.userIndex.get(key);
if (userEntry) {
userEntry.lastAccess = Date.now();
userEntry.accessCount++;
// 访问次数超过阈值,晋升到全局缓存
if (userEntry.accessCount >= this.HOT_THRESHOLD) {
await this.promoteToGlobal(userEntry);
this.userIndex.delete(key);
}
return userEntry.filePath;
}
// 3. 都没命中,返回 null,调用方触发重建或下载
return null;
}
static async put(skuId: string, version: number, filePath: string,
size: number): Promise<void> {
const key: string = CacheKey.build(skuId, version);
const entry: CacheEntry = {
key, size,
lastAccess: Date.now(),
accessCount: 1,
filePath
};
// 新商品先进用户缓存,访问次数够了再晋升全局
this.userIndex.set(key, entry);
await this.evictUserIfNeeded();
}
// 用户缓存超限时,按 LRU 淘汰
private static async evictUserIfNeeded(): Promise<void> {
let totalSize: number = 0;
for (const e of this.userIndex.values()) { totalSize += e.size; }
while (totalSize > this.USER_LIMIT && this.userIndex.size > 0) {
// 找 lastAccess 最小的淘汰
let oldestKey: string | null = null;
let oldestTime: number = Infinity;
for (const [k, e] of this.userIndex) {
if (e.lastAccess < oldestTime) {
oldestTime = e.lastAccess;
oldestKey = k;
}
}
if (oldestKey) {
const evicted = this.userIndex.get(oldestKey)!;
await fileIo.unlinkSync(evicted.filePath);
this.userIndex.delete(oldestKey);
totalSize -= evicted.size;
}
}
}
// 全局缓存超限时,按 LFU 淘汰(访问次数最少先淘汰)
private static async evictGlobalIfNeeded(): Promise<void> {
let totalSize: number = 0;
for (const e of this.globalIndex.values()) { totalSize += e.size; }
while (totalSize > this.GLOBAL_LIMIT && this.globalIndex.size > 0) {
// 找 accessCount 最小的淘汰
let leastKey: string | null = null;
let leastCount: number = Infinity;
for (const [k, e] of this.globalIndex) {
if (e.accessCount < leastCount) {
leastCount = e.accessCount;
leastKey = k;
}
}
if (leastKey) {
const evicted = this.globalIndex.get(leastKey)!;
await fileIo.unlinkSync(evicted.filePath);
this.globalIndex.delete(leastKey);
totalSize -= evicted.size;
}
}
}
// 用户缓存中的商品晋升到全局缓存
private static async promoteToGlobal(entry: CacheEntry): Promise<void> {
this.globalIndex.set(entry.key, entry);
await this.evictGlobalIfNeeded();
await this.persistGlobalIndex();
}
// 全局索引持久化,应用重启后热门商品还在
private static async persistGlobalIndex(): Promise<void> {
const prefs = await preferences.getPreferences(getContext(), 'recon_cache');
const json = JSON.stringify([...this.globalIndex.entries()]);
await prefs.put('globalIndex', json);
await prefs.flush();
}
}
实现里有几个关键点:
晋升机制——商品在用户缓存里访问次数超过 5 次,晋升到全局缓存。这样新商品先进用户缓存,被多次访问后变热门进全局,避免新商品直接进全局被 LFU 淘汰。
全局索引持久化——LFU 的频率统计要跨重启保留,否则重启后清零退化成 LRU。用 preferences 持久化,每次访问更新频率后写一次。写入耗时 8-15 ms,我们做了防抖——1 秒内多次访问只写一次,减少写入开销。
淘汰策略分缓存——用户缓存用 LRU(个人浏览顺序有意义),全局缓存用 LFU(热门商品频率高保留)。两个策略各管一半,互不干扰。
缓存的读写路径可以画成一条判定链。命中、晋升、淘汰三个动作都在这条链上。
图里全局用 LFU、用户用 LRU,两套淘汰策略各管一半,这是混合方案的关键。
缓存策略选择决策
不同设备不同策略,按存储空间自动选。
// entry/src/main/ets/cache/CacheStrategySelector.ets
export class CacheStrategySelector {
static select(): CacheStrategy {
const freeBytes: number = getFreeStorage();
const totalBytes: number = getTotalStorage();
// 存储 < 32 GB:纯全局 LRU,省空间
if (totalBytes < 32 * 1024 * 1024 * 1024) {
return CacheStrategy.GLOBAL_LRU;
}
// 剩余 < 2 GB:纯全局 LRU,避免占满
if (freeBytes < 2 * 1024 * 1024 * 1024) {
return CacheStrategy.GLOBAL_LRU;
}
// 高端设备:混合策略
return CacheStrategy.MIXED;
}
}
低端设备退到全局 LRU,命中率低但存储省。高端设备用混合策略,命中率高。策略选择在应用启动时做一次,运行期不变。
注意一下
- LRU 在"翻一圈回来"场景命中率 0%,浏览类应用不要用纯 LRU,热门商品会被淘汰
- LFU 要持久化频率统计,否则重启后清零退化成 LRU,用 preferences 持久化并防抖
- 新商品进 LFU 缓存频率为 0 会被立刻淘汰,给初始频率保底或先进 LRU 再晋升
- 按场景分组粒度太粗,组内冷热不均时整组淘汰损失热门,只适合组内访问一致场景
- 按用户分组存储成本翻倍,端侧扛不住,适合服务端,端侧用混合策略折中
- 缓存 key 要带版本号,重建结果更新时缓存自动失效,否则用户看到旧版本
- 离线时不校验版本号,用本地缓存版本,避免强校验导致缓存全失效
- 缓存策略按设备存储自动选,低端退全局 LRU,高端用混合,启动时选一次
。
更多推荐


所有评论(0)