Nginx 静态资源缓存实战:expires 与 Cache-Control 冲突排查、immutable 的两个前提

为什么你的静态资源缓存策略一直不生效

几乎每个个人站长都做过同一件事:在 Nginx 配置里写上 expires 30d;,然后打开浏览器开发者工具,满怀期待地看 Network 面板里的静态资源,结果 Cache-Control 那一栏显示的是 no-cache,或者干脆是空的。刷新几次,请求还是 200,不是 304,更不是 from disk cache

这不是你的配置写错了,大多数时候是三个地方在互相打架:Nginx 的 expires、上游 PHP 已经吐出的响应头、以及 CDN 自己的缓存规则。这篇文章就把这三个层面的冲突拆开讲清楚,最后给出一套可以直接抄到生产环境的配置。

第一层:expires 和 add_header 到底谁说了算

Nginx 的 expires 指令其实是个语法糖,它会同时做两件事:设置 Expires 响应头,以及设置 Cache-Control: max-age=N。注意它生成的是一个 完整的 Cache-Control 值,而不是追加。

问题就出在这里。如果你同时写了这两行:

location ~* \.(css|js)$ {
    add_header Cache-Control "public, max-age=31536000";
    expires 30d;
}

你猜最终客户端拿到的是哪个?答案是 两个都可能出现,具体取决于 Nginx 内部指令的执行顺序和继承规则。在同一个 location 里,add_header 会在 expires 之后执行,往往是后加的覆盖先加的,于是你配置里写的 30 天变成了 31536000 秒。反过来如果 add_header 写在 server 块、expires 写在 location 块,那么 location 块的优先,30 天生效。

这里有个很多人不知道的坑:add_header 默认不继承。只要你在某个 location 里写了一条 add_header,那么 server 块或 http 块里所有的 add_header 在这个 location 里就全部失效了,除非你加上 always 参数或者在 location 里重新声明一遍。这就是为什么你明明在 http 块里配了安全头,静态资源目录却一个都没有的原因。

# 正确做法:要么全写在一个层级,要么用 always
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;

第二层:上游已经吐出的响应头不会被清掉

如果你用了 proxy_pass 或者 fastcgi_pass,静态资源的响应头有可能是上游应用生成的。典型场景是 PHP 的 session_start() 会自动发送:

Cache-Control: no-store, no-cache, must-revalidate
Expires: Thu, 19 Nov 1981 08:52:00 GMT

对于真正由 PHP 输出的静态文件(比如动态生成的 .css、缩略图接口),这些头会跟 Nginx 的 expires 叠加,客户端看到两个 Cache-Control 就蒙了,最终行为是取最严格的,于是缓存直接失效。

解决办法是在 Nginx 侧用 proxy_hide_header 把这些头藏掉:

location ~* \.(css|js|png|jpg|webp|woff2)$ {
    proxy_hide_header Cache-Control;
    proxy_hide_header Expires;
    proxy_hide_header Set-Cookie;
    proxy_hide_header Pragma;
    expires 365d;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

对 FastCGI 站点(Typecho、WordPress 都是)要用对应的 fastcgi_hide_header。我自己给 Typecho 站点做优化时,最常漏的就是 Set-Cookie——只要响应里带了 Set-Cookie,很多 CDN 就会拒绝缓存这个请求,无论你的 Cache-Control 写得多漂亮。

第三层:immutable 与文件名指纹的配合

immutable 是一个非常值得用的指令,它的含义是告诉浏览器:这个 URL 对应的资源在你的缓存有效期内永远不会变,不要发条件请求来验证。好处是彻底消灭 304 往返,静态资源直接从磁盘读,也不占用网络。

immutable 有一个前提:URL 必须带内容指纹。如果你的 style.css 一年都不换名字,那么用户一年内都拿不到新版本,你只能靠用户手动清缓存,这会造成大量"线上改了但用户看到旧样式"的客诉。

正确做法是给静态资源的文件名加上内容哈希,比如 style.a3f9c1.css。Typecho 和 WordPress 都有插件可以做,或者你在主题模板里用一个基于文件修改时间的查询参数:

<link rel="stylesheet" href="/usr/themes/mytheme/style.css?v=<?php echo filemtime(__DIR__ . '/style.css'); ?>">

?v= 查询参数也能达到换 URL 的效果,但要注意:部分 CDN 默认不缓存带查询参数的请求,需要在 CDN 侧开启"忽略查询参数缓存"或"查询参数参与缓存键"的选项,否则你等于放弃了这个文件的边缘缓存。

Nginx 按文件类型分层的完整配置

下面这套配置是我在多个 Typecho / WordPress 个人站上跑稳定了的版本,直接抄:

# 长缓存 + 指纹类静态资源,一年
location ~* \.(css|js|woff2?|ttf|eot|otf)$ {
    expires 365d;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    access_log off;
}

# 图片,指纹类也给长缓存
location ~* \.(png|jpe?g|gif|webp|avif|svg|ico)$ {
    expires 180d;
    add_header Cache-Control "public, max-age=15552000" always;
    access_log off;
}

# HTML 绝对不要长缓存,交给 CDN 或协商缓存
location ~* \.html?$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate" always;
}

