网站静态资源返回304状态码是什么意思?ETag与Last-Modified详解

用浏览器开发者工具或抓包工具看网站请求,会发现图片、CSS、JS 这类静态资源经常返回 304 Not Modified。不少新手看到 304 会紧张,以为是不是出错了,或者跟 404 一样是异常。其实恰恰相反:304 是 HTTP 协议里"我很省"的体现——服务器告诉浏览器"你本地缓存的文件还是最新的,直接用,不用重新下载",是协商缓存命中时返回的状态码,对性能只有好处。

这篇文章把 304 讲透:先解释 304 到底是什么、为什么能省流量,再梳理浏览器缓存的完整流程(强缓存与协商缓存的区别),然后分别讲清 Last-Modified/If-Modified-Since 和 ETag/If-None-Match 两套协商机制的原理与对比,接着给出 Nginx 里 etag、expires、Cache-Control 的真实配置方法,最后落到实战:什么情况 304 是好事、什么情况它反而会坑你,以及"改了文件浏览器不更新"这类高频问题怎么排查。

核心结论先给出:304 不是错误,而是协商缓存命中;它省的是传输流量,但每次仍要发一次请求去问服务器。真正极致省流量的是强缓存(根本不发请求)。理解这两者的区别,才能把缓存策略配对。

一、304 到底是什么

HTTP 状态码 304(Not Modified)属于 3xx 重定向类,但它不跳转地址,而是表达一个语义:客户端发来的条件请求表明,本地缓存版本仍未过期,服务器无需返回内容。

关键特征:

  • 不是错误:不像 404(找不到)、403(无权限)、500(服务器错误),304 是正常且期望发生的结果。
  • 响应没有 body:304 响应体为空,只带少量头部,所以极省带宽。
  • 需要客户端主动发起:客户端要先带上"条件"(上次拿到的 ETag 或 Last-Modified),服务器才可能回 304。
  • 仍是一次网络请求:这点常被忽略——304 省的是 body,请求本身(DNS、TCP、可能的 TLS、协商)还是要走的。

所以 304 与"完全不请求"完全是两码事。

二、浏览器缓存的完整流程

要理解 304,必须先理解浏览器的两级缓存:强缓存和协商缓存。

浏览器请求资源
   │
   ├─ 有强缓存且未过期? ── 是 ──> 直接用本地缓存(200 from cache),不发请求
   │
   └─ 否 ──> 发起请求,带上 ETag / Last-Modified
                │
                ├─ 服务器判断未变 ──> 返回 304,浏览器用本地缓存
                │
                └─ 服务器判断已变 ──> 返回 200 + 新内容

两种缓存的对比:

类型 相关响应头 是否发请求 生效条件
强缓存 Cache-Control: max-age、Expires 不发 本地缓存未过期
协商缓存 ETag/If-None-Match、Last-Modified/If-Modified-Since 要发 强缓存失效后向服务器确认

流程是:先看强缓存,强缓存过期(或没有)才走协商缓存。协商缓存命中就返回 304,未命中返回 200 带新内容。理解这个层级,就能明白 304 发生在"强缓存失效之后"。

三、Last-Modified / If-Modified-Since 机制

这是最早、也最简单的协商缓存方案,基于文件的最后修改时间。

  • 服务器首次响应资源时,带上 Last-Modified: Wed, 01 Oct 2026 10:00:00 GMT。
  • 浏览器下次请求同一资源时,在请求头带上 If-Modified-Since: Wed, 01 Oct 2026 10:00:00 GMT(就是上次那个时间)。
  • 服务器比较:如果文件在这个时间之后没被修改过,返回 304;否则返回 200 和新内容,并更新 Last-Modified。
首次:
  响应头  Last-Modified: Wed, 01 Oct 2026 10:00:00 GMT
再次:
  请求头  If-Modified-Since: Wed, 01 Oct 2026 10:00:00 GMT
  响应     304 Not Modified

局限:

  • 精度只到秒:同一秒内改了两次,第二次改动感知不到。
  • 时间可能不可靠:文件被重新部署、从版本库检出,mtime 变化但内容没变,会误判为"已修改",导致该 304 却 200。
  • 只能表示时间先后,无法表达内容是否真的相同。

