ETag与304:条件请求让缓存真正省流量

一、为什么「加了缓存」反而更慢

很多站长给静态资源加上 Cache-Control: max-age=31536000 之后就认为缓存这件事做完了。实际情况是,浏览器确实把文件存下来了,但用户在发版后的第一次访问、在换了设备的第一次访问、在 HTTP 与 HTTPS 之间跳转时,仍然要走一次完整的下载。而这一次下载的体积,往往比你想的要大得多。

HTTP 里其实有两套缓存机制在同时工作。一套是我们熟悉的「新鲜度」:由 Cache-Control 的 max-age 决定,在有效期内浏览器直接用本地副本,一个字节都不发。另一套是「校验」:由 ETag 和 Last-Modified 配合条件请求头 If-None-Match、If-Modified-Since 决定,浏览器会发一个很小的请求去问服务器「我这份还能用吗」,服务器回 304 Not Modified 或者重新给整个文件。

大部分站点的问题不在于没配新鲜度,而在于校验机制是坏的、或者根本没开。坏掉的校验有两个直接后果:一是过期的资源必须整份重传,白白浪费用户的流量和等待时间;二是 CDN 与回源之间、反向代理与后端之间的二次校验全部失效,每次回源都是完整的 200。这篇文章讲清楚这两套机制怎么配合、ETag 怎么生成才不会自相打架、304 为什么在高并发下反而可能压垮后端,以及怎么用一个脚本从线上把问题量出来。

二、先分清:新鲜度和校验不是一回事

把浏览器的缓存判断想象成一次取书的过程。新鲜度是「这本书还在我桌上,我直接看」,校验是「书不在桌上,但我知道馆里有一本,我去问管理员这本改版了没有」。两者是先后关系:新鲜度先判断,判定过期之后才轮到校验;校验失败(即 304)之后,浏览器会把本地副本的新鲜期重置,重新开始一段 max-age。

这就解释了为什么不带指纹的静态资源要配「短新鲜期 + 强校验」。假设某个 CSS 文件配了 max-age=300:五分钟后它过期了,用户再访问页面,浏览器带上 If-None-Match 去问服务器。服务器比对 ETag 一致,回一个几十字节的 304,浏览器继续用本地那份,同时这个文件的新鲜期又被续上五分钟。整个过程没有传输任何内容,只有一个请求往返。

反过来,如果服务器根本没返回 ETag,500 多 KB 的文件每五分钟就要重下一遍。用户在移动网络上看页面会明显感觉「时不时的卡一下」,而你看访问日志只看到一堆 200,看不出任何异常——因为 200 本来就是「正常」的状态码。

三、ETag 的两种生成方式,以及为什么会互相打架

Nginx 默认就会给静态文件生成 ETag,格式形如 "6742a1c3-2f8",由文件的修改时间和大小拼出。这个 ETag 是弱校验,特点是生成极快、不需要读整个文件。对静态文件来说它已经够用,因为「同一个修改时间 + 同一个大小」基本可以唯一确定内容。

问题出在动态页面。PHP 输出的 HTML 默认没有任何 ETag,如果你用 CDN 或者 Nginx 的 fastcgi_cache 加了一层,最常见做法是让缓存层自己算一个。这里就有第一个坑:多种 ETag 来源同时存在时会互相覆盖或互相矛盾。比如后端通过 header('ETag: "abc"') 发了一个,Nginx 的 fastcgi_cache 又因为配置了 fastcgi_cache_valid 而重算一个,最终到浏览器手上的可能是后者,而 CDN 记的是前者。结果就是每次请求的 ETag 都不一样,浏览器永远拿不到 304。

# 查看线上真实的 ETag 与校验相关头
curl -sI https://www.example.com/style.css?_=1 | grep -iE 'etag|last-modified|cache-control'

# 带上 ETag 做一次条件请求,看是否真的返回 304
ET=$(curl -sI https://www.example.com/style.css | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
curl -s -o /dev/null -w 'code=%{http_code} size=%{size_download}' -H "If-None-Match: $ET" https://www.example.com/style.css

第二条命令是判断「校验到底有没有生效」最直接的手段。如果返回 code=200 而且 size 是文件完整大小,说明 304 没走通;返回 code=304 且 size=0,才说明机制正常。

第二个坑是 gzip。同一份文件在压缩与不压缩两种形态下,字节内容不同,严格来说 ETag 也应该不同。Nginx 的处理方式是:当响应经过 gzip 或 gzip_static 之后,会弱化 ETag(加 W/ 前缀)以保证不同压缩形态之间可以互相校验。如果你自己写了 add_header ETag ... 硬塞一个值,可能就把这个保护破坏了。经验规则很简单:静态文件的 ETag 一律交给 Nginx,不要手写。

四、304 不是免费的:它的成本在哪儿

很多人以为 304 零成本,因为它没有响应体。但一次 304 的前提是服务器已经跑完了计算:动态页面要先连数据库、渲染模板、生成完整 HTML,然后才发现内容没变、可以只回一个 304。也就是说,304 省的是带宽,不省 CPU。请求量大、页面又重的时候,你甚至会看到「上了 ETag 之后后端负载没降」的情况,原因就在这里。

真正既省带宽又省 CPU 的做法,是让 CDN 或者反向代理来回答这类请求。共享缓存层持有内容副本,收到条件请求时由它直接比对并回 304,请求根本不回到 PHP。这也意味着配置顺序很重要:Cache-Control 的 max-age 要先让响应在共享层有一份副本,条件请求才可能被中间层截住。

反过来,如果页面是「因人而异」的(带登录态、带未读数),那么它既不该被共享缓存,也不该给 ETag:因为一旦 CDN 拿它做过条件校验,就可能把 A 的页面用 304 答复给 B。本站历史上不止一次出现「看到别人的头像」这类事故,根因都是同一个方向——把不该共享的东西放进了可共享的链路。

五、指纹化之后,长缓存与短校验怎么组合

现代前端构建几乎都会给文件名加内容指纹,比如 app.7f3a9c2e.js。一旦有了指纹,缓存策略就可以非常激进,而且不需要 ETag 出场:

# 带指纹的静态资源:一年,且不再回源校验
location ~* \.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|svg)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

