缓存事故的两种形状
第一种形状是“新版本不生效”。用户更新后仍然看到旧页面,客服让他清缓存,清完就好了。第二种形状是“新页面引用了旧文件”。HTML 已经是新的,但它指向的 app.3.js 还没同步到边缘节点,页面白屏。两种事故的根因相反:前者缓存太久,后者缓存不一致。
缓存策略不是“把静态资源设长一点”。它是一组按资源类型分配的决策:哪些文件可以一年不变,哪些必须每次回源,哪些旧文件在退役后还要留一段时间。做出这组决策的前提,是文件名能表达内容版本——也就是内容哈希。
本节把哈希、缓存头、版本窗口和旧资源退役放在一个函数集里。文中的缓存时长是案例假设值,只有测试输出是本机真实运行结果。
内容哈希解决什么
内容哈希的规则很简单:文件内容变,文件名就变;内容不变,文件名不变。这样缓存就可以按“文件名”永久化,因为同一个 URL 永远不会对应两份不同内容。
三个前提必须同时成立:
- 哈希由最终产物内容计算,而不是由源码或构建时间计算。源码没变但构建时间变了,不应该换名。
- 引用方必须使用带哈希的名字。HTML、清单、Service Worker 里如果还写着旧名字,哈希就白算。
- 哈希足够长,碰撞概率可忽略。示例用 32 位 FNV-1a 只是为了可读,真实构建应该用 128 位以上的哈希。
反过来,HTML、清单和 Service Worker 不能带内容哈希后永久缓存。它们是版本指针:内容变了但 URL 不变,或者 URL 变了但旧指针还被别人引用。它们必须回源校验。
缓存头是一张决策表
把资源类型和缓存语义写成表,比在每个部署脚本里手写字符串可靠:
| 类型 | Cache-Control | ETag | 原因 |
|---|---|---|---|
| 带哈希的 JS/CSS/字体/图片 | public, max-age=31536000, immutable | 不需要 | 内容变则换名 |
| HTML | no-cache | 需要 | 每次都校验,命中 304 也很快 |
| 资源清单 | no-cache | 需要 | 它是版本指针 |
| Service Worker | no-cache | 需要 | 必须让浏览器发现更新 |
| 接口响应 | no-store | 不需要 | 缓存语义由业务显式声明 |
immutable 的语义是“在 max-age 内不要发起校验请求”,它只对带哈希的资源成立。把 HTML 设成 immutable,用户会在一年内看不到新版本;把 Service Worker 设成 immutable,浏览器连更新检查都不会发。
// file: src/build/asset-cache.ts
// tier: L1
// verify: node --test test/build/asset-cache.test.ts
/**
* FNV-1a 32 位哈希,只用于演示“内容变则文件名变”。
* 真实构建应使用 128 位以上的哈希,并处理超长文件的分块读取。
*/
export function contentHash(content: string): string {
let hash = 0x811c9dc5;
for (const byte of new TextEncoder().encode(content)) {
hash ^= byte;
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
export function hashedName(name: string, content: string): string {
const dot = name.lastIndexOf(".");
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";
return `${base}.${contentHash(content)}${ext}`;
}
export function isImmutableName(name: string): boolean {
return /\.[0-9a-f]{8}\.(js|css|woff2|png|svg|webp)$/.test(name);
}
export type AssetKind = "hashed" | "html" | "manifest" | "service-worker" | "api";
export interface CacheDecision {
cacheControl: string;
etag: boolean;
immutable: boolean;
reason: string;
}
const CACHE_RULES: Record<AssetKind, CacheDecision> = {
hashed: {
cacheControl: "public, max-age=31536000, immutable",
etag: false,
immutable: true,
reason: "文件名含内容哈希,内容变化必然换名",
},
html: {
cacheControl: "no-cache",
etag: true,
immutable: false,
reason: "HTML 引用当前版本资源,每次都要回源校验",
},
manifest: {
cacheControl: "no-cache",
etag: true,
immutable: false,
reason: "清单是版本指针,不能与旧清单长期共存",
},
"service-worker": {
cacheControl: "no-cache",
etag: true,
immutable: false,
reason: "脚本要让浏览器发现更新,不能 immutable",
},
api: {
cacheControl: "no-store",
etag: false,
immutable: false,
reason: "接口响应默认不落共享缓存,由业务显式声明缓存",
},
};
export function cacheDecision(kind: AssetKind): CacheDecision {
return CACHE_RULES[kind];
}
export interface VersionWindow {
current: string;
minimum: string;
}
export interface CompatDecision {
status: "supported" | "upgrade-required" | "unknown";
reason: string;
}
export function parseVersion(input: string): [number, number, number] | undefined {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(input);
if (!match) return undefined;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function compareVersion(left: string, right: string): number {
const a = parseVersion(left);
const b = parseVersion(right);
if (!a || !b) throw new Error(`无法比较版本:${left} / ${right}`);
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] - b[index];
}
return 0;
}
export function compatDecision(client: string, window: VersionWindow): CompatDecision {
if (!parseVersion(client) || !parseVersion(window.minimum) || !parseVersion(window.current)) {
return { status: "unknown", reason: `版本号不可解析:${client}` };
}
if (compareVersion(client, window.minimum) < 0) {
return { status: "upgrade-required", reason: `低于最低支持版本 ${window.minimum}` };
}
if (compareVersion(client, window.current) > 0) {
return { status: "supported", reason: "客户端新于服务端,按向前兼容处理" };
}
return {
status: "supported",
reason: `位于兼容窗口 ${window.minimum}..${window.current}`,
};
}
export type AssetManifest = Record<string, string>;
export interface Resolution {
ok: boolean;
path?: string;
reason: string;
}
export function resolveAsset(manifest: AssetManifest, logical: string): Resolution {
const path = manifest[logical];
if (!path) return { ok: false, reason: `清单中没有 ${logical}` };
return { ok: true, path, reason: "命中当前清单" };
}
/**
* history 由旧到新排列。哈希文件从最新清单消失后,仍要在灰度窗口内保留,
* 因为旧 HTML 或旧 Service Worker 可能继续引用它。
*/
export function retirementPlan(
history: AssetManifest[],
graceReleases = 1,
): { keep: string[]; drop: string[] } {
if (history.length === 0) return { keep: [], drop: [] };
const latest = new Set(Object.values(history[history.length - 1]));
const cutoff = history.length - 1 - graceReleases;
const lastSeen = new Map<string, number>();
history.forEach((manifest, index) => {
for (const path of Object.values(manifest)) {
if (!latest.has(path)) lastSeen.set(path, index);
}
});
const keep: string[] = [];
const drop: string[] = [];
for (const [path, index] of lastSeen) {
if (index >= cutoff) keep.push(path);
else drop.push(path);
}
return { keep: keep.sort(), drop: drop.sort() };
}
版本兼容窗口
缓存解决“文件对不对”,版本窗口解决“客户端和服务端能不能对话”。客户端发布不可回滚,旧版本会长期存在,所以服务端必须显式声明一个最低支持版本。
compatDecision 把客户端版本分成三种结果:supported、upgrade-required、unknown。低于最低版本要求升级;位于窗口内正常服务;高于服务端当前版本按向前兼容处理,因为新客户端可能已经会处理旧服务端没有的字段。无法解析的版本返回 unknown,调用方应该按“不支持”处理写路径,而不是假设它是最新版。
窗口不是越宽越好。每多支持一个旧版本,服务端就多一份兼容分支和测试成本。窗口的宽度应该由真实版本分布决定,而不是由“保守一点”决定。
旧资源怎么失效
带哈希的文件不会互相覆盖,但会不断累积。删除太早,旧 HTML 或旧 Service Worker 可能还在引用;删除太晚,存储和部署清单会越来越重。
退役计划按“最后一次出现”计算:某个哈希文件从最新清单消失后,在灰度窗口内继续保留,超过窗口才删除。测试里有一个细节——同一个哈希可能在连续两版都出现,如果按“出现过的版本”逐条判断,它会同时进入保留和删除两个集合。实现改成记录“最后一次出现的下标”,每个文件只归入一边。
这个问题是测试抓出来的,不是评审看出来的。退役逻辑看着简单,边界却在“同一个值出现多次”上。
测试与真实输出
测试覆盖八种情况:哈希稳定性、文件名格式、缓存头、版本窗口、版本比较、清单解析、退役窗口、空历史。完整测试文件如下:
// file: test/build/asset-cache.test.ts
// tier: L1
// verify: node --test test/build/asset-cache.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
cacheDecision,
compatDecision,
compareVersion,
contentHash,
hashedName,
isImmutableName,
resolveAsset,
retirementPlan,
} from "../../src/build/asset-cache.ts";
test("内容哈希稳定、定长,且不同内容得到不同值", () => {
assert.equal(contentHash("body{color:red}"), "053ccf13");
assert.equal(contentHash("body{color:red}"), contentHash("body{color:red}"));
assert.match(contentHash("回声"), /^[0-9a-f]{8}$/);
assert.notEqual(contentHash("回声"), contentHash("回声 "));
});
test("哈希文件名保留目录与扩展名", () => {
const name = hashedName("assets/app.js", "body{color:red}");
assert.equal(name, "assets/app.053ccf13.js");
assert.equal(isImmutableName(name), true);
assert.equal(isImmutableName("assets/app.js"), false);
});
test("缓存头按资源类型区分", () => {
const hashed = cacheDecision("hashed");
assert.match(hashed.cacheControl, /immutable/);
assert.match(hashed.cacheControl, /max-age=31536000/);
assert.equal(hashed.etag, false);
const html = cacheDecision("html");
assert.equal(html.cacheControl, "no-cache");
assert.equal(html.etag, true);
assert.equal(html.immutable, false);
assert.equal(cacheDecision("api").cacheControl, "no-store");
assert.equal(cacheDecision("service-worker").immutable, false);
});
test("版本窗口区分支持、强制升级与未知", () => {
const window = { current: "2.4.0", minimum: "2.0.0" };
assert.equal(compatDecision("2.2.0", window).status, "supported");
assert.equal(compatDecision("1.9.9", window).status, "upgrade-required");
assert.equal(compatDecision("2.4", window).status, "unknown");
assert.match(compatDecision("2.5.0", window).reason, /新于服务端/);
});
test("版本比较按三段数字,不按字符串", () => {
assert.equal(compareVersion("2.10.0", "2.9.9"), 1);
assert.equal(compareVersion("2.0.0", "2.0.0"), 0);
assert.throws(() => compareVersion("2.x.0", "2.0.0"), /无法比较版本/);
});
test("清单解析命中与缺失都给出明确原因", () => {
const manifest = { "app.js": "app.053ccf13.js" };
assert.deepEqual(resolveAsset(manifest, "app.js"), {
ok: true,
path: "app.053ccf13.js",
reason: "命中当前清单",
});
const missing = resolveAsset(manifest, "vendor.js");
assert.equal(missing.ok, false);
assert.match(missing.reason, /vendor\.js/);
});
test("退役计划在灰度窗口内保留旧哈希文件", () => {
const history = [
{ app: "app.1.js", old: "old.1.js" },
{ app: "app.1.js", late: "late.2.js" },
{ app: "app.3.js" },
];
assert.deepEqual(retirementPlan(history, 1), {
keep: ["app.1.js", "late.2.js"],
drop: ["old.1.js"],
});
assert.deepEqual(retirementPlan(history, 2), {
keep: ["app.1.js", "late.2.js", "old.1.js"],
drop: [],
});
});
test("空历史没有可保留或可删除的资源", () => {
assert.deepEqual(retirementPlan([]), { keep: [], drop: [] });
});
在实验工程根目录执行:
node --test test/build/asset-cache.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 内容哈希稳定、定长,且不同内容得到不同值 (0.592875ms)
✔ 哈希文件名保留目录与扩展名 (0.159ms)
✔ 缓存头按资源类型区分 (0.073667ms)
✔ 版本窗口区分支持、强制升级与未知 (0.169875ms)
✔ 版本比较按三段数字,不按字符串 (0.688292ms)
✔ 清单解析命中与缺失都给出明确原因 (0.609625ms)
✔ 退役计划在灰度窗口内保留旧哈希文件 (0.194584ms)
✔ 空历史没有可保留或可删除的资源 (0.069458ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 191.552666
真实构建还要多测两件事:哈希是否只由内容决定(同一份源码重复构建应得到相同名字),以及清单里每个逻辑名是否都能解析到实际存在的文件。前者防止缓存失效,后者防止发布时引用到已经被清理的旧产物。
小结
缓存与版本策略的核心是让 URL 表达内容:内容哈希负责“变”,缓存头负责“存多久”,版本窗口负责“旧客户端还能不能用”,退役计划负责“旧文件什么时候删”。四者缺一不可。
最容易犯的错是把 immutable 当成通用优化,或者把 HTML 和 Service Worker 也设成长期缓存。它们是指针,不是内容。指针必须回源,内容才可以永久缓存。
下一章进入渲染路径:CSR、SSR、SSG 与流式渲染。构建产物决定了“有什么可发”,渲染方式决定了“用户先看到什么”。
阅读导航:上一节:9.2 产物分析与体积预算 · 下一节:10.1 CSR、SSR、SSG 与流式 。
常见错误与边界
- 用构建时间或源码哈希代替产物哈希。内容没变却换名,缓存全部失效。
- 给 HTML 设
immutable。用户会在 max-age 内看不到任何新版本。 - 给 Service Worker 设长期缓存。浏览器连更新检查都不会发出。
- 哈希文件名只在 JS 里使用,HTML 仍引用旧名字。哈希没有闭环。
- 版本窗口只写“最低版本”,不处理高于当前版本的客户端。新客户端会被误判。
- 无法解析的版本当成最新版。写路径应该 fail closed(失败时按不支持处理)。
- 旧文件一退役就删。灰度窗口内旧 HTML 和旧 Service Worker 仍可能引用。
- 退役判断按“出现过的版本”逐条累加。同一哈希会同时进入保留与删除集合,必须按最后出现位置归类。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。