《互联网产品的客户端架构·端形态与体验基础》5.2 服务端状态的缓存与失效

把服务端状态从组件状态里拆出来,按价格、会话、内容流、详情与配置分别设定 ttl、stale 窗口与刷新模式,给出一个注入时钟、支持 stale-while-revalidate 与代际失效的 TypeScript 缓存,并用可运行测试验证过期、去重、前缀失效与旧响应回写拦截。

缓存不是“把响应存起来”

服务端状态和本地状态最容易混淆的一点是:它看起来也能放在一个 Map 里,读的时候取出来即可。但服务端状态有一个本地状态没有的维度——它在服务端会变,而端上不知道什么时候变。缓存的核心不是存储,而是回答三个问题:这份数据还能直接用多久,过期后是先展示旧值还是先等新值,失效时如何避免旧请求把新数据覆盖掉。

「回声」里至少有三类速度完全不同的数据。商品价格必须接近实时,展示一个过期的折扣价可能直接造成资损;会话列表可以接受几十秒的延迟;帮助中心的配置项几乎不变,缓存一小时也没有问题。给它们设同一个 TTL,要么价格不安全,要么配置被无谓地反复拉取。

本节把过期策略做成一张按资源类型索引的表,再实现一个只依赖注入时钟的缓存。所有时间判断都由测试传入,不使用真实定时器,因此结果可复现。

按资源类型定过期策略

资源类型ttlstale 窗口刷新模式理由
price5 秒0blocking价格过期即不可展示,必须等新值
session30 秒120 秒background会话变化不频繁,旧值可短暂展示
feed60 秒300 秒background内容流允许先看旧列表再刷新
detail5 分钟15 分钟background详情变化慢,回退时优先即时渲染
config1 小时0background配置几乎不变,过期后重新拉取即可

这张表里有两个独立参数。ttlMs 决定“多久之内算新鲜”,staleMs 决定“过期之后还愿意展示旧值多久”。只有 background 模式才会使用 stale 窗口;blocking 模式即使配置了窗口也不会展示旧值,价格就是这种情况。

none 模式留给完全由本地维护、不需要主动刷新的资源。当前实现里没有资源使用它,但接口保留,避免以后为了一个特例改动读取路径。

缓存实现

下面的 ResourceCache 把读取、写入、刷新去重和失效收在一起。它接收一个 now 函数,而不是直接调用 Date.now(),这样测试可以精确控制时间。

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

export type ResourceKind = "price" | "session" | "feed" | "detail" | "config";
export type RevalidateMode = "background" | "blocking" | "none";

export interface CachePolicy {
  /** 可以直接展示的时长 */
  ttlMs: number;
  /** 超过 ttl 后仍可继续展示、同时后台刷新的时长 */
  staleMs: number;
  /** background 允许展示旧值,blocking 不允许,none 不主动刷新 */
  mode: RevalidateMode;
}

/**
 * 按资源类型决定过期策略,而不是给所有 key 一个统一 TTL。
 * 价格必须新鲜,配置几乎不变,内容流允许在旧值上停留一会儿。
 */
export const CACHE_POLICIES: Record<ResourceKind, CachePolicy> = {
  price: { ttlMs: 5_000, staleMs: 0, mode: "blocking" },
  session: { ttlMs: 30_000, staleMs: 120_000, mode: "background" },
  feed: { ttlMs: 60_000, staleMs: 300_000, mode: "background" },
  detail: { ttlMs: 300_000, staleMs: 900_000, mode: "background" },
  config: { ttlMs: 3_600_000, staleMs: 0, mode: "background" },
};

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

export interface ReadResult<T> {
  status: CacheStatus;
  value?: T;
  ageMs: number | null;
  shouldRevalidate: boolean;
}

interface Entry {
  kind: ResourceKind;
  value: unknown;
  fetchedAt: number;
}

export interface RevalidateToken {
  key: string;
  generation: number;
}

/**
 * 服务端状态缓存:注入 now,不使用真实时钟。
 * 失效会推进 generation,使失效前发出的请求无法回写旧数据。
 */
export class ResourceCache {
  private entries = new Map<string, Entry>();
  private generations = new Map<string, number>();
  private inflight = new Map<string, number>();
  private readonly now: () => number;

