《互联网产品的客户端架构·端形态与体验基础》7.2 端上缓存与离线优先

把端上缓存拆成键设计、新鲜窗口、可陈旧窗口与离线回退四件事,给出「回声」四类资源的策略表和一个可运行的 TypeScript 读缓存实现,用九个不依赖真实时钟的测试验证 stale-while-revalidate、单飞刷新、持久化与淘汰,并复盘一次把订单状态缓存过久的事故。

缓存的价值是断网时还能看

上一节的调度器让请求变得有秩序,但它假设网络最终会回来。真实的地铁不会配合这个假设:列车进隧道,连接断掉,用户还在盯着会话页。此时正确的行为不是显示一个转圈的空页面,而是先把上次读到的消息渲染出来,再在后台尝试刷新。

这就是端上缓存的第一目标:离线可读。节省流量与减少等待是附带收益,不是设计起点。把缓存当成性能优化,团队就会在“数据可能过期”时选择不缓存;把它当成可用性能力,团队才会认真设计过期、回退与冲突。

「回声」有四类典型读资源:首页内容流、会话消息、个人资料、订单详情。它们的变化频率、过期代价与离线价值完全不同,不能用同一个 TTL 一刀切。本节先把缓存键定清楚,再给出策略表,最后写一个可运行的读缓存。代码里的存储层是注入的字符串键值接口,换成 localStorage 或 SQLite 只替换适配器。

文中出现的时长与容量都是案例假设值,用来演示判断过程,不是行业标准。

缓存键:先确定“同一个东西”

缓存键不是资源的 URL。同一个 URL 在不同语言、不同账号、不同实验分组下可能是不同内容;同一个资源在升级前后也可能结构不兼容。键设计错了,后面的过期策略再精细也没有意义。

案例的键由五段组成:结构版本、资源名、资源标识、语言、变体。结构版本在数据格式变化时递增,旧缓存自然失效;语言与变体分开,避免“中文用户看到英文缓存”;每一段都做 URI 编码,防止标识里的冒号或斜杠破坏分隔。

一个常见错误是把查询参数按对象序列化后拼进键。对象键顺序不稳定,同一个查询可能生成两个键,缓存命中率骤降。正确做法是显式列出参与缓存的维度,顺序固定,缺省值也写进键里。

策略表:新鲜、可陈旧、不可用

每个资源需要两个时间窗口,而不是一个 TTL:

  • 新鲜窗口(fresh):窗口内直接读缓存,不发请求。
  • 可陈旧窗口(stale):超过新鲜窗口但仍在窗口内,先返回旧值,同时后台刷新。
  • 超出两个窗口:缓存不可用,必须等网络;除非调用方显式允许旧值兜底。
资源新鲜窗口可陈旧窗口依据
feed:home60 秒24 小时内容流变化快,但离线时旧列表仍有阅读价值
message:thread5 秒7 天消息必须及时,离线历史必须可读
profile:self10 分钟30 天个人资料变化慢,几乎总是先展示
order:detail00金额与状态必须实时,不允许陈旧
未知资源30 秒1 小时保守默认,迫使新资源显式登记策略

这张表是策略的单一来源。组件不应该自己写 if (age > 60000),否则同一资源在不同页面会有不同口径。order:detail 的两个零不是“还没配置”,而是明确表达“不可缓存”:age < 0 永远不成立,任何一次读取都会走网络。

可陈旧窗口的长度要按“过期代价”而不是“变化频率”决定。消息变化很快,但离线时看到七天前的会话仍然比看空页面好;订单金额变化不一定快,但展示错误金额的代价极高,所以窗口是零。

读缓存实现

下面的实现只做读路径:查缓存、判新鲜度、决定直接返回还是后台刷新。写路径与失效由业务层调用 put 与 invalidate,不在通用容器里猜。

// file: src/data/cache-store.ts
// tier: L1
// verify: node --test test/data/cache-store.test.ts

export interface CachePolicy {
  /** 新鲜窗口:窗口内不发请求 */
  freshMs: number;
  /** 可容忍的过期窗口:超时后仍可先展示,同时后台刷新 */
  staleMs: number;
}

