浏览器缓存失效排查实战:Nginx 缓存头、CDN 边缘节点与 Service Worker 的三层定位法

做个人站长这些年,被问得最多的一句话不是"怎么优化",而是"我明明改了 css,浏览器里怎么还是老样子"。这个问题看起来像玄学,实际上它是整条前端加载链路上最容易出问题、也最容易被误判的一环。Nginx 的缓存头配置、CDN 的边缘节点、浏览器自己的启发式缓存、文件指纹机制,这四层任意一层出问题,症状都是同一句话:改了不生效。

先搞清楚:浏览器"缓存"其实是三个独立的东西

很多新手把"浏览器缓存"当成一个开关,其实它至少由三个互相独立的机制组成,排错时必须分开看,否则会一直在错误的方向上打转。

第一层是强缓存,由响应头 Cache-Control 和 Expires 决定。命中强缓存时,浏览器根本不发请求,直接拿本地副本,你在服务器上把文件删了它都看不见。第二层是协商缓存,由 ETag/If-None-Match 和 Last-Modified/If-Modified-Since 决定。此时浏览器会发请求,服务器回 304,省的是流量不是往返。第三层是启发式缓存,这是绝大多数"改了不生效"的真正元凶——当响应里既没有 Cache-Control 也没有 Expires,浏览器会自己估算一个缓存时长,通常取 (Date - Last-Modified) * 10%,并且这个行为完全不受你控制。

所以第一件事永远是打开 F12 → Network,勾掉 "Disable cache",点开那个有问题的资源,看 Response Headers 里到底有没有 Cache-Control。如果 Size 那一列显示的是 (disk cache) 或者 (memory cache),说明它连请求都没发,问题一定在强缓存或者 HTML 本身的头。

Nginx 配置:哪些文件该长缓存,哪些绝对不能

一个典型的错误是全局加了一条 expires 30d;,然后所有 html 也被缓存了 30 天。html 是入口文件,它一变,里面引用的所有资源路径都变。html 必须短缓存甚至不缓存。

正确的分层策略是这样:带指纹的静态资源长缓存 + immutable,html 用 no-cache 走协商,图片字体中等时长。

server {
    root /www/wwwroot/zz1984;

    # 入口 HTML:永远回源协商,不命中强缓存
    location ~* \.html$ {
        add_header Cache-Control "no-cache, must-revalidate";
        etag on;
        try_files $uri =404;
    }

    # 带指纹的静态资源:一年,immutable 告诉浏览器连刷新都别问
    location ~* \.(css|js|woff2|woff|ttf)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    # 图片:指纹化程度低,给 30 天但不要 immutable
    location ~* \.(png|jpg|jpeg|gif|webp|avif|svg|ico)$ {
        add_header Cache-Control "public, max-age=2592000";
        access_log off;
    }

    # PHP 动态输出:不缓存
    location ~* \.php$ {
        add_header Cache-Control "no-store";
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php7.4-fpm.sock;
    }
}

这里有几个非常容易踩的细节,我按踩坑频率排一下。

坑一:add_header 不会继承,被内层 location 一覆盖全没了

Nginx 的 add_header 有个反直觉的规则:只要内层 location 里出现了任何一条 add_header,外层的全部失效,不是合并而是替换。所以如果你在 server 层写了 add_header X-Frame-Options 和缓存头,然后在某个 location 里为了跨域加了一条 add_header Access-Control-Allow-Origin *,那个 location 里所有的安全头都会静默消失。解决办法是要么在每个 location 里重复写全,要么升级到 Nginx 1.29+ 用 add_header_inherit merge,要么统一 include 一个 headers 片段文件。

坑二:Expires 和 Cache-Control 同时出现会打架

expires 30d; 本身就会生成一条 Cache-Control: max-age=2592000。如果你又在下面手写了一条 add_header Cache-Control "no-cache",最终响应里会出现两条 Cache-Control,而浏览器对多条同名字段的处理是各写各的,实际行为取决于实现,非常危险。选一种就好,不要混用。

坑三:上游把缓存头又盖了一遍

如果你前面套了 CDN 或者用了 PHP 自己输出头,Nginx 加的 add_header 可能会被上游响应里的同名头覆盖。可以用 proxy_hide_header Cache-Control; 先把上游的删掉,再自己 add。用 curl 加 -I 看最终响应头,一次就能确认到底是谁在写这个头。

文件指纹:长缓存能成立的前提

上面给 css/js 配了一年的 immutable,前提是文件名里必须带内容指纹。否则一年之内你改了文件,所有用户都拿不到新版。这是新手最容易忽略的因果关系。

Typecho、WordPress 这类 PHP 站,主题里通常用 filemtime() 做版本号,形如 /usr/themes/handsome/style.css?v=1729300000。这招能用,但有两个问题:一是 URL 变了但文件名没变,部分 CDN 会把不同查询串当成同一个缓存对象(取决于缓存键配置),二是 v 参数只能骗过浏览器,骗不过 immutable 的语义一致性。

真正稳妥的做法是构建时把内容哈希写进文件名:style.a3f9c1.css。如果没有构建流程,退而求其次用 mtime 做 v 参数也行,但此时把 immutable 去掉,改成 max-age=31536000 即可,因为新 v 值也会被当成新 URL。

