ETag 配置错了,CDN 每月多回源三个 TB
去年年中,一家 CDN 服务商给我打电话,说我的回源流量超过了套餐额度,建议我升级套餐。我当时的第一反应是「不可能,我的文章页都是纯文本,图片都在对象存储上」。登录 CDN 后台看明细,回源带宽曲线确实一路上涨,而且集中在静态资源路径上——CSS、JS、以及几十万张缩略图。
抓了几个请求对比才发现问题:浏览器请求图片时,带的是 If-None-Match,CDN 也把请求转到了源站,源站返回 304 Not Modified。理论上 304 响应体很小,回源流量应该可以忽略。但问题在于,我的 Nginx 用 etag on 生成的 ETag 里带着文件的 inode 号,而后端是 rsync 从另一台机器同步过来的——每台机器的 inode 都不一样,每次同步后 inode 也可能变。于是每次同步,全站的 ETag 都变了,CDN 上缓存的全部「未修改」判断失效,几十万张图片在用户下次访问时全部重新下载。
这件事让我把 HTTP 条件请求这套机制彻底梳理了一遍。它平时不出现在任何「性能优化清单」里,但一旦配错,代价是真实的账单。这篇文章讲清楚 ETag 在 Nginx 里到底是怎么生成的、Last-Modified 和 ETag 的分工、以及多机部署和 CDN 场景下最容易踩的几个坑。
条件请求的基本机制:三个头和三种结果
浏览器缓存一份资源后,下次再请求时不会直接重新下载,而是先「问一下」源站这份缓存还能不能用。用的就是条件请求:
| 请求头 | 含义 | 对应的响应头 |
|---|---|---|
If-None-Match: "abc123" | 我本地有 ETag 为 abc123 的副本 | ETag: "abc123" |
If-Modified-Since: Sat, 20 Oct 2026 10:00:00 GMT | 我本地的副本是这个时间之后拿到的吗? | Last-Modified: ... |
If-Match / If-Unmodified-Since | 写操作时的乐观锁 | — |
源站的回答只有三种:
- 200 + 完整内容:缓存失效,重新传一遍。
- 304 Not Modified:缓存有效,客户端继续用本地副本。响应体为空,只有头部。
- 412 Precondition Failed:只用于
If-Match这类写场景,读请求不涉及。
规范里有个要求很容易被忽略:如果请求同时带了 If-None-Match 和 If-Modified-Since,源站必须优先判 If-None-Match,只有当它不存在时才看时间(RFC 9110 第 13.2.2 节)。原因是 ETag 的精度比秒级时间戳高得多——同一秒内改两次文件,Last-Modified 完全看不出来,而 ETag 会变。
# 手工试一次条件请求,看源站到底怎么回的
curl -sI "https://www.example.com/static/app.css" | grep -iE 'etag|last-modified'
# 假设拿到 ETag: "65f3a1b2-1c4"
curl -sI -H 'If-None-Match: "65f3a1b2-1c4"' \
"https://www.example.com/static/app.css" | head -3
# 期望:HTTP/2 304
# 如果返回 200,说明 ETag 不匹配(或者被中间层改了)
Nginx 的 ETag 是怎么生成的
Nginx 默认就开着 etag on,但很少有人知道它生成的是什么。源码里 Nginx 对静态文件的 ETag 格式是「十六进制的修改时间 - 十六进制的文件长度」,对应 ngx_http_set_etag 函数。也就是说,它不包含内容哈希,只包含 mtime 和 size。
这个设计有它的道理:算内容哈希意味着每个请求都要把整个文件读一遍(除非有额外缓存),对大文件是不可接受的。用 mtime+size 是零成本的近似。
但近似就有误差,误差来源主要有三个:
一、mtime 在同步时被改变。rsync -a 会保留 mtime,所以正常情况没问题。但如果用了 rsync --checksum(只按内容判断,仍保留 mtime)、或者更糟的 cp 不带 -p、scp 不加 -p、CI 流水线里 tar 解压不带 --preserve,mtime 全部变成「当前时间」,ETag 于是每次部署都变。
二、文件长度相同、内容不同。改个错别字,「的」改成「了」,文件长度一样,mtime 如果因为某些构建工具的行为没变(比如构建过程做了 trim 但保留时间戳),ETag 就不变。此时浏览器和 CDN 会继续用旧内容——这是「改了页面但用户看不到」的经典原因之一。
三、多机部署时 mtime 不一致。这是我们那次问题的根源更深处:两台机器上的同一个文件,mtime 差了几秒,ETag 就不同。CDN 有多个回源节点、或者负载均衡在多机之间轮询,同一个 URL 在不同请求上拿到不同 ETag,CDN 的缓存判断永远在「命中→失效」之间抖动。
# 检查多机 ETag 一致性(这是部署后必做的检查)
for host in 10.0.0.11 10.0.0.12 10.0.0.13; do
echo "== $host"
curl -sI "http://$host/static/app.css" | grep -i etag
done
# 三个 ETag 必须完全一致,否则 CDN/负载均衡会出现缓存抖动
三种配置方案,各自的取舍
针对上面的问题,Nginx 侧有三种做法,适用场景不同:
方案一:保留默认 ETag,但保证 mtime 一致
location ~* \.(css|js|jpg|png|webp|woff2)$ {
root /www/site;
etag on; # 默认就是 on,写出来是为了显式
add_header Last-Modified ""; # 见下文,先别急着加
}
这是最省事的方案,前提是你的部署链路能保证 mtime 稳定。rsync -a、docker cp、tar -x --preserve 都可以。CI 里要特别注意 artifact 打包步骤,很多 CI 默认的 actions/upload-artifact 或 tar -czf 不带 -p,mtime 会在打包/解包过程中被重置。
我的经验是:静态资源统一走「文件名带内容指纹」的发布流程(app.a3f9c1.css),这样一来 ETag 对不对根本不重要——URL 变了就是新资源,URL 没变就是老资源。ETag 只对「同一个 URL 可能内容变化」的场景才有意义。
方案二:关掉 ETag,只用 Last-Modified
location ~* \.(css|js|jpg|png|webp|woff2)$ {
etag off;
if_modified_since exact; # 严格按秒比较,不宽容
add_header Last-Modified ""; # 不要清掉,下面解释
}
if_modified_since 有三个值:exact(默认)、before、off。exact 是「文件 mtime 必须严格大于请求里的时间才返回 200」,before 是「大于等于就返回 200」。差别在于同一秒内修改的文件,exact 模式下可能被误判为未修改。
关掉 ETag 的好处是消除了 inode 和 mtime 精度带来的不确定性,代价是丢失了「同一秒多次修改」的检测能力。对静态资源(每天最多部署几次)来说完全可以接受。
方案三:用内容哈希做 ETag
Nginx 原生不支持按内容算 ETag。有两种办法:部署时把哈希写进文件名或目录名,或者在构建阶段生成内容哈希并在 Nginx 里用变量注入。后者的实现成本高、收益不明显,我实际用的都是前者:让 URL 承载版本信息,让 ETag 退居次要。
如果确实需要内容级 ETag(比如一个 JSON API 的响应),那不该由 Nginx 做,应该在应用层算,然后 add_header ETag 注入。但要小心:应用返回的 ETag 必须带双引号,且不能有中文——有些老版本的 Nginx 对非 ASCII 的 header 值会截断。
CDN 场景里最要命的三个坑
单机部署下 ETag 配错最多是「多下几次」,CDN 场景下就变成真金白银。三个坑我都在线上遇到过:
坑一:CDN 会改写或剥离 ETag。大部分 CDN 默认会把源站的 ETag 转换成自己的格式,或者干脆不传给浏览器。这意味着你在源站精心配的 ETag 在边缘完全无效。正确的做法是在 CDN 后台确认「是否透传 ETag」这个开关。如果 CDN 不透传,那浏览器和 CDN 之间的判断用的是 CDN 自己生成的值,你不必操心;但源站和 CDN 之间的判断仍然用源站的 ETag,这部分还是会消耗回源带宽。
坑二:add_header 会覆盖而不是追加,且受继承规则影响。这一条坑过无数人——同一个 location 里写了 add_header Cache-Control ...,父级的 add_header X-Frame-Options ... 就全部消失,因为 Nginx 的 add_header 是「子块完全覆盖父块」语义,只要子块里出现任意一个 add_header,父块的所有 add_header 都不再生效(除非子块里写 add_header_inherit merge,这是 1.29.3 才有的新指令,老版本没这个)。
所以千万不要随手写 add_header Last-Modified ""; 来「清掉」某个头——它会连带清掉你所有的安全头。add_header 只能加,不能删。要删某个头,得在应用层不做,或者在 proxy_hide_header 里处理上游传来的头。
坑三:If-None-Match 的弱验证符 W/。ETag 有两种形式:强验证符 "abc" 和弱验证符 W/"abc"。Nginx 生成的是强验证符。有些 CDN 或压缩模块会把强验证符转成弱的(因为 gzip 压缩后字节不同,但语义相同)。如果在中间层做了动态压缩,而你又在源站按强匹配比较,就会出现「明明内容没变却总是 200」。遇到这种情况,检查 Nginx 的 gzip 是否在 ETag 计算之后执行——实际上 Nginx 的 gzip 是流式压缩,ETag 在压缩前算好,所以一般不会有这个问题,但第三方模块(比如某些 CDN 的边缘压缩)可能会有。
两件小事:Range 请求与 304 的响应头
条件请求和 Range 请求经常一起出现,视频和大文件下载时会同时带 Range 和 If-None-Match。这时正确的响应是 304 而不是 206——如果缓存整体有效,就不需要给部分内容。Nginx 默认处理是对的,但如果你在前面加了自定义的 limit_rate 或分片逻辑,要注意别把这个顺序搞乱。
另一个细节:304 响应必须带上和 200 相同的缓存相关头(Cache-Control、ETag、Expires、Vary),否则浏览器会用旧的缓存策略覆盖新的。Vary 尤其重要——如果你的站点根据 Accept-Encoding 返回不同的压缩版本,Vary: Accept-Encoding 必须出现在 304 里,否则中间缓存可能把 gzip 版本发给不支持 gzip 的客户端。
# 手工验证 304 的头是否完整
curl -sI -H 'If-None-Match: "已拿到的值"' https://www.example.com/static/app.css \
| grep -iE '^(HTTP|cache-control|etag|vary|expires)'
# 应能看到 HTTP/2 304,且 Cache-Control / ETag / Vary 都在
一张自查表
把上面这些浓缩成部署后五分钟就能跑完的检查:
- 单机:
curl -sI拿到 ETag,用它做条件请求,必须是 304。 - 多机:对每台后端 IP 请求同一个 URL,ETag 必须完全一致。
- 两次部署之间:改一个静态文件后重新部署,确认 ETag 变了、且没变的部分 ETag 没变。如果全站 ETag 一起变,说明 mtime 没保留。
- CDN:用
curl -I分别请求源站 IP 和 CDN 域名,对比 ETag 是否被改写。 - 304 头完整性:确认
Cache-Control、Vary在 304 响应里出现。 - mtime 保留:在部署脚本末尾加一行
stat -c '%y' 某个静态文件,两次部署后对比,时间应该和构建产物一致而不是部署时间。
回到那次 CDN 账单。最终的处置有三条:把 etag off 加在静态资源 location 上(统一退回 Last-Modified)、给 rsync 加 -a 保证 mtime 保留、以及把缩略图的 URL 改成带内容指纹的形式。三件事做完,回源流量当月降了七成。
这件事给我的教训不是「ETag 很重要」,而是任何「默认开启」的机制都值得搞清楚它到底基于什么假设。Nginx 的 ETag 假设「mtime + size 能唯一标识内容」,这个假设在单机、单次部署的世界里基本成立;一旦进了多机 + CDN + 频繁同步的世界,它就不再成立了。而这种失效不会报错、不会告警,只会安静地烧掉你的流量费。