缓存的价值是断网时还能看
上一节的调度器让请求变得有秩序,但它假设网络最终会回来。真实的地铁不会配合这个假设:列车进隧道,连接断掉,用户还在盯着会话页。此时正确的行为不是显示一个转圈的空页面,而是先把上次读到的消息渲染出来,再在后台尝试刷新。
这就是端上缓存的第一目标:离线可读。节省流量与减少等待是附带收益,不是设计起点。把缓存当成性能优化,团队就会在“数据可能过期”时选择不缓存;把它当成可用性能力,团队才会认真设计过期、回退与冲突。
「回声」有四类典型读资源:首页内容流、会话消息、个人资料、订单详情。它们的变化频率、过期代价与离线价值完全不同,不能用同一个 TTL 一刀切。本节先把缓存键定清楚,再给出策略表,最后写一个可运行的读缓存。代码里的存储层是注入的字符串键值接口,换成 localStorage 或 SQLite 只替换适配器。
文中出现的时长与容量都是案例假设值,用来演示判断过程,不是行业标准。
缓存键:先确定“同一个东西”
缓存键不是资源的 URL。同一个 URL 在不同语言、不同账号、不同实验分组下可能是不同内容;同一个资源在升级前后也可能结构不兼容。键设计错了,后面的过期策略再精细也没有意义。
案例的键由五段组成:结构版本、资源名、资源标识、语言、变体。结构版本在数据格式变化时递增,旧缓存自然失效;语言与变体分开,避免“中文用户看到英文缓存”;每一段都做 URI 编码,防止标识里的冒号或斜杠破坏分隔。
一个常见错误是把查询参数按对象序列化后拼进键。对象键顺序不稳定,同一个查询可能生成两个键,缓存命中率骤降。正确做法是显式列出参与缓存的维度,顺序固定,缺省值也写进键里。
策略表:新鲜、可陈旧、不可用
每个资源需要两个时间窗口,而不是一个 TTL:
- 新鲜窗口(fresh):窗口内直接读缓存,不发请求。
- 可陈旧窗口(stale):超过新鲜窗口但仍在窗口内,先返回旧值,同时后台刷新。
- 超出两个窗口:缓存不可用,必须等网络;除非调用方显式允许旧值兜底。
| 资源 | 新鲜窗口 | 可陈旧窗口 | 依据 |
|---|---|---|---|
feed:home | 60 秒 | 24 小时 | 内容流变化快,但离线时旧列表仍有阅读价值 |
message:thread | 5 秒 | 7 天 | 消息必须及时,离线历史必须可读 |
profile:self | 10 分钟 | 30 天 | 个人资料变化慢,几乎总是先展示 |
order:detail | 0 | 0 | 金额与状态必须实时,不允许陈旧 |
| 未知资源 | 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 窗口。展示错误状态的代价远高于一次等待。
- 结构升级后直接解析旧缓存。键里没有结构版本,字段缺失会在运行时爆炸。
- 在组件里各写一套过期判断。同一资源出现多个口径,线上行为无法解释。
- 把缓存当一致性方案。缓存只提升可用性,提交路径仍要校验与幂等。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。