# 明确禁止缓存的敏感路径
location ~* ^/(action|admin)/ {
    expires -1;
    add_header Cache-Control "no-store" always;
}

几个要点说明:

expires 365d 会同时给出 Expiresmax-age,对老浏览器兼容;immutable 需要现代浏览器才认,不认也不会出错。HTML 用 expires -1 会产生 Cache-Control: no-cache,这不是"不缓存",而是"每次都来问要不要用缓存的",配合 ETag 就能得到高效的 304no-store 才是真正的完全不缓存,只应该给后台和写操作路径用。

另一个容易忽略的点是 access_log off。静态资源的访问日志占了 Nginx 日志的绝大部分体积,关掉它能显著降低磁盘 IO,也让 GoAccess 这类日志分析工具里的爬虫行为更容易看清——日志太吵时你根本发现不了异常爬取。

用 curl 验证缓存头是否正确

不要只看浏览器,浏览器会混入本地缓存和 Service Worker 的干扰。用 curl 看服务端真实返回:

curl -sI "https://你的域名/usr/themes/mytheme/style.css?a=1" | grep -iE 'cache-control|expires|etag|last-modified'

预期的输出应该是 cache-control: public, max-age=31536000, immutable。如果看到 no-store 或者根本没有 Cache-Control,说明你上面哪一层的冲突没解决。

再验证一次动态页面:

curl -sI "https://你的域名/" | grep -i cache-control

首页应当是 no-cache 而不是 public, max-age=31536000。我见过最惊悚的一种配置事故,是全站 location / 上写了 expires 7d,导致搜索引擎和读者都拿到过期一周的首页,新文章完全没人看得见——这种问题从外部看就是"网站不更新了",排查起来极其费时。

CDN 侧别漏掉这一环

如果站点套了 CDN,Nginx 的头只是第一层,CDN 还有自己的缓存策略。需要确认三件事:

第一,CDN 是否遵循源站的 Cache-Control。有些 CDN 默认用"智能缓存",对 .css/.js 强制 7 天,无视你源站的一年。这种要么在 CDN 面板里改成"遵循源站",要么配置页面规则单独指定。

第二,发布新版资源后是否需要主动刷新 CDN 缓存。指纹命名的资源不需要刷,同名资源必须刷。建议在部署脚本最后一步自动调用 CDN 的刷新 API,把被改动的 URL 推过去。

第三,Vary 头要谨慎。如果你在响应里加了 Vary: User-Agent(很多移动适配插件会加),CDN 的缓存键就会按 UA 分片,命中率暴跌,回源率暴涨。个人站的流量扛不住这种浪费,能不用就不用。

一个真实案例:为什么改了头像用户还是不刷新

讲一个我自己踩过的坑,它把上面三层冲突全都串起来了。站点有个"最近评论者头像"的模块,头像是按用户邮箱生成的固定路径 /avatar/{hash}.png。用户换了邮箱之后,头像路径变了,新路径 200 正常;但问题是老用户上传的自定义头像用的是固定路径 /avatar/custom/{uid}.png,而这个路径被 Nginx 配了 expires 365d; immutable

