重建结果缓存策略: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,立刻被淘汰。一个新上架商品第一次被看,频率 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(热门商品频率高保留)。两个策略各管一半,互不干扰。

缓存的读写路径可以画成一条判定链。命中、晋升、淘汰三个动作都在这条链上。

命中

未命中

命中

达到

未达到

未命中

超容量

未超

请求 skuId 与 version

拼出缓存 key

全局缓存是否命中

更新访问次数后直接返回

用户缓存是否命中

访问次数是否达到热门阈值

晋升到全局缓存

返回用户缓存文件

触发重建或下载

写入用户缓存

缓存是否超容量

用户缓存按 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,高端用混合,启动时选一次
    。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