四、ETag / If-None-Match 机制

ETag(Entity Tag)是对文件内容的摘要标识,通常是内容哈希(Nginx 默认用 Last-Modified 时间 + 文件大小组合生成一个强 ETag)。

  • 服务器首次响应带 ETag: "5f8a1b-1a2b-2c"。
  • 浏览器下次请求带 If-None-Match: "5f8a1b-1a2b-2c"。
  • 服务器比较 ETag,相同则 304,不同则 200 带新内容和新的 ETag。
首次:
  响应头  ETag: "5f8a1b-1a2b-2c"
再次:
  请求头  If-None-Match: "5f8a1b-1a2b-2c"
  响应     304 Not Modified

优势:基于内容而非时间,文件内容没变即使 mtime 变了也能正确命中 304。ETag 分两类:

  • 强 ETag:ETag: "abc",要求内容逐字节一致。
  • 弱 ETag:ETag: W/"abc",允许语义等价(差别不显著)。

注意多台服务器的坑:如果静态资源由多台后端/Nginx 提供服务,且 ETag 生成算法里包含 Last-Modified 或 inode 等每台机器不同的信息,同一文件在不同机器上 ETag 会不一致,用户在机器间切换就会反复 200。解决方式是统一 ETag 生成规则,或用内容哈希。

五、两套机制对比与取舍

维度 Last-Modified ETag
依据 文件修改时间 内容标识(哈希/时间+大小)
请求头 If-Modified-Since If-None-Match
响应头 Last-Modified ETag
精度 秒级 内容级(更精确)
误判风险 mtime 变化但内容没变会误判 更准,但多机不一致会误判
服务器开销 小 需计算标识,略大
优先级 同时存在时 ETag 优先 优先

两者可以同时存在,此时浏览器会同时发送 If-None-Match 和 If-Modified-Since,服务器以 ETag 结果为准。多数场景保留两者即可,Nginx 默认都开。若遇到多机 ETag 不一致的顽固问题,可以考虑关闭 etag 只用 Last-Modified,或改用内容哈希方案。

六、Nginx 里的静态资源缓存配置

Nginx 有几个和缓存直接相关的指令:

server {
    listen 80;
    server_name static.example.com;

    location ~* \.(jpg|jpeg|png|gif|css|js|woff2?)$ {
        root /var/www/static;

        # 协商缓存:ETag 默认开启(on)
        etag on;

        # 强缓存:告诉浏览器在本地直接用,不发请求
        # 下面两行等价性:expires 会自动生成 Cache-Control max-age
        expires 30d;

        # 更精细地自定义 Cache-Control(会覆盖 expires 生成的)
        # add_header Cache-Control "public, max-age=2592000, immutable";

        # 关掉强缓存,强制每次走协商缓存(调试用)
        # add_header Cache-Control "no-cache";
    }
}

要点:

  • etag on:Nginx 默认开启 ETag,一般不用改。设为 off 可关闭。
  • expires 30d:设置强缓存 30 天,同时生成 Cache-Control: max-age=2592000 和 Expires 头。30d 是常见经验值;带内容哈希(文件名含指纹)的资源可以设很久(如 1 年),不带指纹的 HTML 建议设短或不缓存。
  • add_header Cache-Control "no-cache":注意 no-cache 不是"不缓存",而是"可以缓存但每次必须向服务器确认",效果就是每次走协商缓存、可能返回 304。真正不缓存是 no-store。
  • 改完校验重载:nginx -t && nginx -s reload。

常见缓存头语义对照:

响应头 含义
Cache-Control: max-age=2592000 强缓存 30 天,期间不发请求
Cache-Control: no-cache 可缓存,但每次必须协商(走 304)
Cache-Control: no-store 完全不缓存
Cache-Control: immutable 声明内容永不变化,配合长 max-age 减少刷新请求
Expires: <日期> 老式强缓存头,被 Cache-Control 优先覆盖

七、304 是好事还是"坑"?