  constructor(now: () => number) {
    this.now = now;
  }

  get size(): number {
    return this.entries.size;
  }

  read<T>(kind: ResourceKind, key: string): ReadResult<T> {
    const entry = this.entries.get(key);
    if (!entry || entry.kind !== kind) {
      return { status: "miss", ageMs: null, shouldRevalidate: true };
    }
    const policy = CACHE_POLICIES[kind];
    const ageMs = Math.max(0, this.now() - entry.fetchedAt);
    if (ageMs <= policy.ttlMs) {
      return {
        status: "fresh",
        value: entry.value as T,
        ageMs,
        shouldRevalidate: false,
      };
    }
    const withinStale =
      policy.mode === "background" &&
      policy.staleMs > 0 &&
      ageMs <= policy.ttlMs + policy.staleMs;
    if (withinStale) {
      return {
        status: "stale",
        value: entry.value as T,
        ageMs,
        shouldRevalidate: true,
      };
    }
    return { status: "expired", ageMs, shouldRevalidate: policy.mode !== "none" };
  }

  write<T>(
    kind: ResourceKind,
    key: string,
    value: T,
    fetchedAt: number = this.now(),
  ): void {
    this.entries.set(key, { kind, value, fetchedAt });
  }

  /** 同一 key 只允许一个刷新在飞;已有刷新时返回 undefined。 */
  beginRevalidate(key: string): RevalidateToken | undefined {
    if (this.inflight.has(key)) return undefined;
    const generation = this.generations.get(key) ?? 0;
    this.inflight.set(key, generation);
    return { key, generation };
  }

  /** 只有 token 仍对应当前 generation 时才写入,防止旧响应覆盖新数据。 */
  commit<T>(
    token: RevalidateToken,
    kind: ResourceKind,
    value: T,
    fetchedAt: number = this.now(),
  ): boolean {
    if (this.inflight.get(token.key) !== token.generation) return false;
    this.inflight.delete(token.key);
    if ((this.generations.get(token.key) ?? 0) !== token.generation) return false;
    this.entries.set(token.key, { kind, value, fetchedAt });
    return true;
  }

  abort(token: RevalidateToken): void {
    if (this.inflight.get(token.key) === token.generation) {
      this.inflight.delete(token.key);
    }
  }

  /** 删除缓存并推进 generation,返回此前是否存在条目。 */
  invalidate(key: string): boolean {
    this.generations.set(key, (this.generations.get(key) ?? 0) + 1);
    this.inflight.delete(key);
    return this.entries.delete(key);
  }

  invalidatePrefix(prefix: string): number {
    let removed = 0;
    for (const key of [...this.entries.keys()]) {
      if (key.startsWith(prefix)) {
        this.invalidate(key);
        removed += 1;
      }
    }
    return removed;
  }

  clear(): void {
    for (const key of [...this.entries.keys()]) this.invalidate(key);
  }
}

四种读取结果

read 返回四种状态,界面据此决定显示什么:

状态有值是否刷新界面行为
fresh是否直接渲染,不显示加载态
stale是是渲染旧值,同时后台刷新
expired否是显示加载态,等待新值
miss否是显示加载态,等待新值

stale 是 stale-while-revalidate 的核心:用户立刻看到内容,刷新在后台进行,成功后替换。它牺牲的是一段时间内的一致性,换取的是不会因为一次网络往返把已有内容清空。对于内容流和详情页,这个交换通常值得;对于价格与库存,不应该发生。

expired 与 miss 对界面几乎一样,但对诊断不同。前者说明曾经有过数据、只是太旧;后者说明从未成功拉取。日志与埋点应该区分两者,否则无法判断是缓存策略太激进,还是请求一直失败。

年龄计算用了 Math.max(0, now - fetchedAt)。设备时钟被用户或系统调整时,now 可能小于写入时间;如果不做保护,年龄变成负数,会落进 fresh 分支。结果虽然也是 fresh,但 ageMs 为负会污染监控数据。按 0 处理是更诚实的选择:无法确认时间,就当作刚刚写入。

失效与在飞请求

