为什么你的 CDN 什么都没缓存:那个悄悄挡住它的响应头

agentready.md 上的每个静态文件都带着同一行返回:

cf-cache-status: BYPASS

字体、样式表、脚本、图片。不是 MISS——MISS 表示边缘节点还没见过这个文件,会把刚取回的副本留下。BYPASS 表示边缘节点读完响应,判定自己无权存储,明天还会得出同样的判定。每个访客都在从源站拉走每一个字节,而且本来会一直这样下去。

那四项不是问题的设置

  • Development Mode——关闭。它会把边缘缓存整整停三小时,而且常常开了忘关,所以先查它。
  • Caching Level——Standard。
  • Page Rules——没有一条把 Cache Level 设成 Bypass。
  • Browser Cache TTL——一个合理的值。

控制台关于缓存说的每一句话都是把它存下来。边缘节点什么都没存。

答案在响应里,不在控制台里

只要不再只盯着你专门去找的那一行,而是把整块响应头读完,它立刻就露出来:

curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS

第三行要一年。第四行说明为什么没人给。

Set-Cookie 的响应不能存进共享缓存。 Cookie 属于某一个访客;把这条响应存下来再交给下一个人,等于把别人的 Cookie 递过去。所有 CDN 都会拒绝。Cloudflare 给这个拒绝起了个名字叫 BYPASS,别的 CDN 只是默默不存,什么也不说。

Cookie 是我们自己加的。一个中间件在每个请求上生成 CSRF token 并附带下发,好让站点任何位置渲染出的表单都能就地拿到一个。这个「每个请求」包括对一个 woff2 字体的请求——一个不渲染表单、不读 token、也不执行代码的文件。

修复

不是削弱 CSRF 防护,只是不再给用不上它的东西发 token:

const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
  '/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);

function servesWithoutCookie(path) {
  return COOKIELESS_FILES.has(path)
    || ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
    || path.endsWith('.md');
}

按路径判断,不要按扩展名。有些最需要缓存的响应并不是从磁盘读出来的,而是模板生成的——每个页面的 Markdown 版本、我们的 SVG 徽章——它们同样从不渲染表单。

如果这个 Cookie 不是你写的,去看会话层。不少框架只要请求经过中间件就开一个会话并下发它的 Cookie,哪怕里面从来没存过东西。

在任何技术栈上排查

查一个静态资源,别查首页。首页常常有正当理由无法缓存,而且它对自己拉起的那三十个文件什么也说明不了。

for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
  echo "== $u"
  curl -sI "https://example.com$u" \
    | grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done

同一个 URL 请求两次。第二次应该命中:Cloudflare 上是 cf-cache-status: HIT,CloudFront 上是 x-cache: Hit from cloudfront,Varnish 和 nginx 上是不断增长的 age:。第二次仍然未命中,说明对象根本没被保留,原因就在你刚打印出来的那几行里。

其他常见嫌疑

Cache-Control: private——只给浏览器,永远不进共享缓存。no-store——谁都别存,浏览器也不行。no-cache 的意思不是「别缓存」,而是每次复用前先回源校验,那仍然是每个文件一次往返。

Vary: * 直接让响应无法缓存。Vary: Cookie 几乎一样糟:缓存键里从此带上一个逐访客变化的值,于是什么都共享不了。Vary: User-Agent 会把一个文件炸成成千上万个变体。真正该出现在这里的是 Accept-Encoding

查询字符串。多数缓存以完整 URL 作键,所以一个页面每个带 ?utm_source=?fbclid= 的变体都是一个单独的对象,都要回源。把营销参数在边缘剥掉或归一化。

还有框架的默认值,它算不上 bypass,代价却差不多:每个静态文件都是 max-age=0,意味着每次页面跳转都要为每个资源发一次校验请求。

一份站得住的缓存策略

规则看的是 URL 承诺了什么,而不是文件是什么。

带内容哈希的 URL(app.css?v=9f2ac41bapp.9f2ac41b.css)是一个内容地址:文件变了,URL 就变了。这些给 public, max-age=31536000, immutable,永远没有需要清除的东西。同一个文件如果请求时不带这个令牌,只给一小时:令牌就是那个承诺,没人许过的承诺不该拿来缓存。像 logo 或社交封面这种原地替换的图片,给一周。

然后是那个例外,也是最多人做错的一处,因为这个文件看上去是你手里最该缓存的东西:任何嵌在别人页面里、而你无法为其加版本号的 URL。我们把 badge.js 设成一小时,就是为了这一件事。它跑在我们既不控制 HTML、也不控制 URL 的站点里,一年的 immutable 意味着一个整整一年够不着的 bug。

如果你用 @fastify/static 发文件

两个细节花了我们一个下午。它默认发送 Cache-Control: public, max-age=0。而且如果你用 setHeaders 自己设这个头,还要一并传 cacheControl: false,否则库会用它默认的 maxAge 拼出自己的那份,悄悄盖掉你的——留下的东西看上去还挺像是有意为之:

app.register(fastifyStatic, {
  root: join(__dirname, 'public'),
  cacheControl: false,           // 否则 setHeaders 会被覆盖
  setHeaders: setStaticCacheHeaders,
});

动任何一项缓存设置之前,先把一条响应完整读一遍。弄坏缓存的那个响应头,名字里往往没有 cache。

检查你的网站 · AI 就绪自查清单 · 机器人被允许了吗