多数情况下 304 是好事:省流量、省后端计算、加快二次访问。但它也有需要警惕的场景:

  • 强缓存没设好,导致每次请求都走 304:虽然省了 body,但每次都要发请求+往返,比"根本不发请求"慢得多。理想做法是给带指纹的静态资源设长 max-age,让它们根本不发请求;只有 HTML 等易变资源才走 304。
  • HTML 页面返回 304 导致内容不更新:如果 HTML 被缓存,用户改了内容后仍看到旧页面。HTML 通常应设 no-cache 或很短的 max-age。
  • 把 304 当成"没请求成功":开发时看到 304 就以为接口没返回,其实是缓存命中。调试时可在 DevTools 勾选 "Disable cache" 或强制刷新,看到 200。

八、排障:该 304 却 200、该更新却不更新

现象 可能原因 处理
每次刷新都 200,不走 304 ETag/Last-Modified 缺失或多机不一致 检查响应头;统一 ETag 规则;etag on
文件改了浏览器还显示旧的 强缓存 max-age 太长 缩短 max-age,或给文件名加内容指纹
该 304 却总是 200 且反复下载 ETag 随时间/机器变化 关闭 etag 用 Last-Modified,或统一算法
强制刷新(Ctrl+F5)看不到 304 强刷会跳过缓存 正常现象,用普通刷新或 DevTools 观察
部分用户正常部分不更新 多台服务器 ETag 不一致 统一多机的 ETag 生成,或用哈希文件名

用 curl 验证缓存头是否如预期:

# 看响应头里的 ETag / Last-Modified / Cache-Control
curl -I https://static.example.com/app.js

# 模拟带条件请求,验证是否返回 304
ETAG=$(curl -sI https://static.example.com/app.js | grep -i etag | awk '{print $2}' | tr -d '\r')
curl -I -H "If-None-Match: $ETAG" https://static.example.com/app.js | head -1
# 期望看到:HTTP/2 304

常见问题

Q:304 是错误吗?需要处理吗?
不是错误,是协商缓存命中的正常结果。通常不需要处理;如果发现静态资源频繁返回 304(每次都发请求协商),可以考虑给它们配长强缓存,让浏览器直接用本地副本,连请求都省掉。

Q:no-cache 和 no-store 有什么区别?
no-cache 允许缓存,但每次使用前必须向服务器确认(可能得到 304);no-store 是完全禁止缓存,每次都重新下载。想"改了就更新"用 no-cache,想"绝不落盘"用 no-store。

Q:为什么我改了 CSS,用户看到的还是旧的?
多半是强缓存 max-age 设得太长且文件名没变。最稳妥的做法是给静态资源文件名加内容指纹(如 app.3f8a.js),内容一变文件名就变,配合长强缓存,既快又不会看到旧文件。

Q:ETag 和 Last-Modified 同时存在,用哪个?
以 ETag 为准。浏览器会同时发送两个条件,服务器比较 ETag 决定是否 304。若多机环境 ETag 不一致造成反复重下,可考虑关闭 ETag 只用 Last-Modified,或统一 ETag 生成规则。

Q:调试时怎么避免 304 干扰?
DevTools 的 Network 面板勾选 "Disable cache"(需保持工具打开),或用 Ctrl+F5/Cmd+Shift+R 强制刷新,会跳过所有缓存看到真实响应;也可以用 curl 带 -H 'Cache-Control: no-cache' 观察。

总结

304 是协商缓存命中的正常状态,含义是"本地缓存仍有效,无需重新传输内容",它省的是响应体的流量,但请求本身仍要发生。

浏览器缓存分两级:强缓存(Cache-Control: max-age)命中时不发请求;强缓存失效后走协商缓存(ETag/Last-Modified),命中返回 304、未命中返回 200。

Last-Modified 基于时间、精度到秒、有误判风险;ETag 基于内容标识、更精确,但多机生成规则不一致会踩坑。两者同时在时以 ETag 为准。

Nginx 侧保持 etag on,用 expires 或 add_header Cache-Control 配置强缓存;带指纹的静态资源设长 max-age,HTML 用 no-cache 或短缓存。

别把 304 当错误;真正影响体验的是"该用强缓存却每次协商"和"该更新却因长缓存看到旧文件",前者靠长 max-age + 文件名指纹解决,后者靠缩短 HTML 缓存解决。