结果就是:用户换了头像,服务器上的文件确实更新了,但浏览器和 CDN 都拿着一年前的缓存,用户看到永远是旧头像,于是投诉"你们网站有 bug"。这里的教训是,凡是内容可能变、但 URL 不变的资源,绝对不能配 immutable。immutable 的语义是"在有效期内永远不会变",它不是"我希望能缓存久一点"的修辞。

修法有两个方向。短期方案是给这个路径单独开一个 location,改成短缓存加协商:

location ~* ^/avatar/custom/ {
    expires 5m;
    add_header Cache-Control "public, max-age=300, must-revalidate" always;
}

长期方案是给头像 URL 加上版本参数,换头像时版本号变,URL 变,缓存自然失效,然后就可以放心地配长缓存。这两种方案的选择标准很简单:你能不能控制 URL 的变化。能,就用长缓存加指纹;不能,就必须用短缓存加协商。中间没有第三条路,任何想"既要缓存一年又要内容随时更新"的配置都必然出事故。

用缓存头审计做一次全站体检

配置写完之后,建议做一次全站缓存头审计,把所有会返回给浏览器的资源类型都覆盖一遍。写个小脚本,对若干代表性 URL 发 HEAD 请求并打印缓存相关头:

#!/bin/bash
DOMAIN="https://你的域名"
for p in "/" "/index.php" "/usr/themes/mytheme/style.css" \
         "/usr/uploads/2026/01/pic.jpg" "/feed/" "/sitemap.xml" \
         "/avatar/custom/1.png"; do
  echo "=== $p ==="
  curl -sI "${DOMAIN}${p}?audit=$(date +%s)" \
    | grep -iE '^(HTTP/|cache-control|expires|etag|last-modified|vary|set-cookie)' \
    || echo "(无缓存相关头)"
done

审计时重点看四个问题:

一是有没有资源返回了 Set-Cookie。静态资源带 Set-Cookie 会让很多 CDN 直接放弃缓存,这是隐性的大坑,尤其在用了 PHP 会话的站点上,因为 session_start() 会把 cookie 塞进任何经过 PHP 的响应。

二是Vary 是否合理Vary: Accept-Encoding 是正常且必要的(区分 gzip 和未压缩版本);但 Vary: User-AgentVary: Cookie 对 CDN 缓存命中率是灾难级的,会按维度分片导致回源率飙升。

三是HTML 类资源有没有被误配长缓存。RSS /feed/sitemap.xml 也是容易被误配的东西——sitemap 被缓存一天,新文章就晚一天被爬虫发现;RSS 被缓存太久,订阅用户看不到更新。这两个建议都设成 10 到 30 分钟的短缓存,兼顾回源压力和时效性。

四是ETagLast-Modified 是否都在。协商缓存依赖它们,缺了任何一个,浏览器就只能无条件重新下载。Nginx 默认会生成 ETag,但如果你开了 gzip 压缩,注意 gzip 模块会改变 ETag 的生成逻辑,某些版本下会导致强 ETag 变成弱 ETag(带 W/ 前缀),这在分布式多机上可能导致协商缓存失效。多机部署时建议直接用 Last-Modified 就够了。

一个判断缓存策略是否生效的实用方法

最后给一个我自己常用的验证流程,三步走:

打开开发者工具 Network 面板,勾上 Disable cache 关掉,第一次加载记录 Transfer SizeTime;然后强制刷新(Ctrl+Shift+R)一次;再普通刷新(F5)一次。如果第三次的静态资源显示 (disk cache)Transfer Size 接近 0,说明缓存策略生效了。如果第三次仍然是 200 且有几百毫秒耗时,那就回到上面三层逐一排查。

缓存策略这件事的价值不在"看起来专业",而在于它直接影响 TTFB 之外的加载体验、服务器的带宽成本,以及搜索引擎爬虫的抓取预算。爬虫每次来都要重新下载你所有的 CSS 和图片,抓取预算就被这些重复内容消耗掉了,留给新文章的额度自然就少。把静态资源缓存配好,是投入产出比最高的一项站点优化,值得花半小时彻底理清。

Last modification:September 22nd, 2026 at 01:31 pm

Leave a Comment