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

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

```
cf-cache-status: BYPASS
```

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

## 那四项不是问题的设置

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

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

## 答案在响应里，不在控制台里

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

```bash
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：

```js
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，哪怕里面从来没存过东西。

## 在任何技术栈上排查

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

```bash
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=9f2ac41b`、`app.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` 拼出自己的那份，悄悄盖掉你的——留下的东西看上去还挺像是有意为之：

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

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

[检查你的网站](/zh) · [AI 就绪自查清单](/zh/tools/ai-readiness-checklist) · [机器人被允许了吗](/zh/tools/ai-crawlers-checker)
