静态资源指纹:让长缓存真正安全

站点的静态资源一直在更新,CSS 改了、JS 修了个小 bug,发上去刷新页面却还是旧的,要按 Ctrl+F5 才看到新样式。这是缓存问题;反过来,为了避免这个问题把缓存全部关掉,访客每次访问都要重新下载一遍几百 KB 的文件,慢得让人想离开。一个可行的解法是文件指纹(下文简称指纹),也就是在每个静态资源的 URL 里带一段跟内容绑定的字符,内容不变指纹不变、内容一改指纹就变。这篇文章讲清楚它怎么落地,以及落地过程中真正会出错的地方。

一、不做指纹时,只有两种选择,两种都错

没有指纹的站点,静态资源的缓存策略只有两种写法:

  • 强缓存一年:Cache-Control: max-age=31536000。速度最快,但只要改了文件,所有老访客都看不到新版本,只能等缓存过期或者手动清 CDN。
  • 不缓存或很短:max-age=60 或者加 no-cache。每次访问都要回源验证,首屏资源一多,几百个请求同时打过来,服务器出口带宽和响应时间都会变差。

指纹打破了这个二选一:既然 URL 变了,那就是一个「新文件」,浏览器没有任何缓存,必须去取;而老 URL 的缓存虽然还在,但页面上已经不再引用它了。于是可以放心地把 max-age 写到一年。指纹真正的价值不是「让文件更新及时」,而是「让长期缓存变得安全」,这一点想通了,后面的取舍就好做了。

二、嵌入与查询串:两种指纹形式的差别

指纹可以放在文件名里,也可以放在查询参数里:

文件名:/static/app.3f9a1c.css
查询串:/static/app.css?v=3f9a1c

两种都能让浏览器把它当成不同资源,但差别在中间层。查询串版本在不少 CDN 的默认配置里是不参与缓存键的,也就是说 app.css?v=1 和 app.css?v=2 会命中同一份边缘缓存,改了等于没改。另外部分代理服务器对带查询串的静态资源直接不缓存。文件名嵌入则没有这个问题,几乎所有中间层都会把它当成不同路径。

所以结论是:如果整条链路(浏览器、Nginx、CDN)都是自己控制的,查询串够用且实现最简单;只要中间夹了第三方 CDN 或者不确定的代理,就用文件名嵌入。判断方法很直接:改一次文件,然后 curl -sI 新 URL 看 X-Cache 一类的响应头,如果显示命中而内容还是旧的,说明缓存键不含查询串。

三、指纹从哪来:内容哈希,不是时间戳

最省事的做法是拿文件修改时间当指纹,比如 app.css?t=1698600000。它的毛病是:检查发布只 touch 了一下文件、或者打包工具重新生成了一份内容完全相同的产物,指纹就变了,所有访客的缓存集体失效,一次「无意义发布」把所有老用户的缓存全部打掉。站点每天发布十几次的话,等于常年没有缓存。

正确的做法是对文件内容做哈希,取前 8 位即可。八位十六进制有四十亿种可能,对几百个文件的站点来说碰撞概率可以忽略:

md5sum app.css | cut -c1-8

这一步在构建脚本里做,产物目录里同时保留原文件名和指纹文件名,或者只保留带指纹的一份,由模板去引用。用 sha1sum 也行,速度稍慢一点但没有实际影响。如果构建工具本身支持,直接开它的 contenthash 选项比自己写脚本更可靠。

四、HTML 不缓存,静态资源长缓存

指纹方案能成立,前提是 HTML 本身不能被长期缓存。因为只有 HTML 是最新的,它才会去引用新的指纹 URL;HTML 被缓存了,用户拿到的还是旧页面,里面全是旧指纹,整个方案就退化成「强缓存一年」。

所以 Nginx 里要按扩展名分流,而不是全站一刀切:

