一次「缓存明明开了」的重复构建
某团队接入远端构建缓存后,仪表盘显示命中率 61%,但发布耗时几乎没有下降。抽查一条日志发现,同一个提交连续构建两次,第二次仍然执行了完整打包;缓存服务里躺着上一次的产物,键却对不上。
排查结果是三类输入没有被写进键:构建脚本读取的 NODE_ENV、打包器的小版本号、以及上游包产物的哈希。任何一项变化,产物都可能不同,但缓存键没有变化,于是缓存只能「保守地」判为未命中。团队把命中率当成缓存配置问题,实际问题是缓存键没有覆盖真正决定产物的输入。
缓存键是输入的函数,不是提交号的别名
把 commit SHA 当缓存键最省事,也最没用:同一个提交在不同环境、不同工具链、不同依赖产物下可能得到不同结果;反过来,两个不同提交只要输入相同,就应该复用同一份产物。
一个可用的缓存键至少包含五段:
| 段 | 回答的问题 | 变化后的后果 |
|---|---|---|
task | 在构建什么 | 不同任务的产物不能混用 |
sourceHash | 本包源码是什么 | 源码变了仍复用旧产物 |
toolchain | 用什么工具与版本 | 工具升级后产物格式可能变化 |
env | 构建时环境是什么 | 环境分支被错误地当成同一份 |
deps | 上游产物是什么 | 上游修复没有传导到下游 |
这五段要按稳定顺序序列化后再哈希。直接拼字符串容易被分隔符、键顺序和 Unicode 规范化影响;先做规范化 JSON,再对结果哈希,才能保证「同样的输入得到同样的键」。
产物清单:命中的证据
缓存命中不能只看「目录存在」。目录可能被清理了一半,文件可能被其他任务覆盖,压缩产物的时间戳也可能不同。命中判定至少要核对三件事:
| 检查 | 回答的问题 | 失败后的动作 |
|---|---|---|
| 键一致 | 输入是否相同 | 重新构建并写新条目 |
| 产物齐全 | 清单里的文件是否都在 | 重新构建缺失部分 |
| 清单一致 | 大小与哈希是否匹配 | 丢弃条目,避免污染下游 |
产物清单本身也要有摘要。清单摘要让下游可以把「上游产物」作为一个值写进自己的缓存键;没有摘要,下游只能依赖路径或时间戳,无法判断上游是否真的变了。
体积预算:把「能构建」变成「可交付」
构建成功不等于可以发布。客户端产物直接进入用户设备,体积、文件数量与最大单文件都会影响下载、安装与启动。预算应该是发布门禁的一部分,而不是事后报表。
一个实用的预算报告只回答三个数:总量、超出量、最大的几个文件。总量判断是否超限,超出量决定要砍多少,最大文件指出先看哪里。预算值可以按端设置,但必须写进流水线;写在文档里的预算不会阻止任何人发布。
把缓存与清单写成代码
下面的模块只依赖 node:crypto,不读文件系统。哈希、键构造、命中判定、清单校验与预算计算都是纯函数,方便在 CI、本地脚本与测试里复用。
// file: src/build/artifact-cache.ts
// tier: L1
// verify: node --test test/build/artifact-cache.test.ts
import { createHash } from "node:crypto";
export type FileDigest = {
path: string;
size: number;
hash: string;
};
export type ArtifactManifest = {
files: FileDigest[];
totalBytes: number;
digest: string;
};
export type CacheKeyInput = {
task: string;
sourceHash: string;
toolchain: string;
env: Record<string, string>;
deps: Record<string, string>;
};
export type CacheEntry = {
key: string;
createdAt: number;
ttlMs?: number;
manifest: ArtifactManifest;
};
export type CacheDecision = {
status: "hit" | "miss";
reason: "hit" | "no-entry" | "key-changed" | "expired" | "artifact-missing";
missing: string[];
};
export type BudgetReport = {
totalBytes: number;
budgetBytes: number;
overBy: number;
withinBudget: boolean;
largest: FileDigest[];
};
export function hashContent(content: string | Uint8Array): string {
return createHash("sha256").update(content).digest("hex");
}
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
const body = keys
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",");
return `{${body}}`;
}
export function hashFiles(files: Record<string, string>): FileDigest[] {
return Object.keys(files)
.sort()
.map((path) => {
const content = files[path];
return {
path,
size: Buffer.byteLength(content, "utf8"),
hash: hashContent(content),
};
});
}
export function buildCacheKey(input: CacheKeyInput): string {
const canonical = canonicalJson({
task: input.task,
sourceHash: input.sourceHash,
toolchain: input.toolchain,
env: input.env,
deps: input.deps,
});
return `${input.task}-${hashContent(canonical).slice(0, 16)}`;
}
export function buildManifest(files: Record<string, string>): ArtifactManifest {
const digests = hashFiles(files);
const totalBytes = digests.reduce((sum, item) => sum + item.size, 0);
const digest = hashContent(canonicalJson(digests));
return { files: digests, totalBytes, digest };
}
export function evaluateCache(
entry: CacheEntry | undefined,
desiredKey: string,
existingFiles: Iterable<string>,
now: number = Date.now(),
): CacheDecision {
if (!entry) {
return { status: "miss", reason: "no-entry", missing: [] };
}
if (entry.key !== desiredKey) {
return { status: "miss", reason: "key-changed", missing: [] };
}
if (
entry.ttlMs !== undefined &&
now - entry.createdAt > entry.ttlMs
) {
return { status: "miss", reason: "expired", missing: [] };
}
const present = new Set(existingFiles);
const missing = entry.manifest.files
.map((file) => file.path)
.filter((path) => !present.has(path))
.sort();
if (missing.length > 0) {
return { status: "miss", reason: "artifact-missing", missing };
}
return { status: "hit", reason: "hit", missing: [] };
}
export function verifyManifest(
manifest: ArtifactManifest,
actual: Record<string, { size: number; hash: string }>,
): string[] {
const problems: string[] = [];
const listed = new Set<string>();
for (const file of manifest.files) {
listed.add(file.path);
const found = actual[file.path];
if (!found) {
problems.push(`missing:${file.path}`);
continue;
}
if (found.size !== file.size) problems.push(`size:${file.path}`);
if (found.hash !== file.hash) problems.push(`hash:${file.path}`);
}
for (const path of Object.keys(actual)) {
if (!listed.has(path)) problems.push(`extra:${path}`);
}
return problems.sort();
}
export function checkSizeBudget(
entries: FileDigest[],
budgetBytes: number,
): BudgetReport {
const totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0);
const largest = [...entries]
.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path))
.slice(0, 3);
return {
totalBytes,
budgetBytes,
overBy: Math.max(0, totalBytes - budgetBytes),
withinBudget: totalBytes <= budgetBytes,
largest,
};
}
evaluateCache 的判定顺序值得注意:先看有没有条目,再看键,再看 TTL,最后才检查产物是否齐全。键不一致时不需要检查文件,因为即使文件都在,它们也不对应这次输入;TTL 过期时也不需要,因为条目已经不被信任。顺序让未命中的原因唯一,日志才能直接指向修复动作。
verifyManifest 同时报告缺失、大小不符、哈希不符与多余文件。多余文件不是噪音:它可能意味着上一次构建没有清理干净,产物里混入了旧代码。只检查清单内的文件,会漏掉这类污染。
测试与真实输出
在实验工程根目录执行:
node --test test/build/artifact-cache.test.ts
本机一次运行的真实输出末尾如下,耗时随机器变化:
✔ 完整清单校验没有问题 (0.075709ms)
✔ 体积在预算内时报告通过 (0.099667ms)
✔ 体积超预算时报告超出字节 (0.045042ms)
✔ 内容变化改变清单摘要 (0.054334ms)
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 154.792084
这些用例固定了键的稳定性、四类未命中原因、清单校验与预算边界。它们不测试远端存储的并发、租约与垃圾回收,那些属于缓存服务本身。
判据与反例
判断缓存是否可信,看下面五条:
| 判据 | 通过的样子 | 反例 |
|---|---|---|
| 键覆盖输入 | 改动环境或工具链会换键 | 只哈希源码,工具升级后复用旧产物 |
| 键稳定 | 键顺序无关、序列化确定 | 环境变量书写顺序改变导致未命中 |
| 命中可解释 | 每次未命中都有唯一原因 | 只记录 hit/miss,不记录为什么 |
| 清单可校验 | 大小与哈希都能对上 | 只检查目录存在 |
| 预算可阻断 | 超限时流水线失败 | 预算只出现在周报里 |
第一个反例是「缓存键包含时间」。加上时间戳可以避免脏读,但会让缓存永远不命中;正确的做法是让键只包含输入,用 TTL 与清理策略处理陈旧,而不是把时间混进键。
第二个反例是「缓存命中就跳过校验」。缓存服务可能被多人共享,条目也可能被错误写入。命中后至少校验清单摘要;对安全敏感的产物,还要校验签名或来源。
第三个反例是「只缓存最终包」。中间产物(转译结果、类型声明、资源清单)往往才是构建时间的大头。只缓存最后一层,命中率看起来不错,实际省下的时间很少。
小结
缓存不是「打开一个开关」,而是把构建输入写成可比较的值。
- 缓存键必须覆盖源码、工具链、环境与依赖产物,并用规范化序列化保证稳定。
- 命中判定要区分无条目、键变化、过期与产物缺失,原因唯一才能指导修复。
- 产物清单与体积预算把「构建成功」变成「可审计、可交付」。
先让键可信,再追求命中率。键不可信时,命中率越高,风险越大。
阅读导航:上一节:6.1 Monorepo 与包边界 · 下一节:6.3 发布流水线与灰度回滚 。
常见错误与边界
- 用提交号直接当缓存键。不同环境与工具链会得到不同产物,同输入也无法复用。
- 缓存键遗漏环境变量。预览与生产共用条目,配置差异被静默吞掉。
- 缓存键遗漏上游产物哈希。上游修复无法传导,下游继续使用旧依赖。
- 只检查目录存在就判定命中。半清理的目录会让产物缺文件,错误延迟到运行期。
- 命中后不校验清单。共享缓存中的错误条目会污染所有使用方。
- 把时间戳写进缓存键。缓存永远不命中,团队却以为缓存服务有问题。
- 只缓存最终包。中间产物体积大、复用高,却被反复重算。
- 体积预算只写进报表。没有门禁,超限产物仍然会被发布。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。