缓存策略需要行为测试,而不只是检查头部是否存在。两个响应即使都带 max-age,也可能因为其中一个要求验证、另一个禁止存储而表现不同。请求本身同样影响结果:浏览器可以绕过已有副本,却不修改源站的响应策略,也不删除更早保存的缓存条目。要知道用户真正拿到什么,必须观察完整的重复请求过程。

双方都要读同一份约定

Cache-Control 指令具有方向性。请求指令不会自动复制到响应里,RFC 9111 还明确将请求指令描述为建议性的要求;响应指令则约束遵守协议的缓存。中间层即使自己不实现缓存,也必须在转发中保留相应缓存指令。部署配置可能另有覆盖规则,但“代理按自己的配置做了”不等于已经证明源站声明的策略得到执行。

应拆开三个问题:这条响应能否存储,当前年龄是否仍在适用的新鲜度寿命内,这次请求是否允许不经验证直接复用。no-store 禁止 HTTP 缓存存储;no-cache 允许存储,但复用前必须成功验证。max-age 设定新鲜度,must-revalidate 则禁止响应过期后未经验证就复用。响应仍然新鲜时,must-revalidate 本身并不要求每次回源,不能把它误解成 no-cache。

private 将存储限制到单用户缓存,public 在其他条件允许时明确授予缓存许可,并不是每一条可缓存响应都必须写 public。s-maxage 覆盖共享缓存的新鲜度,不适用于浏览器私有缓存。Vary 则提供请求匹配维度。这些机制都不会识别应用当前登录的是哪个账户,也不会因为有效期很短,就自动让个人数据变得适合共享。

坏掉的样子

测试时每次添加新的查询参数,会改变缓存键,可能完全掩盖原本的复用行为。在开发者工具中禁用缓存,也可能造成相同误判;某些自动化请求拦截机制同样会禁用 HTTP 缓存。有效测试应在一个隔离上下文中重复访问完全相同的网址,一边统计源站实际收到的请求,一边断言返回正文,而不是仅凭页面看起来没变化就判断命中。

Fetch 的 cache 选项属于浏览器接口,不是响应头。它的 no-store 模式跳过缓存查找,也不把新响应写回 HTTP 缓存;reload 跳过查找,但可以更新缓存。两者都不等于抹除全部旧副本。源站修改响应头,也不能迫使尚未过期的条目立刻回源。浏览器历史恢复、Service Worker 自行保存的内容又是其他机制,不属于下面实验的覆盖范围。

no-cache 也不保证获得 304。只有存在合适的验证器,并且所选表示仍然有效,才有机会节省正文传输。没有 ETag 或 Last-Modified 时,每次访问可能仍返回完整的 200 响应。因此要同时测量回源次数和传输正文,不能看到“每次都请求了源站”就认为重验证一定已经发挥性能收益。

快速验证

本次在 macOS 上使用 Node 26.0.0、Playwright 1.63.0 及其配套 Chromium 153.0.8010.12 无头浏览器验证。将依赖装入临时目录;Linux 还需具备 Playwright 文档要求的浏览器系统依赖。下载需要联网。使用 NODE_PATH="$cache_browser_lab/node_modules" PLAYWRIGHT_BROWSERS_PATH="$cache_browser_lab/browsers" node 执行 JavaScript,测试请求只访问自动分配的回环端口,不使用现有浏览器配置。

cache_browser_lab=$(mktemp -d)
npm install --prefix "$cache_browser_lab" --ignore-scripts --no-audit --no-fund --package-lock=false playwright@1.63.0
PLAYWRIGHT_BROWSERS_PATH="$cache_browser_lab/browsers" node "$cache_browser_lab/node_modules/playwright/cli.js" install chromium --only-shell --no-remove
const assert = require("node:assert/strict");
const http = require("node:http");
const { once } = require("node:events");
const { chromium } = require("playwright");

const policies = new Map([
  ["/fresh", "max-age=3600"],
  ["/must", "max-age=3600, must-revalidate"],
  ["/validate", "no-cache, max-age=3600"],
  ["/discard", "no-store, max-age=3600"],
]);
const hits = new Map();
const server = http.createServer((request, response) => {
  const route = new URL(request.url, "http://127.0.0.1").pathname;
  if (route === "/") {
    response.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
    response.end("<!doctype html><title>Cache experiment</title>");
  } else if (policies.has(route)) {
    const count = (hits.get(route) || 0) + 1;
    hits.set(route, count);
    response.writeHead(200, {
      "Content-Type": "text/plain", "Cache-Control": policies.get(route),
    });
    response.end(`${route}:${count}`);
  } else {
    response.writeHead(404, { "Cache-Control": "no-store" });
    response.end();
  }
});

async function main() {
  server.listen(0, "127.0.0.1");
  await once(server, "listening");
  let browser;
  try {
    browser = await chromium.launch();
    console.log("Chromium", browser.version());
    const context = await browser.newContext({ serviceWorkers: "block" });
    try {
      const page = await context.newPage();
      await page.goto(`http://127.0.0.1:${server.address().port}/`);
      const read = (route, cache = "default") => page.evaluate(async ({ route, cache }) => {
        const response = await fetch(route, { cache });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.text();
      }, { route, cache });
      for (const [route, expectedHits] of [
        ["/fresh", 1], ["/must", 1], ["/validate", 2], ["/discard", 2],
      ]) {
        const first = await read(route);
        const second = await read(route);
        assert.equal(first, `${route}:1`);
        assert.equal(second, `${route}:${expectedHits}`);
        assert.equal(hits.get(route), expectedHits);
        console.log(route, "origin requests:", hits.get(route));
      }
      assert.equal(await read("/fresh", "no-store"), "/fresh:2");
      assert.equal(await read("/fresh"), "/fresh:1");
      assert.equal(hits.get("/fresh"), 2);
      assert.equal(await read("/fresh", "reload"), "/fresh:3");
      assert.equal(await read("/fresh"), "/fresh:3");
      assert.equal(hits.get("/fresh"), 3);
      console.log("no-store bypass retained version 1; reload stored version 3");
    } finally {
      await context.close();
    }
  } finally {
    if (browser) await browser.close();
    await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
  }
}

main().catch(error => { console.error(error); process.exitCode = 1; });

最初四条路由的回源次数依次为一、一、二、二。两个新鲜策略都复用第一次拿到的正文;no-cache 与 no-store 即使搭配很长的 max-age,也会再次访问源站。这里有意不提供验证器,所以回源返回的是完整正文。仅凭这些次数,不能区分 no-cache 和 no-store 的存储资格,实验也没有做出这种推断。

后续 no-store 模式取得版本二,但紧接着的普通请求仍返回先前缓存的版本一。reload 模式取得并保存版本三,下一次普通请求便复用版本三。源站计数器证明这些确实是缓存决策,不是两次响应字符串碰巧相同。最后关闭服务器和隔离浏览器上下文,不改变日常使用的浏览器状态。

这些观察只覆盖这个 Chromium HTTP 缓存和列出的请求,没有验证共享 CDN、重启后的持久化、陈旧响应或账户隔离。它们需要各自的验收。局部实验的价值在于明确客户端实际执行了什么,而不是把一次可复现的本地结果延伸成整条生产交付链都正确的结论。

延伸阅读

来源