为什么给静态资源加 Expires 不等于「缓存配好了」
几乎每个 Nginx 教程都会告诉你加这么一段:
location ~* \.(jpg|jpeg|png|gif|css|js|woff2)$ {
expires 30d;
}加上之后用浏览器开发者工具看,响应头里确实出现了 Expires,看起来大功告成。但如果你的站每天更新几篇文章、每周改一次模板,这段配置很快会给你带来两个问题:用户看不到新样式,以及你不得不给所有资源 URL 加 ?v=123 之类的参数——而那个参数会把 CDN 的缓存键打散,命中率一落千丈。
Expires 这一族指令(expires、Cache-Control: max-age、immutable、stale-while-revalidate)看起来简单,实际是整个静态资源加速里最容易配错的一环。本文想把这几件事说清楚:浏览器真的按什么规则判断「要不要重新下载」、这几条指令之间谁覆盖谁、以及一份个人站能直接抄的、不会造成「改了不生效」的三层缓存配置。
先搞清楚浏览器到底怎么决定「重新下载」
浏览器拿到一个资源时,判断流程是这样的(简化版):
- 本地有缓存副本吗?没有 → 直接请求。有 → 下一步。
- 缓存条目还是「新鲜」的吗?(基于
max-age/Expires计算)新鲜 → 直接用本地副本,不发任何请求。不新鲜 → 下一步。 - 带着验证器(
If-None-Match或If-Modified-Since)发条件请求。服务器返回 304 → 用本地副本,只多花一个 RTT。返回 200 → 用新内容。
所以缓存策略本质上是两个独立的旋钮:
旋钮一:「新鲜期」有多长(max-age)。新鲜期内一个请求都不发,这是最快的状态。代价是新鲜期内你改了文件用户也看不到。
旋钮二:「过期之后用什么验证」(ETag / Last-Modified)。这决定了过期后是「一个 304 的廉价往返」还是「完整重下」。
绝大多数人的错误是只调旋钮一,然后为了让改动生效把旋钮一调到 0——于是每次访问都要发条件请求,性能优势基本消失。正确的思路是:让不常变的资源有极长的新鲜期,同时让它的 URL 随内容变化;让常变或无法指纹化的资源保持短新鲜期但必须有验证器。
expires 指令展开后到底是什么
expires 是一个宏,它一次性改两个头。理解它展开成什么,后面所有问题都能自己推出来:
| 配置 | 产生的响应头 | 浏览器行为 |
|---|---|---|
expires 30d; | Expires: 现在+30天Cache-Control: max-age=2592000 | 30 天内不发请求 |
expires max; | Expires: Thu, 31 Dec 2037 23:55:55 GMTCache-Control: max-age=315360000 | 10 年内不发请求(业界称「永久缓存」) |
expires 0; | Expires: 现在Cache-Control: max-age=0 | 每次都要验证 |
expires -1; | Expires: 现在-1秒Cache-Control: no-cache | 每次都要验证(且代理不能直接用) |
expires off; | 不加任何缓存头 | 由浏览器/代理自行启发式判断(通常很短) |
expires modified 7d; | Expires: 文件mtime+7天 | 「从文件最后修改时间起算 7 天」 |
三个必须记住的点:
expires 0 和 expires -1 的区别经常被搞混。0 产生 max-age=0,它仍然是一个「可缓存」的响应,缓存可以存下来但每次用之前必须验证;-1 产生 no-cache,语义一样(可缓存、必须验证),但多了一层对 HTTP/1.0 代理的兼容处理。实际效果几乎相同,真正的「不要缓存」是 no-store,那是另一回事:no-store 要求「不许写盘」,用于含敏感信息的响应。
expires max 不是 2038 年问题,是刻意的。2038 年是 32 位 time_t 溢出的日子,Nginx 用 Thu, 31 Dec 2037 23:55:55 GMT 作为「最大安全时间」,提前 4 分 5 秒留出余量。看到这个日期不用慌,这是标准做法。
expires modified 7d 是个陷阱。它的新鲜期是从文件的 mtime 算起,不是从「本次请求」算起。如果一个文件是 30 天前部署的,配上 expires modified 7d,那么 Expires 头会是「23 天前」——已经过期了,每次访问都要验证。这个配置只有在文件持续被更新时才有意义,日常静态资源别用。
Cache-Control 与 Expires 同时存在,谁赢?
两家 CDN 和三个浏览器都遵循同一条规则:Cache-Control: max-age 优先于 Expires。因为 Expires 是 HTTP/1.0 的产物,需要服务器与客户端时钟同步;Cache-Control 是 HTTP/1.1 的,用相对时间,不存在时钟问题。RFC 9111 明确写了这一点。
这就是为什么 expires 30d 会同时发两个头——它照顾了老客户端。但如果你只写 add_header Cache-Control "max-age=2592000" 而不写 Expires,现代浏览器完全没问题。
更麻烦的是重复头的情况。如果 Nginx 配了 expires 30d,同时在别处又 add_header Cache-Control "no-cache",那么响应里会出现两个 Cache-Control:
Cache-Control: max-age=2592000
Cache-Control: no-cache这时候不同实现的处理方式不一样:RFC 允许把它们合并成一个逗号分隔的列表,而浏览器遇到 no-cache 与 max-age 并存时,通常以更保守的为准(即 no-cache 生效)——但这不是保证。有些 CDN 会取第一个,有些取最后一个,造成「源站行为与 CDN 行为不一致」,这是最难排查的一类缓存问题。所以第一条铁律:同一类缓存头只在一个地方产生。
三层缓存:把「不会变」「偶尔变」「天天变」分开
我最终用的方案是把静态资源按「变化频率」分成三层,每层用完全不同的缓存策略。这个划分比「按文件类型划分」更可靠,因为文件类型和变化频率并不一致(比如 favicon.ico 是图片但可能天天换,而 vendor.js 一年不动)。
第一层:带指纹的资源,永久缓存
带内容哈希的 URL(/static/app.a3f9c1.js、/assets/main.7b2e4d.css)是唯一可以放心「永久缓存」的东西。因为内容一变,URL 就变,用户自然会请求新 URL,不存在「看不到新版本」的问题。
# 带 8 位以上十六进制哈希的路径,永久缓存
location ~* /(static|assets|dist)/.+[.-][0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|webp|avif|svg)$ {
expires max;
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}immutable 是一个很实用的扩展指令(RFC 8246),它告诉浏览器:在这个资源的新鲜期内,即使用户按 F5 刷新,也不要发条件请求。没有它的时候,用户按 F5 会让浏览器对页面上所有资源都发 If-None-Match,服务器收到几十个 304 请求——虽然每个很小,但在弱网下会明显拖慢「刷新」体验。加上 immutable 后 F5 也直接用本地副本。这是静态资源加速里性价比最高的一行配置。
注意这里的正则用 [.-][0-9a-f]{8,} 而不是泛泛的 .*-[0-9a-f]+,是为了避免把 /static/banner-deadbeef.jpg(人为命名的图片)误判成指纹资源——虽然那种误判通常无害,但如果哪天你替换了同名文件,就会陷入「改了永远不生效」的深渊。宁可让判别严格一点,漏过的资源走第二层。
第二层:无指纹但更新不频繁的资源,中等新鲜期
主题里那些没法加指纹的文件:/usr/themes/handsome/style.css、字体文件、logo。这些 URL 是主题写死的,改了必须让用户拿到新版。给它一个既不长为「永久」,也短到改动能在一天内生效的新鲜期:
location ~* \.(css|js|woff2?|ttf|eot|svg|ico)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
etag on; # 必须保留验证器
if_modified_since exact;
}7 天怎么定的?考虑两件事:一是模板改动的紧急程度(改个样式要 7 天内生效,可以接受;但如果是修 XSS 就等不了),二是命中率(新鲜期越长命中率越高)。我的实际做法是这个值跟着「最后一次紧急改模板」的时间调整——如果半年没紧急改过,就调到 30d;如果刚出过事故,调到 1d,等稳定了再涨回去。
关键是要保留 etag on。过期后浏览器发一个 If-None-Match,服务器比对 ETag,相同返回 304(约 200 字节),不同返回 200。这样「过期」的代价只是一个 RTT,不是重下整个文件。
第三层:HTML 与动态页,必须每次验证
HTML 是最特殊的:它不能被长期缓存(否则用户看不到新文章),但让它 no-store 又太浪费——条件请求的 304 只要 200 字节,比完整 HTML 便宜得多。
location ~ \.php$ {
expires off;
add_header Cache-Control "private, no-cache, must-revalidate" always;
...
}
# 或静态化的 HTML 缓存
location ~ /\.html$ {
expires 10m;
add_header Cache-Control "public, max-age=600" always;
if_modified_since exact;
}private 是给 CDN 看的:告诉它「这个响应只属于单个用户,不要缓存到边缘节点」。如果不加 private,一个带登录状态的页面可能被 CDN 缓存后返回给别的访客——这是历史上最惨烈的缓存事故类型之一。文章页如果对所有人内容一样,可以改成 public 并短新鲜期(10 分钟),让 CDN 扛住突发流量。
must-revalidate 的作用是禁止缓存「在过期后为了省流量而继续使用旧副本」——对 HTML 来说这个保护是必要的。
add_header 的隐藏行为:子块一写,父块全丢
这是 Nginx 缓存头排查里最容易让人崩溃的一条:add_header 在 location 里出现时,会完全覆盖父级(server/http)的 add_header,而不是追加。
假设你在 server 段配置了安全头:
server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000" always;
location ~* \.css$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
# ← 这个 add_header 让上面三个安全头在这个 location 里全部消失
}
}结果:所有 CSS 响应里既没有 X-Frame-Options 也没有 HSTS。nginx -t 不报错,curl 首页(走 location /)也一切正常,只有 curl 一个 CSS 文件才能发现。这个坑害过无数人,而且它的症状是「某些响应缺少安全头」,几乎不可能靠肉眼发现。
三种解法:
解法一(最干净):不要在子块里用 add_header 写缓存头。用 expires 指令产生 Cache-Control,它不受 add_header 覆盖规则影响。但如果你需要 immutable 这种 expires 不支持的扩展指令,就必须用 add_header。
解法二:把安全头也复制到每个有 add_header 的 location。能用但难维护,漏一个就是漏洞。
解法三:Nginx 1.29.3 之后的 add_header_inherit merge。新版本加入了这个指令,写在有 add_header 的块里就变成「继承父级 + 追加本级」:
location ~* \.css$ {
add_header_inherit merge;
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable" always;
}个人站的 Nginx 版本往往不是最新的(Debian 12 仓库里是 1.22/1.24),所以实际做法是解法一 + 用 map 合并头:把所有要发 Cache-Control 的情况统一在一个 map 里算出值,只在 server 段用一次 add_header。这样既不会覆盖,也不用重复写。
map $uri $cc_value {
~^/(static|assets)/.+[.-][0-9a-f]{8,}\. "public, max-age=31536000, immutable";
~\.(css|js|woff2?)$ "public, max-age=604800";
~\.html$ "public, max-age=600";
default "private, no-cache, must-revalidate";
}
server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Cache-Control $cc_value always;
# ... 各 location 里不再写任何 add_header
}注意 map 里 default 必须放在最后,且 map 的匹配是「按顺序第一个命中」,不是最长匹配。这点和 location 的语义不同,很容易写错。
CDN 层的二次改写:源站头对了不代表用户拿到对的
站点套 CDN 后,缓存策略实际由两处决定:源站响应头 + CDN 自己的缓存规则。常见四种「源站对了、用户拿到错的」:
情况一:CDN 覆盖 max-age。很多 CDN 默认「静态资源缓存 30 天、HTML 不缓存」,直接无视源站的 Cache-Control。这时候你在 Nginx 里调 max-age 完全无效。必须在 CDN 后台把缓存规则改成「遵循源站」,或者显式配置路径级规则。验证方法是 curl -I 对比源站与 CDN 返回的头。
情况二:CDN 剥离 ETag。部分 CDN 为了「统一」会删掉源站的 ETag,或者把多台源站的不同 ETag 统一化。后果是条件请求失效——浏览器拿着 CDN 给的 ETag 来验证,回源时 CDN 用自己的规则判定,可能直接返回完整 200。这时候要看 X-Cache 之类的调试头确认。
情况三:追踪参数打散缓存键。这是命中率杀手。用户从邮件、微信分享过来的链接带 ?utm_source=xxx,如果 CDN 缓存键包含完整 query string,那么每个来源都是一个新缓存条目,命中率腰斩。解决是在 CDN 配置里忽略 utm_/fbclid/gclid 等追踪参数(主流 CDN 都有这个开关),或者在 Nginx 里把这些参数剥掉再 301。
情况四:Set-Cookie 抑制缓存。如果响应里带 Set-Cookie,大多数 CDN 会完全跳过缓存。WordPress 之类的程序很容易在静态资源响应里带上 Cookie(插件干的)。检查方法是 curl -I 一个 CSS 看有没有 Set-Cookie,有就得在 Nginx 里用 proxy_hide_header / fastcgi_hide_header 剥掉。
location ~* \.(css|js|png|jpg|woff2)$ {
fastcgi_hide_header Set-Cookie;
add_header Cache-Control "public, max-age=604800" always;
# ...
}验证:三次 curl 把三层都验一遍
配置完必须验证,而且要验证「第一次请求」「带验证器请求」「命中缓存请求」三种情况。用 curl 一条命令就能做:
# 1) 首次请求,看响应头
curl -sI "https://www.zz1984.com/usr/themes/handsome/assets/css/style.css" \
| grep -iE 'cache-control|expires|etag|last-modified|age|set-cookie'
# 2) 带 ETag 做条件请求,期望 304
etag=$(curl -sI "$URL" | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -sI -H "If-None-Match: $etag" "$URL" | head -1
# 期望:HTTP/2 304
# 3) 带过期的 Last-Modified 做条件请求,期望 304
lm=$(curl -sI "$URL" | awk -F': ' 'tolower($1)=="last-modified"{print $2}' | tr -d '\r')
curl -sI -H "If-Modified-Since: $lm" "$URL" | head -1
第二个和第三个都返回 304 才算「验证器可用」。如果返回 200,说明 ETag 被 CDN 改了或者 Nginx 的 ETag 与 mtime 对不上(比如部署脚本每次 rsync 都重置了 mtime)。
还有一个特别容易漏的验证:用浏览器无痕窗口 + 禁用缓存访问一次,再开正常的无痕窗口访问一次,对比 Network 面板里资源的 Size 列。第二次应该是 (disk cache) 或者 (memory cache),如果还是显示实际字节数,说明新鲜期没生效——通常是 CDN 覆盖了 max-age。
三个真实翻车案例
案例一:把 expires max 加到了没指纹的文件上。给整个 /usr/themes/ 目录加了永久缓存,结果改模板后自己看不到效果,以为是没部署成功,反复重传了三次。最后发现是本机浏览器缓存。改完必须加指纹才敢 max——这条现在是我不做例外的规矩。
案例二:add_header 覆盖导致 HSTS 丢失。在图片 location 里加了 add_header Cache-Control,结果 Strict-Transport-Security 在这批响应里消失。虽然 HSTS 只要主文档响应里有就生效(浏览器按 domain 记),但 X-Content-Type-Options 的丢失是真问题。这个 bug 藏了三个月,直到我为了别的事 curl 一个图片才发现。
案例三:if_modified_since exact 让 304 全变成了 200。默认行为是 if_modified_since exact,它要求 If-Modified-Since 的时间精确等于文件的 mtime 才返回 304。但很多客户端(尤其是老版下载器和某些 CDN 节点)会把时间截断到秒或舍入,导致永远不匹配,每次都返回完整内容。改成 if_modified_since before 更宽容(时间早于 mtime 就返回 304),代价是极少数情况下会误判「没变」。我现在的做法是保留 exact 但确保 ETag 存在——ETag 优先,浏览器会用它而不是时间。
一份可以直接抄的完整配置
把上面所有东西合起来,去掉注释大概 25 行,覆盖 90% 的个人站场景:
# http 段
map $uri $cc_final {
~^/(static|assets|dist)/.+[.-][0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|webp|avif|svg)$
"public, max-age=31536000, immutable";
~\.(css|js|woff2?|ttf|eot|svg|ico)$
"public, max-age=604800";
~\.(png|jpe?g|gif|webp|avif)$
"public, max-age=2592000";
~\.html$
"public, max-age=600, must-revalidate";
default
"private, no-cache, must-revalidate";
}
server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Cache-Control $cc_final always;
etag on;
if_modified_since exact;
gzip_static on;
location ~* \.(css|js|png|jpe?g|gif|webp|avif|woff2?|svg|ico)$ {
fastcgi_hide_header Set-Cookie;
access_log off;
try_files $uri =404;
}
}几个说明:default 分支给 HTML 与动态页 no-cache(可缓存但必须验证),这样 304 帮你省带宽,同时永远不会展示过期内容。gzip_static on 在有预压缩文件时直接发 .gz,省掉实时压缩的 CPU——配合 CDN 效果更好,因为 CDN 回源拿到的就是压缩过的。
try_files $uri =404 在静态 location 里很重要:没有它,Nginx 会把不存在的静态文件交给后面的 location ~ \.php$ 处理,产生一堆诡异的 404 日志,甚至在旧版 PHP 上有路径解析漏洞。
最后:缓存策略的取舍只有一条原则
写完这么多配置,回头看其实只有一条原则:新鲜期越长越好,前提是你能保证「内容变了 URL 就变」。所有缓存事故都来自某个违反了这条前提的地方——没指纹的文件给了 max,或者给 max 的文件被同 URL 覆盖了。
所以判断一个资源该给多长缓存,只问一句:这个 URL 的内容会不会在不换 URL 的情况下改变? 会 → 短新鲜期 + 保留 ETag。不会 → 放心给最大新鲜期 + immutable。整个静态资源加速的复杂度,本质上是这个问题的复杂度。