/** 策略表是案例假设值,真实产品应按资源的变化频率与代价逐项评审。 */
export const CACHE_POLICIES: Record<string, CachePolicy> = {
  "feed:home": { freshMs: 60_000, staleMs: 86_400_000 },
  "message:thread": { freshMs: 5_000, staleMs: 604_800_000 },
  "profile:self": { freshMs: 600_000, staleMs: 2_592_000_000 },
  "order:detail": { freshMs: 0, staleMs: 0 },
};

export function policyFor(resource: string): CachePolicy {
  return CACHE_POLICIES[resource] ?? { freshMs: 30_000, staleMs: 3_600_000 };
}

export interface CacheKeyParts {
  resource: string;
  id: string;
  locale?: string;
  variant?: string;
}

/**
 * 缓存键包含结构版本、资源、标识、语言与变体。
 * 每一段都做 URI 编码,避免 id 里的冒号或斜杠破坏分隔。
 */
export function buildCacheKey(parts: CacheKeyParts): string {
  const segment = (value: string): string => encodeURIComponent(value);
  return [
    "v1",
    segment(parts.resource),
    segment(parts.id),
    segment(parts.locale ?? "zh-CN"),
    segment(parts.variant ?? "default"),
  ].join(":");
}

export interface CacheStore {
  get(key: string): string | undefined;
  set(key: string, raw: string): void;
  remove(key: string): void;
  keys(): string[];
}

/** 用字符串键值模拟 localStorage,换到真机时只替换这一层。 */
export class MemoryStore implements CacheStore {
  private readonly data = new Map<string, string>();

  get(key: string): string | undefined {
    return this.data.get(key);
  }

  set(key: string, raw: string): void {
    this.data.set(key, raw);
  }

  remove(key: string): void {
    this.data.delete(key);
  }

  keys(): string[] {
    return [...this.data.keys()];
  }
}

interface StoredRecord<T> {
  value: T;
  storedAt: number;
  accessedAt: number;
}

export type CacheStatus = "fresh" | "stale" | "expired" | "miss";

export interface CacheEntry<T> {
  value: T;
  storedAt: number;
  accessedAt: number;
  ageMs: number;
}

export interface CacheLookup<T> {
  status: CacheStatus;
  entry: CacheEntry<T> | null;
}

export type CacheSource = "cache-fresh" | "cache-stale" | "cache-stale-offline" | "network";

export interface CacheResult<T> {
  value: T;
  source: CacheSource;
  /** stale 命中时非空:后台刷新完成即 resolve,失败也会 resolve */
  revalidating: Promise<void> | null;
}

export interface GetOptions {
  forceRefresh?: boolean;
  /** 默认 false:超出 stale 窗口的旧数据不能静默返回 */
  allowExpiredOnError?: boolean;
}

export interface CacheOptions {
  now: () => number;
  store: CacheStore;
  maxEntries: number;
}

/**
 * 端上读缓存:先读本地、按策略判定新鲜度,再决定直接返回还是后台刷新。
 * 离线时 stale 窗口内的数据仍可读;超出窗口必须由调用方显式允许。
 */
export class EndpointCache {
  private readonly now: () => number;
  private readonly store: CacheStore;
  private readonly maxEntries: number;
  private readonly loading = new Map<string, Promise<unknown>>();

  constructor(options: CacheOptions) {
    if (options.maxEntries < 1) throw new Error("maxEntries must be >= 1");
    this.now = options.now;
    this.store = options.store;
    this.maxEntries = options.maxEntries;
  }

  lookup<T>(key: string, policy: CachePolicy): CacheLookup<T> {
    const raw = this.store.get(key);
    if (raw === undefined) return { status: "miss", entry: null };
    let record: StoredRecord<T>;
    try {
      record = JSON.parse(raw) as StoredRecord<T>;
    } catch {
      this.store.remove(key);
      return { status: "miss", entry: null };
    }
    const age = Math.max(0, this.now() - record.storedAt);
    const entry: CacheEntry<T> = {
      value: record.value,
      storedAt: record.storedAt,
      accessedAt: record.accessedAt,
      ageMs: age,
    };
    if (age < policy.freshMs) return { status: "fresh", entry };
    if (age < policy.freshMs + policy.staleMs) return { status: "stale", entry };
    return { status: "expired", entry };
  }

