浏览器每次都重新下载:HTTP 条件请求失效导致 304 变 200 的六类成因与五步排查

浏览器每次都重新下载:你的 304 协商缓存可能早就不工作了

打开 Chrome 的开发者工具,切到 Network 面板,刷新一下你的网站。如果那些几个月都不会变的 CSS、JS、logo 图片,Size 一栏显示的是实际文件大小(比如 128 kB),而不是灰色的 (memory cache) 或 (disk cache),同时 Status 一直是 200——那你每来一个访客,服务器就要把几百 KB 的静态资源重发一遍。

这不是「缓存没配好」这么笼统的问题,它有非常具体的成因,而且排查起来有明确的判读方法。这篇文章只讲一件事:HTTP 条件请求(conditional request)为什么失效,以及怎么一步步找出来。

一、先把概念理清:两种缓存不是一回事

浏览器的缓存分两层,很多站长混着讲,导致优化时抓错方向。

类型由谁决定头部典型行为
强缓存(freshness)客户端自己判断Cache-Control: max-age=31536000有效期内完全不发请求,直接用本地副本
协商缓存(validation)需要问服务器ETag / Last-Modified发请求带 If-None-Match,服务器回 304 空响应

它们的关系是:强缓存过期后,才会走协商缓存。也就是说,如果你把 max-age 设成一年,那一年内浏览器根本不发请求,304 的问题也就不会暴露——但代价是内容更新后用户死活刷不出来。

真正需要 304 有效的场景是:HTML 文档、以及更新频率中等的内容(比如每天更新的文章列表页、带版本号之外的接口)。这些资源max-age不会设太长,就该让协商缓存接管,避免每次重传整个响应体。

二、304 的完整交互流程

先说清楚一次正常的协商缓存长什么样:

# 第一次请求
GET /style.css HTTP/1.1
Host: www.example.com

HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 48321
ETag: "5f8a3c-abc"
Last-Modified: Tue, 20 Sep 2026 08:12:33 GMT
Cache-Control: no-cache
# 第二次请求(浏览器自动带上验证头)
GET /style.css HTTP/1.1
Host: www.example.com
If-None-Match: "5f8a3c-abc"
If-Modified-Since: Tue, 20 Sep 2026 08:12:33 GMT

HTTP/1.1 304 Not Modified
ETag: "5f8a3c-abc"
Cache-Control: no-cache
(注意:304 没有响应体)

关键点:304 响应不带 body,所以省掉的是那 48KB 的传输,而不是省掉一次请求。对于移动网络下的用户,这个差别很显著。

三、六类导致 304 失效的成因

成因一:根本没输出 ETag 或 Last-Modified

最直接的原因。先测:

curl -sI https://www.example.com/style.css | grep -iE 'etag|last-modified'

如果两条都没有输出,那 304 就不可能发生。Nginx 默认会为静态文件生成 ETag(格式是 "inode大小-修改时间戳"),如果看不到,通常是这三个原因:

  • 配置里写了 etag off;(有人为了「安全」关掉它,其实没必要)
  • gzip 模块开了 gzip_static 但 gzip_vary 相关处理不当
  • 请求经过了一层代理,把头部吃掉了

成因二:ETag 每次都变,导致永远不匹配

这是最隐蔽的一种。ETag 明明存在,但每次请求的值都不一样,浏览器发来的 If-None-Match 永远对不上。实测方法:

for i in 1 2 3; do
  curl -sI https://www.example.com/page.html | grep -i etag
done

三次输出如果不同,就是这个问题。常见原因:

  • 动态页面用「响应内容 MD5」当 ETag,而页面里有时间戳、随机数、CSRF token
  • 多台后端服务器各自用 inode 生成 ETag——同一份文件在不同机器上 inode 不同,ETag 就不同。这是负载均衡下最典型的坑
  • Nginx 里用了 add_header ETag $request_id; 这类自作聪明的写法

修法:要么把 ETag 换成基于内容哈希的稳定值,要么在 Nginx 里剥掉上游的 ETag 让他自己重新生成:

location / {
    proxy_pass http://backend;
    # 剥掉上游不稳定的 ETag,让 Nginx 按自己规则生成
    proxy_hide_header ETag;
    # 同时剥掉可能不对的 Last-Modified
    proxy_hide_header Last-Modified;

    # 只在响应体确定时追加(Nginx 会自动转成 ETag)
    etag on;
}

成因三:中间层改写了响应,ETag 被剥离

如果你的站套了 CDN,要特别小心这一条。Cloudflare 默认会对 HTML 做压缩和改写,并且在响应经过它时可能移除或重算 ETag,同时还可能把 Last-Modified 保留原样——结果就是「有 ETag 但其实对不上」。

诊断方法是对比源站和 CDN 两边的响应头:

# 源站直连(用 --resolve 绕过 DNS,直接打源 IP)
curl -sI --resolve www.example.com:443:203.0.113.10 \
  https://www.example.com/style.css | grep -iE 'etag|last-modified|cf-'

# 经过 CDN
curl -sI https://www.example.com/style.css | grep -iE 'etag|last-modified|cf-'

两边 ETag 不一致 → 问题在 CDN 层,不在你的 Nginx。有些 CDN 提供「保留源站 ETag」的开关,在面板里打开即可。

成因四:Cache-Control 把协商缓存也一并禁了

看一下这几行,它们的作用完全不同:

Cache-Control 值能否走 304说明
max-age=3600能(1 小时后)1 小时内不发请求,之后走协商
no-cache能每次都验证,但验证通过可复用本地副本 ← 名字误导,其实是「允许缓存」
no-store不能完全不存,每次都是完整 200
must-revalidate能过期后必须验证,不许用过期的

最常见的错误是把 no-cache 和 no-store 搞反,或者为了「保证用户看到最新内容」直接上 no-store——那就彻底放弃了 304 的收益。想保证新鲜度又要省流量,正确写法是 Cache-Control: no-cache(配合 ETag)。

成因五:Vary 头缺失导致缓存键冲突

这一点经常连带出更严重的问题。如果你的响应根据 Accept-Encoding 返回不同内容(gzip 或未压缩),但没有输出 Vary: Accept-Encoding,CDN 或代理可能会把 gzip 版本发给不支持 gzip 的客户端,或者反过来——缓存里存了一份,然后 304 的验证基于错误的副本,产生内容错乱。

Nginx 里正确的写法:

gzip on;
gzip_types text/css application/javascript application/json;
gzip_vary on;   # 关键:自动追加 Vary: Accept-Encoding

成因六:请求带了 Cache-Control: no-cache 之类的强制头

如果浏览器端(比如某些前端框架、或用户按了 Ctrl+Shift+R)主动发了 Cache-Control: no-cache 或 Pragma: no-cache,服务器可能直接返回 200 而不是 304。这是正常行为,不是 bug。排查时注意用普通刷新(F5)而不是硬刷新(Ctrl+F5),否则你永远看不到 304。

四、完整的排查流程

按这个顺序走,基本能定位到具体环节:

# 第 1 步:静态资源有没有验证头
curl -sI https://www.example.com/logo.png | grep -iE 'etag|last-modified|cache-control'

# 第 2 步:手动模拟条件请求,看是否返回 304
ETAG=$(curl -sI https://www.example.com/logo.png | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2)
echo "ETag = $ETAG"
curl -sI -H "If-None-Match: $ETAG" https://www.example.com/logo.png | head -1
# 期望:HTTP/2 304

# 第 3 步:连续请求,看 ETag 是否稳定
for i in 1 2 3; do curl -sI https://www.example.com/page.html | grep -i '^etag'; done

# 第 4 步:直接打源站,排除 CDN 干扰
curl -sI --resolve www.example.com:443:203.0.113.10 https://www.example.com/logo.png | grep -iE 'etag|server'

# 第 5 步:看 Nginx 访问日志里 304 的比例
grep -c ' 304 ' /var/log/nginx/access.log
grep -c ' 200 ' /var/log/nginx/access.log

第 2 步是最有价值的一步——它能直接区分「服务器不支持条件请求」和「客户端没发条件请求」这两类完全不同的问题。

五、Nginx 侧的正确配置

一份可以照抄的静态资源与文档配置:

# 带指纹的静态资源(style.a1b2c3.css):可以强缓存一年
location ~* \.(?:css|js|woff2?|ttf|eot|svg|png|jpe?g|gif|webp|avif|ico)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    # Nginx 默认 etag on,这里保持默认即可
    access_log off;
}

# 不带指纹的、可能被直接引用的资源:短强缓存 + 协商
location ~* \.(?:html|htm|xml|json)$ {
    add_header Cache-Control "public, no-cache, must-revalidate";
    etag on;
    # 注意:不要在 location 里用 add_header 覆盖了上级的无关头
}

# 动态页面:允许缓存但每次验证
location ~ \.php$ {
    add_header Cache-Control "public, no-cache";
    proxy_hide_header ETag;   # 若是反代
}

有一个 Nginx 的经典陷阱必须提醒:add_header 在子 location 里出现会丢弃父级所有 add_header(全有或全无)。如果你在 server 块里配了安全响应头,然后在 location 里加了一条 Cache-Control,那些安全头就全没了。修法是把所有 add_header 都提到同一个层级,或者用 add_header ... always 并重复声明。

六、验证优化效果

改完之后不要只看「感觉快了」,用真实数字验证。客户端侧用 DevTools 的 Network 面板看两点:

  • 静态资源的 Size 列是否显示 (disk cache) / (memory cache)
  • 刷新后非缓存资源的 Status 是否变成 304,且 Size 是极小的几百字节

服务端侧统计 304 占比:

# 最近 10000 条请求里 304 的比例
tail -n 10000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn | head

健康的内容站,HTML 文档的 304 比例通常在 20%~40%(回访用户 + 爬虫的重复抓取),静态资源则几乎全是 (disk cache) 命中的、根本不发请求。如果 304 是 0,基本可以确定上面六类成因里至少中了一个。

七、什么时候不该追求 304

  • 带指纹的静态资源:文件内容变了 URL 就变,强缓存一年,immutable 打满,根本不需要协商——这是最优解,别退而求其次去搞 304。
  • 付费墙内容或个性化页面:如果响应内容因人而异,用 304 可能会把 A 用户的缓存副本给 B 用户复用,产生串号。这类必须 private, no-store。
  • API 接口返回敏感数据:同理,协商缓存会引入跨用户复用风险。

八、小结

304 失效不是一个模糊的「缓存问题」,它永远是下面六者之一:没有验证头、ETag 不稳定、中间层改写、Cache-Control 写成了 no-store、Vary 缺失、客户端强制刷新。用本文第四节的五步排查法,多半在第三步就水落石出。修好之后省下的是每个回访用户的重复流量,对带宽有限的个人 VPS 来说,这是性价比最高的一类优化——改的是响应头,不是代码。

Last modification:September 29th, 2026 at 01:25 pm

Leave a Comment