# 不带指纹的静态资源:短新鲜期 + 依赖 ETag 校验
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2)$ {
    expires 5m;
    add_header Cache-Control "public, max-age=300, must-revalidate";
}

immutable 的作用是告诉浏览器「在这个新鲜期内不要发条件请求」。它对带指纹的文件完全正确,因为文件内容变了文件名就一定变。但它对没有指纹的文件是危险的:用户按刷新都可能拿不到新版本,尤其是部分移动端浏览器。这就是为什么这两段规则必须分开写,而不是全局一条 expires 1y 了事。

还有一处容易忽略的是 HTML 本身。站点的入口 HTML 一定不能长缓存,建议用 Cache-Control: private, no-cache——no-cache 不等于不缓存,它的意思是「可以存,但每次使用前必须校验」。这样用户每次打开页面都能拿到最新的资源引用清单,同时又省掉整页重传。

六、用一个脚本把线上缓存行为量出来

配置写完之后不要凭感觉判断有没有生效。下面这段脚本会依次检查「首次请求的缓存头、带 ETag 的条件请求、带 Last-Modified 的条件请求」三件事,直接输出结论:

#!/bin/sh
# 用法: sh cache_probe.sh https://www.example.com/page.html
U="$1"
H=$(curl -sI -m 10 "$U")
echo "--- headers"
printf '%s' "$H" | grep -iE '^(cache-control|etag|last-modified|age|x-cache|cf-cache-status):'

ET=$(printf '%s' "$H" | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
LM=$(printf '%s' "$H" | grep -i '^last-modified:' | cut -d' ' -f2- | tr -d '\r')

echo "--- if-none-match"
curl -s -o /dev/null -w 'code=%{http_code} bytes=%{size_download} ' -H "If-None-Match: $ET" "$U"

echo "--- if-modified-since"
curl -s -o /dev/null -w 'code=%{http_code} bytes=%{size_download} ' -H "If-Modified-Since: $LM" "$U"

判读方法:etag 为空说明完全没有校验能力;条件请求返回 200 且 bytes 与完整页面一致,说明校验被忽略;两个条件请求都返回 304 且 bytes=0,说明机制正常。如果响应里有 age 或者 cf-cache-status: HIT,说明命中的是共享层,那么 304 有可能就是共享层回的,这属于更好的情况。

七、几个反直觉的结论

第一,ETag 不应该包含服务器标识。有些框架默认生成的 ETag 里带了后端实例编号,导致同一个文件在 A 机器和 B 机器上 ETag 不同。用户在两台机器之间来回命中时,每次都判定不一致、每次都重传。多机部署时务必要么统一 ETag 算法,要么干脆不生成、只依赖 Last-Modified。

第二,Last-Modified 的时间精度只有一秒。在一秒内对同一个文件做两次修改,第二次的 If-Modified-Since 可能判定为「没变化」,用户拿到的还是旧版。这就是为什么 ETag 必须存在:它是字节级或元数据级的判据,不受时间精度限制。只用 Last-Modified 的站点,在频繁改动的场景下必然出现「偶发看到旧版」。

第三,移动端和中间代理会自己乱造校验头。有些运营商代理会缓存并重写响应头,把 ETag 换成自己的值。遇到「只有部分用户看到旧页面」这种报障时,不要只看自家服务器日志,要拿到用户的完整响应头。最省事的判据是在 URL 后面加一个随机查询串,如果加了就正常、不加就异常,那问题一定在某一层共享缓存上。

八、上线与回归的顺序

改动缓存策略永远要有顺序,不然你会在发版当晚收到一堆「样式全乱」的投诉。我的习惯是:先在测试域名上把 cache_probe.sh 跑通,确认 ETag 存在、304 可回;再把带指纹与不带指纹的两条资源规则分开配置;然后才动 HTML 的 Cache-Control;最后才去 CDN 后台改缓存规则。每一步之间观察至少一天,因为 CDN 边缘节点的缓存不会立刻同步。

回归测试很简单,用同一批 URL 连续请求二十次,记录每一次的状态码与 size_download。正常的形态是「首次 200、后续 304、偶尔 200(缓存过期)」,如果出现「每次都是 200 且字节数完全一致」,那就是校验没生效;如果出现「同样的 URL 字节数忽大忽小」,那就是同一份内容在压缩与未压缩形态之间来回切,多半是 Vary 或 Accept-Encoding 配错了。

九、小结

缓存的正确姿势不是把 max-age 调到最大,而是把「新鲜度和校验」这两套机制都配对:带指纹的资源走长缓存加 immutable,不带指纹的资源走短缓存加强校验,HTML 一律短缓存。ETag 交给 Nginx 生成,别手写、别让多层各自生成。判断有没有真的生效,不要看配置,要跑一遍条件请求看是不是 304。

Last modification:November 8th, 2026 at 07:32 pm

Leave a Comment