验证指纹是否生效的方法很土但很好用:改一个字,刷新页面,看 Network 里 css 的请求 URL 变没变。没变就是指纹机制坏了,这时候你配一年缓存等于给自己埋雷。

CDN 层:改了不生效最常见的第二现场

Nginx 配对了,浏览器也清缓存了,还是老样子——大概率是 CDN 边缘节点拿着旧副本。这里要先明确一个概念:CDN 的缓存和浏览器的缓存是两条独立的链路,清浏览器缓存完全不影响 CDN。

排查顺序建议是:先用 curl -I "https://www.zz1984.com/xxx.css?t=$(date +%s)" 带随机参数,绕过 CDN 缓存打到源站,确认源站返回的是新内容和新缓存头。然后再用不带参数的 URL 请求,对比返回头里的 Age、X-Cache、CF-Cache-Status 这类字段。Age: 3600 说明这个副本已经在边缘躺了一小时。

# 绕过 CDN 看源站真实响应头
curl -sI "https://www.zz1984.com/assets/app.css?_=$(date +%s)" | grep -iE 'cache-control|etag|last-modified|age'

# 看 CDN 命中情况(Cloudflare)
curl -sI "https://www.zz1984.com/assets/app.css" | grep -iE 'cf-cache-status|age|expires'

如果是 Cloudflare,cf-cache-status 的值含义值得记住:HIT 命中,MISS 回源,REVALIDATED 协商通过,EXPIRED 过期回源,BYPASS 按规则跳过,DYNAMIC 没被缓存。看到 DYNAMIC 说明这个文件压根没进 CDN 缓存,那你改了立刻生效,问题在别处。

另外要注意 CDN 的"开发模式"开关。Cloudflare 有 Development Mode,开启后静态资源只缓存 3 小时(其实是绕过缓存),很多人开了忘记关,导致流量翻倍还不自知。国内 CDN 一般有"缓存刷新"按钮,改完 css 要主动刷 URL,别指望自动发现。

Service Worker 与磁盘缓存:最隐蔽的第三现场

如果前面三层都查干净了,还是没有更新,请立刻检查 Service Worker。这是最阴险的一种,因为它完全绕过了 HTTP 缓存语义。SW 一旦注册,可以拦截所有请求并从 Cache Storage 返回旧文件,你在 Nginx 上把文件删了它也能给你变出来。

打开 F12 → Application → Service Workers,看有没有 active 的注册。如果有,勾选 "Update on reload" 或者点 Unregister。同时看 Cache Storage 里有没有残留的缓存桶。很多站长的站装了 PWA 插件自己都不知道,之后每次改样式都要跟幽灵打架。

还有一种情况是运营商或者手机浏览器的中间代理缓存。这种情况表现为:你自己电脑上是新的,用手机 4G 访问是旧的,用 WiFi 又是新的。遇到这种只能靠文件名指纹解决——这也是为什么我一直强调指纹比缓存头更重要。

一个可直接照抄的排查 SOP

把上面所有内容压缩成一个固定流程,出问题时按顺序走,通常 5 分钟内能定位:

  1. 确认现象范围:只有一个文件不更新,还是整页都不更新?整页不更新基本是 html 被强缓存了。
  2. 开无痕窗口:无痕窗口没有 SW 也没有磁盘缓存,能一次排除浏览器侧干扰。如果无痕正常,问题在本地缓存或 SW。
  3. curl 带随机参数打源站:确认源站内容确实已经是新的。很多"缓存问题"其实是发布流程没生效,文件根本没上传。
  4. curl 不带参数看 CDN 头:确认 CDN 是否命中旧副本,需要就去刷缓存。
  5. 检查响应头是否有 Cache-Control:没有的话浏览器会启发式缓存,这就是根因,回去补配置。
  6. 检查文件名是否带指纹:没有指纹却配了长缓存,属于配置层面的设计错误,必须改。
  7. 检查 Service Worker:注销并清空 Cache Storage。
  8. 改完在手机上用移动网络复测:这一步能抓住运营商透明代理缓存,也是唯一能验证"真实用户视角"的手段。

几个反直觉的结论

第一,no-cache 不等于不缓存。它的准确含义是"可以存,但每次使用前必须向服务器验证"。真正的不缓存是 no-store。这两个词搞混的人非常多,导致配了 no-cache 却发现还是有缓存,以为配置没生效。

第二,must-revalidate 只在资源过期后才起作用,它约束的是过期后的行为,不是禁止缓存。给 html 配 no-cache 时通常连着写 must-revalidate,这是习惯用法,实际起作用的是前者。

第三,清浏览器缓存这个动作,在 Chrome 里"清除浏览数据"经常清不掉 SW 和 Cache Storage,因为它们在另一个存储分区。要清干净得用 Application 面板逐个删,或者用无痕窗口。这也是为什么很多站长觉得"我清了啊还是不行"。

最后说句经验话:缓存问题的根本解法不是把缓存关掉,而是让缓存可以被安全地永久保留。给资源加指纹、给 html 留短缓存、把 CDN 缓存键设计干净,这三点做到,你一年都不用再碰缓存刷新按钮。反过来,如果指纹机制没建起来,那你配的每一条长缓存都是在给未来的自己挖坑。

Last modification:October 18th, 2026 at 07:23 pm

Leave a Comment