缓存最容易出错的地方不是过期,而是失效与刷新并发。用户提交了修改,前端调用 invalidate 删除列表;与此同时,一个更早发出的列表请求刚好返回。如果没有保护,旧响应会被写回缓存,界面看起来像“修改没生效”。

实现用 generation 解决这个问题。每个 key 有一个递增的代际号,beginRevalidate 把当前代际号写进 token,commit 只有确认 inflight 仍是这个 token、且代际号没有变化时才写入。invalidate 删除条目并把代际号加一,因此在失效之前发出的请求即使成功返回,commit 也会返回 false。

同一个 key 的并发刷新由 inflight 去重。beginRevalidate 在已有刷新时返回 undefined,调用方据此跳过重复请求。失败路径必须调用 abort,否则这个 key 会一直被认为“正在刷新”,后续重试全部被挡住。这和上一章请求去重里失败要清理 inflight 是同一条规则。

invalidatePrefix 用于批量失效,比如“当前用户的所有会话”或“某个商品下的全部价格”。它逐个调用 invalidate,保证每个 key 的代际号都推进,而不是只删条目。只删条目的实现会让在飞请求绕过代际检查,等于没有失效。

测试与真实输出

测试注入时钟,覆盖新鲜、stale、过期、blocking、失效拦截、刷新去重、前缀失效、时钟回拨与类型串用。

node --test test/state/cache.test.ts

本机一次运行的真实输出:

✔ 空缓存读取为 miss,并请求拉取 (0.5535ms)
✔ ttl 内为 fresh,不触发刷新 (0.416292ms)
✔ 超过 ttl 但在 stale 窗口内,先展示旧值再后台刷新 (0.58525ms)
✔ 超过 stale 窗口后为 expired,不再展示旧值 (0.153083ms)
✔ blocking 策略的价格不允许展示过期值 (0.085709ms)
✔ 失效会推进 generation,失效前发出的刷新无法回写 (0.176792ms)
✔ 同一 key 的刷新按 token 去重,abort 后可以重新开始 (0.068583ms)
✔ 前缀失效只删除匹配的 key 并返回数量 (0.0815ms)
✔ 时钟回拨时年龄按 0 处理,不会误判为过期 (0.086833ms)
✔ 同 key 换了资源类型按 miss 处理,避免策略串用 (0.165208ms)
✔ 策略表保持资源类型的过期顺序 (0.082584ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 202.344

耗时是整次运行的墙钟时间,不是单条用例成本。判定仍然是退出码为 0 且 fail 0。

小结

服务端状态需要三样东西:按资源类型设定的过期策略、过期后的展示与刷新选择、失效与在飞请求之间的顺序保护。三者缺一,缓存就会在某个边界上给出错误结果。

ttl 与 stale 窗口是两个独立参数,前者决定新鲜度,后者决定愿意用一致性换多少即时渲染。blocking 模式不展示旧值,适合价格与库存;background 模式展示旧值并刷新,适合内容流与详情。

代际号让失效具备顺序语义:失效之后,旧请求即使成功也不能回写。它比“删除条目”多一步,但正是这一步避免了提交后看到旧数据的经典问题。

下一节换一个角度:有些状态不是“一份值”,而是一组互斥阶段和合法迁移。什么时候值得引入状态机,什么时候普通布尔标记就够,需要一条判据。

阅读导航:上一节:5.1 本地状态与共享状态 · 下一节:5.3 状态机的引入判据 。

常见错误与边界

  • 所有资源共用一个 TTL。价格不够新,配置又被过度拉取。
  • 把 stale 窗口当成“多缓存一会儿”。它是愿意展示旧值的时间,不是数据的保质期。
  • blocking 模式仍然渲染旧值。价格与库存场景会直接展示错误信息。
  • 失效只删条目、不推进代际。失效前发出的请求返回后会把旧值写回来。
  • 刷新失败不调用 abort。key 永久处于刷新中,后续重试全部被去重挡住。
  • 用真实时钟写测试。时间边界只能靠等待触发,既慢又不稳定。
  • 不同资源类型共用 key 空间。价格与详情同 key 时会读到对方的策略。
  • 把缓存命中率当成唯一指标。命中旧价格比未命中更危险。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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