  async get<T>(
    key: string,
    policy: CachePolicy,
    loader: () => Promise<T>,
    options: GetOptions = {},
  ): Promise<CacheResult<T>> {
    const found = this.lookup<T>(key, policy);

    if (!options.forceRefresh && found.status === "fresh" && found.entry) {
      this.touch(key, found.entry);
      return { value: found.entry.value, source: "cache-fresh", revalidating: null };
    }
    if (!options.forceRefresh && found.status === "stale" && found.entry) {
      return {
        value: found.entry.value,
        source: "cache-stale",
        revalidating: this.revalidate(key, loader),
      };
    }

    try {
      const value = await this.refresh(key, loader);
      return { value, source: "network", revalidating: null };
    } catch (error) {
      if (found.entry && options.allowExpiredOnError === true) {
        return { value: found.entry.value, source: "cache-stale-offline", revalidating: null };
      }
      throw error;
    }
  }

  put<T>(key: string, value: T): void {
    const at = this.now();
    const record: StoredRecord<T> = { value, storedAt: at, accessedAt: at };
    this.store.set(key, JSON.stringify(record));
    this.evict();
  }

  invalidate(key: string): void {
    this.store.remove(key);
  }

  /** 同一 key 的并发刷新只发一次请求,与 EchoClient 的去重规则一致。 */
  private refresh<T>(key: string, loader: () => Promise<T>): Promise<T> {
    const running = this.loading.get(key);
    if (running) return running as Promise<T>;
    const task = loader()
      .then((value) => {
        this.put(key, value);
        return value;
      })
      .finally(() => {
        this.loading.delete(key);
      });
    this.loading.set(key, task);
    return task;
  }

  private revalidate<T>(key: string, loader: () => Promise<T>): Promise<void> {
    return this.refresh(key, loader).then(
      () => undefined,
      () => undefined,
    );
  }

  private touch<T>(key: string, entry: CacheEntry<T>): void {
    const record: StoredRecord<T> = {
      value: entry.value,
      storedAt: entry.storedAt,
      accessedAt: this.now(),
    };
    this.store.set(key, JSON.stringify(record));
  }

  private evict(): void {
    const keys = this.store.keys();
    if (keys.length <= this.maxEntries) return;
    const ranked = keys.map((key) => {
      const raw = this.store.get(key);
      let accessedAt = 0;
      try {
        accessedAt = (JSON.parse(raw ?? "{}") as StoredRecord<unknown>).accessedAt ?? 0;
      } catch {
        accessedAt = 0;
      }
      return { key, accessedAt };
    });
    ranked.sort((a, b) => a.accessedAt - b.accessedAt);
    for (const item of ranked.slice(0, keys.length - this.maxEntries)) {
      this.store.remove(item.key);
    }
  }
}

几个边界需要说明。

stale 命中不等待刷新。 get 在 stale 窗口内立即返回旧值,同时把刷新 Promise 放进 revalidating。调用方可以选择等待,也可以直接渲染。刷新失败不会把旧值删掉,这是离线可读的关键:网络失败只意味着“这次没更新”,不意味着“缓存无效”。

单飞刷新。 同一 key 的并发 get 可能同时触发后台刷新,loading 表保证只发一次请求。这与上一节的请求去重是同一个模式,只是作用在缓存层。

超出窗口必须显式回退。 默认情况下,过期数据不会被静默返回;调用方要传 allowExpiredOnError: true 才拿到 cache-stale-offline。这个默认值是有意的:订单、余额、权限这类数据不能因为“至少有点东西”就展示错误状态。

淘汰按访问时间。 容量上限防止缓存无限增长;每次新鲜命中都会更新 accessedAt,写入新条目时淘汰最久未访问的。它不是严格的 LRU(读取 stale 不更新访问时间),但行为可预测,测试可以直接比对键集合。

持久化是适配器的事。 缓存条目序列化为字符串,CacheStore 只要求 get/set/remove/keys。测试用内存 Map 模拟,真机换成 localStorage 时连同容量上限一起重新评估,因为浏览器的配额与淘汰策略不受应用控制。

测试与真实输出

测试覆盖键设计、新鲜命中、stale 刷新、单飞、离线回退、超窗拒绝、跨实例持久化、淘汰与策略表九个场景。时间通过闭包变量推进,不涉及真实定时器:

