
# 缓存失效：为什么你的 ?v= 多半什么都没做

我们的导航在生产环境上有半个下午完全没有样式。HTML 是对的，服务器上的 CSS 文件是对的，部署也没有报错。在有人看对地方之前，这个问题被误诊了两次。

`app.css` 发布时根本没带 `?v=`。Cloudflare 把它缓存了四个小时，浏览器留得更久。那次发版重命名了一批 CSS 类名，于是新的 HTML 送到了一个仍然握着上一版样式表的浏览器：标记里的每一个类名都对不上任何规则。

能说明这是缓存而不是部署的线索，恰恰也是它难以被发现的原因：强制刷新就好了，第一次打开站点的人看到的是正常页面。冷缓存和一次成功的部署无法区分，所以用干净配置文件测试的人从来没能复现。

`app.js` 倒是有 `?v=`，而那比没有更糟。它把 `package.json` 里的版本号插了进来：

```html
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
```

这个数字在两次发版之间不会动，而我们发布的次数远多于抬版本号的次数。于是 JS 带着一个令牌，任何读模板的人都会以为那是缓存失效，实际上它什么都没让失效。和 CSS 一模一样的 bug，离触发只差一次发版，前面还立着一个幌子。

## 对文件做哈希

缓存失效令牌只有一个职责：文件变了它就变，文件没变它就不变。唯一能做到这一点的值，来自文件自身的字节。

```js
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';

function assetVersion(relativePath) {
  const buf = readFileSync(join(__dirname, 'public', relativePath));
  return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}

const cssVersion = assetVersion('css/app.css');
```

启动时读一次就够：这些是构建产物，不会在运行中的进程底下变化。八位十六进制字符绰绰有余，SHA-1 在这里也够用，因为你要避免的是自己文件新旧两版意外撞车，而不是防一个能自选内容的攻击者。

这样模板就不再撒谎了：

```html
<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">
```

人们通常改用的另外三种令牌：

**版本号**（`?v=1.4.2`）在有人想起来改它的时候才变，这跟文件变了不是一回事。我们的 `app.js` 就是这样。

**时间戳或构建 ID** 确实每次部署都变，所以永远不会发旧内容，但它在什么都没变的时候也会变。哪怕一个字节都没动，每次部署都会丢掉所有访客缓存的 CSS 和每个 CDN 节点上的副本，一次只改了后端的发版照样让每个用户重新下载一遍。

**随机值**（`?v=<%= Math.random() %>`，真有人这么写）每次渲染页面都是一个新 URL。它不是让缓存失效，而是对所有人永久关掉了缓存。

只有内容哈希两头都对：不会发旧的，也不会在没变化时重新下载。

## 查询串还是文件名

`app.a1b2c3d4.css` 把哈希放进文件名而不是查询串，做的是同一个承诺。它需要一个能重写全部引用的构建步骤，所以用打包器的项目通常有，服务端渲染的项目通常没有。

支持文件名的老理由是：有些缓存在算缓存键时会忽略查询串，那样 `?v=` 就成了装饰。这至今仍是一个可能被误开的设置，Cloudflare 的缓存级别里就有一项 “ignore query string”，一旦打开，你站点上所有的 `?v=` 都会悄无声息地失去作用。依赖它之前先确认一下。

## `immutable` 承诺的是 URL，不是文件

`Cache-Control: public, max-age=31536000, immutable` 告诉浏览器不要重新校验，用户按刷新也不要。只有当 URL 是内容地址时这句话才成立，这么一说规则就自己写出来了：

```js
if (/\.(css|js)$/.test(name)) {
  const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
  res.setHeader('Cache-Control', versioned
    ? 'public, max-age=31536000, immutable'
    : 'public, max-age=3600');
}
```

同一个文件，不带令牌请求就只给一小时。令牌就是那个承诺，没人许过的承诺不该换来一年的缓存。

还要确认静态文件中间件没有把它覆盖掉。`@fastify/static` 默认发 `max-age=0`；如果你设置了响应头却没有关掉它自己的 `cacheControl`，它会用那个默认值重新拼出响应头，悄悄盖掉你的。头还在，看着仍然像是有意为之，但它是错的。

字体我们按文件名标成 immutable。子集化的字体是靠改名替换而不是就地编辑，文件名本身已经是内容地址。随代码一起放进仓库的库同理：`alpine-3.14.8.min.js` 把版本写在名字里，没人会去原地改它。

图片给一周，不给一年。它们没有版本标识，而 logo 和 favicon 确实会在同一个地址上被替换。一周长到有意义，也短到能纠正一次失误。

## 必须保持短命的那个文件

我们对外提供一个徽章脚本，它运行在别人的页面里。他们的 HTML 和他们嵌入的 URL，我们都管不到。在那里挂上一年的 `immutable`，意味着一个你整整一年都够不着的 bug：没有办法作废，也没有办法请每一个贴了标签的站点去改。所以它只有一小时，哪怕这个文件几乎从不改动，这也是对的。

任何放在你无法加版本的 URL 上的东西都是这样。决定它缓存时长的，是你需要多快能修好它，而不是它多久变一次。

## 自己验证

HTML 里的令牌和磁盘上的文件对得上吗？

```bash
curl -s https://yoursite.com/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8
```

应该是同一个字符串，否则你的 HTML 指向的东西并不是它描述的那个。如果两次都改过 CSS 的部署之间令牌纹丝不动，那你手上就是我们那个 `app.js`。

然后问问资源本身返回了什么：

```bash
curl -sI 'https://yoursite.com/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
```

抓出过期节点的是 `last-modified`。如果它早于你最后一次部署该文件的时间，说明你的服务器和浏览器之间有东西还攥着旧副本，而这件事会不会伤到你，取决于你 HTML 里的那个令牌。

[检查你的网站](/zh) · [AI 就绪清单](/zh/tools/ai-readiness-checklist) · [我们测量什么](/zh/about)
