站点的静态资源一直在更新,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 引用了还不存在的文件,页面会白屏或者样式错乱。这个窗口很短,但在流量大的站上一定会有人撞上。
正确顺序是:
- 先把带新指纹的资源全部上传到服务器(旧文件保留,不要删)。
- 确认每个新 URL 都能返回 200,且 Content-Length 与本地一致。
- 再上传 HTML 或刷新页面缓存。
- 观察十分钟,确认没有 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 用不用、历史文件清理不清理,都是锦上添花,不影响主流程的正确性。