node --test test/data/cache-store.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 缓存键包含结构版本、资源、标识、语言与变体 (0.793916ms)
✔ 新鲜窗口内直接命中,不调用加载器 (0.652375ms)
✔ stale 命中先返回旧值,后台刷新完成后写回新值 (0.721084ms)
✔ 同一 key 的并发后台刷新只发一次请求 (0.156833ms)
✔ 离线时 stale 窗口内仍可读,刷新失败不影响返回值 (0.171708ms)
✔ 超出 stale 窗口默认抛错,显式允许时才回退旧值 (0.376792ms)
✔ 换一个实例仍能从同一个存储读到缓存 (0.089125ms)
✔ 超过容量时淘汰最久未访问的条目 (0.520958ms)
✔ 策略表按资源返回,未知资源使用保守默认值 (0.099958ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 181.789459

一次把订单状态缓存过久的事故

某次版本为了“提升下单页流畅度”,给订单详情加了十分钟缓存。上线后出现一类客诉:用户支付失败,返回订单页却看到“已支付”,再次点击支付,最终产生两笔订单。复盘时发现三个问题叠加:

  • 订单状态被当成普通详情,复用了内容流的缓存策略。
  • 支付失败后没有主动失效缓存,页面读到的仍是支付前的旧状态。
  • 重试支付没有幂等键,服务端把第二次请求当成新订单。

根因不是缓存本身,而是策略表缺了一行。order:detail 的窗口应该是零,支付成功或失败都必须触发失效;缓存层能提供 invalidate,但“什么时候调用”属于业务状态机,不能指望通用容器自动推断。

修复后,订单详情不进入缓存,支付结果页直接读服务端;如果产品确实需要离线展示订单,也应该只缓存“历史订单列表”这种只读视图,并把金额与状态的实时校验留在提交路径上。这是一个典型边界:缓存可以提升可用性,但不能替代一致性校验。

与 HTTP 缓存的对照

端上缓存和 HTTP 缓存看起来相似,职责并不同:

维度HTTP 缓存端上读缓存
键URL + 请求头资源、标识、语言、变体、结构版本
控制者服务端响应头客户端策略表
共享范围代理、浏览器、用户之间可能共享仅当前用户与设备
离线能力取决于 Service Worker 与缓存策略显式设计,可按资源回退
失效服务端驱动业务事件与 TTL 共同驱动

HTTP 缓存解决的是“这个响应能不能复用”,端上缓存解决的是“这个用户在这个设备上应该先看到什么”。前者由服务端主导,后者必须由客户端按资源语义决定。两者可以叠加,但不能互相替代。

小结

端上缓存的核心不是 TTL,而是三个判断:这份数据属于谁(键)、过期后还能不能用(新鲜与可陈旧窗口)、网络失败时要不要兜底(显式回退)。策略表把这三个判断集中到一处,组件不再各自发明规则。

离线优先不是“所有数据都缓存”,而是“明确哪些数据在离线时仍然有价值,哪些绝对不能陈旧”。把订单与余额排除在长窗口之外,和把消息历史保留七天,是同一个设计动作的两面。

下一节处理写路径:离线期间产生的修改如何排队、按什么顺序重放、冲突时谁赢,以及为什么幂等键必须在入队时生成。

阅读导航:上一节:7.1 请求编排与去重 · 下一节:7.3 冲突解决与同步队列 。

常见错误与边界

  • 缓存键不含用户、语言或实验分组。不同上下文互相覆盖,用户看到别人的数据。
  • 把 stale 当 fresh 用。超过新鲜窗口却不刷新,数据会无限期停在旧版本。
  • 刷新失败就删缓存。网络失败不等于缓存无效,删掉只会让离线体验更差。
  • 缓存没有容量上限。长时间使用后内存持续增长,低端机最先出问题。
  • 订单、余额、权限使用长 stale 窗口。展示错误状态的代价远高于一次等待。
  • 结构升级后直接解析旧缓存。键里没有结构版本,字段缺失会在运行时爆炸。
  • 在组件里各写一套过期判断。同一资源出现多个口径,线上行为无法解释。
  • 把缓存当一致性方案。缓存只提升可用性,提交路径仍要校验与幂等。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查