《互联网产品的客户端架构·规模化与架构演进》6.2 产物治理与缓存

缓存命中率低,原因往往不是缓存没有生效,而是缓存键没有覆盖真正的输入。本节把内容哈希、工具链、环境变量与依赖产物拼成稳定缓存键,给出命中、键变化、产物缺失与过期四类判定,并用产物清单与体积预算把构建结果变成可审计的事实。命中率只是结果,可重现才是目标。

一次「缓存明明开了」的重复构建

某团队接入远端构建缓存后,仪表盘显示命中率 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 发布流水线与灰度回滚 。

常见错误与边界

  • 用提交号直接当缓存键。不同环境与工具链会得到不同产物,同输入也无法复用。
  • 缓存键遗漏环境变量。预览与生产共用条目,配置差异被静默吞掉。
  • 缓存键遗漏上游产物哈希。上游修复无法传导,下游继续使用旧依赖。
  • 只检查目录存在就判定命中。半清理的目录会让产物缺文件,错误延迟到运行期。
  • 命中后不校验清单。共享缓存中的错误条目会污染所有使用方。
  • 把时间戳写进缓存键。缓存永远不命中,团队却以为缓存服务有问题。
  • 只缓存最终包。中间产物体积大、复用高,却被反复重算。
  • 体积预算只写进报表。没有门禁,超限产物仍然会被发布。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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