location ~* \.(css|js|woff2|png|jpg|jpeg|webp|svg)$ {
    expires 365d;
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

location = /index.html {
    add_header Cache-Control "no-cache";
}

location ~ \.php$ {
    add_header Cache-Control "no-cache, must-revalidate";
    # fastcgi 配置
}

immutable 这个指令值得单独说:它告诉浏览器「这个 URL 的内容永远不会变,用户按 F5 也不要回源验证」。这能让刷新操作也走内存缓存,体验更接近秒开。它的前提依然是「URL 里带内容哈希」——如果指纹是时间戳或者手动版本号,immutable 会导致用户按刷新看到旧内容,非常容易引起投诉。

五、最容易出错的地方:HTML 被缓存了

我见过最典型的失败现场是这样的:服务器上配置都对,指纹也正确生成了,直接访问 curl 拿到的 HTML 里确实是新指纹,但用户浏览器里还是旧页面。原因通常是多了一层它不知道的缓存——

  • CDN 缓存了 HTML。很多 CDN 默认对 .html 也缓存,且忽略源站的 Cache-Control,要在控制台单独把 HTML 的缓存规则设为不缓存或者很短。
  • 应用层的页面缓存插件。WordPress 的缓存插件、各类「静态化」插件都会把整页 HTML 存成文件,指纹变了但缓存文件没刷新。
  • 浏览器对 HTML 的激进缓存。少数浏览器在前进后退时会直接使用内存中的页面,不发起请求,这种情况下用户看到旧页面是正常的,刷新一次就好,不必当成故障处理。

定位方法很固定:用 curl -sI 看 HTML 的响应头里有没有 Age 和 X-Cache。Age 大于 0 说明命中了某层缓存,值的大小就是缓存存了多久。这个数字能帮你立刻判断问题在源站、CDN 还是应用层。

六、发布顺序:先上新文件,再换 HTML

有了指纹,不同资源之间就不会互相干扰,但发布过程仍然有顺序问题。如果先把 HTML 传上去、再传资源,那么在中间的几秒里,用户拿到的 HTML 引用了还不存在的文件,页面会白屏或者样式错乱。这个窗口很短,但在流量大的站上一定会有人撞上。

正确顺序是:

  1. 先把带新指纹的资源全部上传到服务器(旧文件保留,不要删)。
  2. 确认每个新 URL 都能返回 200,且 Content-Length 与本地一致。
  3. 再上传 HTML 或刷新页面缓存。
  4. 观察十分钟,确认没有 404 之后,才清理上一版的文件。

如果目录里的文件数量不多,干脆不清理也行,历史文件占的空间通常只有几百 MB,删错一次造成的 404 反倒更难收拾。真要清理,按「上一版指纹」精确删除,不要按时间批量删,因为某些被多个页面引用的公共资源可能已经很久没变过。

七、入口页与第三方的特殊情况

有三类文件不适合加指纹,要单独处理:

favicon、robots.txt、manifest。这些文件的路径是被浏览器或爬虫按固定位置请求的,改了路径就找不到。它们的正确做法是保持固定路径、给短缓存(几小时)。

被外部引用的 JS。如果你做了一个可以嵌到别人站上的小组件,URL 一旦带指纹,别人每次拿到的都是不同地址,没法稳定引用。这类资源用不带哈希的固定路径,缓存设短。

Service Worker。它的作用域由文件所在的目录决定,路径一变批准范围就变了,容易导致旧的 worker 一直接管页面。要么干脆不给它加指纹,要么在更新逻辑里显式处理版本。

八、验证:四个动作确认真的生效

配置完成后按下面四步验收,缺一步都可能留下隐患:

# 1) 抓 HTML,看引用的资源是否带指纹
curl -s https://www.example.com/ | grep -oE '(href|src)="[^"]+\.(css|js)"' | head

# 2) 看静态资源的缓存头
curl -sI https://www.example.com/static/app.3f9a1c.css | grep -i cache-control

# 3) 看 HTML 是否不缓存
curl -sI https://www.example.com/ | grep -iE 'cache-control|age|x-cache'

# 4) 改一个字节重新构建,确认指纹变了、旧文件还在
echo '/* x */' >> app.css && ./build.sh && ls static/ | grep app

第 2 步应该看到 max-age=31536000 和 immutable;第 3 步应该看不到长缓存,Age 要么为空要么很小;第 4 步应该看到两个不同指纹的文件同时存在。四项都对,方案才算真正跑通。

九、小结:指纹解决的是缓存安全的心理负担

回到开头的问题:改了 CSS 用户看不到新样式。指纹把「要不要给静态资源设长缓存」这道难题变成了不需要纠结的事——URL 带内容哈希,改了就换地址,没改就一直用缓存的,两边都占。真正需要盯住的只有三件事:哈希必须基于内容而不是时间戳、HTML 必须不缓存或短缓存、发布时资源先上 HTML 后上。剩下的细节,比如 immutable 用不用、历史文件清理不清理,都是锦上添花,不影响主流程的正确性。

Last modification:October 30th, 2026 at 01:23 pm

Leave a Comment