缓存失效:为什么你的 ?v= 多半什么都没做
我们的导航在生产环境上有半个下午完全没有样式。HTML 是对的,服务器上的 CSS 文件是对的,部署也没有报错。在有人看对地方之前,这个问题被误诊了两次。
app.css 发布时根本没带 ?v=。Cloudflare 把它缓存了四个小时,浏览器留得更久。那次发版重命名了一批 CSS 类名,于是新的 HTML 送到了一个仍然握着上一版样式表的浏览器:标记里的每一个类名都对不上任何规则。
能说明这是缓存而不是部署的线索,恰恰也是它难以被发现的原因:强制刷新就好了,第一次打开站点的人看到的是正常页面。冷缓存和一次成功的部署无法区分,所以用干净配置文件测试的人从来没能复现。
app.js 倒是有 ?v=,而那比没有更糟。它把 package.json 里的版本号插了进来:
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
这个数字在两次发版之间不会动,而我们发布的次数远多于抬版本号的次数。于是 JS 带着一个令牌,任何读模板的人都会以为那是缓存失效,实际上它什么都没让失效。和 CSS 一模一样的 bug,离触发只差一次发版,前面还立着一个幌子。
对文件做哈希
缓存失效令牌只有一个职责:文件变了它就变,文件没变它就不变。唯一能做到这一点的值,来自文件自身的字节。
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 在这里也够用,因为你要避免的是自己文件新旧两版意外撞车,而不是防一个能自选内容的攻击者。
这样模板就不再撒谎了:
<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 是内容地址时这句话才成立,这么一说规则就自己写出来了:
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 里的令牌和磁盘上的文件对得上吗?
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。
然后问问资源本身返回了什么:
curl -sI 'https://yoursite.com/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
抓出过期节点的是 last-modified。如果它早于你最后一次部署该文件的时间,说明你的服务器和浏览器之间有东西还攥着旧副本,而这件事会不会伤到你,取决于你 HTML 里的那个令牌。