≡articles //snippets ./categories $uses ~about /search

Nginx 双重 Cache-Control 覆盖陷阱:add_header 同名冲突与 Cloudflare 不缓存修复

你在 Nginx 里加了 Cache-Control 头,Cloudflare 面板也配置了 Page Rule 全部缓存。但打开浏览器 F12 一看,cf-cache-status: DYNAMIC。Cloudflare 死活不缓存你的 HTML。

Nginx Cache-Control 双重覆盖陷阱:add_header 与 PHP header() 冲突的根因与修复

排查了 CF 设置、检查了 Page Rule、甚至清空了 CF 全部缓存重新来过,还是 DYNAMIC。最后发现根因不在 Cloudflare,在 Nginx 自己的配置文件里:两条 add_header Cache-Control 指令互相覆盖,加上 PHP 也在偷偷发头,构成了一个双重覆盖陷阱。

这不是个例,大量使用宝塔面板的 WordPress 站点都踩了这个坑。这篇文章是我花了一个下午排查出来的完整过程,从症状、根因到修复脚本一应俱全。

症状:CF 永远 DYNAMIC

先用 curl 确认问题:

curl -sI https://你的域名.com | grep -E "cache-control|cf-cache"

返回值让我们愣了几秒:

cache-control: max-age=0
cf-cache-status: DYNAMIC

我在 Nginx 里明明写的是 Cache-Control "public, s-maxage=3600, max-age=60",结果实际输出的头只有 max-age=0。CF 看到 max-age=0 就会判定为“不应缓存”,返回 DYNAMIC。

如果你也遇到一模一样的症状,curl 看到 max-age=0 但你的配置文件里写的是带 s-maxage 的完整头,那下面的排查过程精准命中你的根因。

排查第一层:PHP 在偷偷发 Cache-Control

第一个坑在 PHP 层。WordPress 通过 PHP-FPM 处理请求时,会自动输出一个 Cache-Control 头:

Cache-Control: max-age=0, no-cache, must-revalidate

这是 WordPress 的标准行为,告诉浏览器和中间代理“不要缓存此响应”。正常情况下这没问题,因为 WordPress 的页面确实应该由 Nginx FastCGI 缓存来控制,而不是让浏览器自己缓存 PHP 输出。

但如果你在 Nginx 的配置里写了 add_header Cache-Control ...,它不会覆盖 PHP 已经发出的同名 header。这不是 bug,这是 Nginx 的设计:add_header 只在响应中没有该 header 时才添加。如果上游(PHP-FPM)已经发了 Cache-Control,Nginx 的 add_header 会被静默忽略。

所以要做的第一步是隐藏 PHP 输出的 Cache-Control,然后再用 Nginx 的 add_header 设置正确的值。注意顺序,fastcgi_hide_header 必须写在 add_header 前面:

fastcgi_hide_header Cache-Control;                           # 先隐藏 PHP 的 max-age=0
add_header Cache-Control "public, s-maxage=3600, max-age=60"; # 再设自己的缓存策略

改完执行 nginx -t && nginx -s reload,再 curl:

cache-control: public, s-maxage=3600, max-age=60

好了!兴冲冲打开 Cloudflare,CF 面板里还是 DYNAMIC。不是 PHP 的问题。

排查第二层:Nginx 自己的 add_header 互相覆盖

这就是第二个坑,也是最隐蔽的一个。拿出完整的 Nginx 配置文件逐行检查,在宝塔生成的 enable-php-83-wpfastcgi.conf 里看到了这个:

add_header Cache-Control "public, s-maxage=3600, max-age=60";    # 你自己加的
add_header Cache-Control  max-age=0;                               # 宝塔模板自带的

两条 add_header Cache-Control 在同一个 location 块里。Nginx 对同名 header 的规则:同一个 block 内多条 add_header 设置同一个头名称时,只有最后一条生效。前面的被后面的完全覆盖。

所以实际流程是这样的:PHP 发了 max-age=0 → Nginx 的 fastcgi_hide_header 剥掉了它 → 然后 Nginx 的 add_header Cache-Control ... 添加了正确的头 → 紧接着下一行 add_header Cache-Control max-age=0 又把它覆盖回了 max-age=0 → CF 收到 max-age=0 → CF 判定不缓存 → DYNAMIC。

你在配置文件里加的那行正确的头,被宝塔模板自带的那行 max-age=0 覆盖得干干净净。而你根本不会注意到:因为从 Nginx 配置文件的视角看,两行都在、语法都正确、nginx -t 也通过。

修复很简单,删掉或注释掉 max-age=0 那一行:

add_header Cache-Control "public, s-maxage=3600, max-age=60";    # 只保留这一条
# add_header Cache-Control  max-age=0;                            # 这行删掉

重建 Nginx 并清 CF 缓存:

nginx -t && nginx -s reload
# 然后去 Cloudflare → 缓存 → 清除所有内容,等 1-2 分钟

再 curl:

cache-control: public, s-maxage=3600, max-age=60
cf-cache-status: HIT

同时修了两个坑,终于 HIT 了。

宝塔用户完整修复版配置文件

如果你的环境是宝塔面板 + WordPress + Nginx FastCGI Cache + Cloudflare,下面这个是踩完坑之后的完整 enable-php-83-wpfastcgi.conf:

set $skip_cache 0;

# POST/后台/登录用户跳过缓存
if ($request_method = POST) { set $skip_cache 1; }
if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php") { set $skip_cache 1; }
if ($http_cookie ~* "wordpress_logged_in") { set $skip_cache 1; }

location ~ [^/].php(/|$) {
    try_files $uri $uri/ /index.php?$args;
    fastcgi_pass unix:/tmp/php-cgi-83.sock;
    include fastcgi.conf;

    fastcgi_cache WORDPRESS;
    fastcgi_cache_bypass $skip_cache;
    fastcgi_no_cache $skip_cache;

    # ★ 关键:先隐藏 PHP 的 max-age=0
    fastcgi_hide_header Cache-Control;
    # ★ 再设自己的缓存头——只保留一条!
    add_header Cache-Control "public, s-maxage=3600, max-age=60";
    # 下面两行是调试头,可选
    add_header X-Cache "$upstream_cache_status From $host";
    add_header Nginx-Cache "$upstream_cache_status";
    fastcgi_cache_valid 200 301 302 1d;
}

如果你的 PHP 版本不是 8.3,文件名和 sock 路径相应调整即可。

为什么 Nginx 的 add_header 会自己覆盖自己

这是 Nginx 文档里写明的行为,但很少有人完整读过这一段:

There could be several add_header directives. These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level.

, Nginx 官方文档

规则两条:① 子 block 的 add_header 会覆盖父 block 的 add_header,不是追加;② 同一 block 内多条同名的 add_header,最后一条生效。这就是为什么宝塔模板里的 max-age=0 会把你自己加的 s-maxage=3600 覆盖掉,它在后面。

同样的覆盖逻辑也适用于其他 header。如果你在不同的配置层级写了多条 add_header Strict-Transport-Security、add_header X-Frame-Options,也是只有最内层、最后面的一条生效。

Cloudflare 的兜底方案:Edge Cache TTL

即使你把 Nginx 的配置修好了,Cloudflare 还有一个兜底参数可以直接覆盖源站的 Cache-Control,Page Rule 里的“边缘缓存 TTL”:

  • URL:你的域名.com/*
  • 缓存级别:缓存所有内容
  • 边缘缓存 TTL:2 小时

设了这个之后,即使 Nginx 不小心又发出了 max-age=0,CF 仍然会按 2 小时缓存。相当于在 CF 层兜了个底。但不要只依赖这个:Nginx 层的头才对浏览器、搜索引擎爬虫、以及非 CF 的中间代理生效。

双重覆盖陷阱总结

一个 cf-cache-status: DYNAMIC 背后可能有两个坑在同时起作用:

层级原因修复
PHP → NginxWordPress 输出 max-age=0,add_header 不覆盖上游同名 headerfastcgi_hide_header Cache-Control;
Nginx 内部两条 add_header Cache-Control,后一条覆盖前一条删掉 max-age=0 那条
CF Page Rule缓存级别“标准”而非“缓存所有内容”改为“缓存所有内容”+ Edge TTL

这三个全中是最常见的,因为宝塔默认配置模板里自带 add_header Cache-Control max-age=0,加上 WordPress PHP 自动发的 max-age=0,刚好构成双重覆盖。修好之后,TTFB 从几百毫秒降到 30-50ms,CF 面板上也终于能看到绿色的 HIT 标识了。

相关阅读:Nginx FastCGI Cache 完整配置、WordPress 安全头完整配置

Nginx 官方文档对 add_header 指令的说明里明确写了这条规则,多级 add_header 不会覆盖,只会在同级替换。本站的 Nginx add_header 继承规则也深度分析了另一个相关坑。

© 2026 本文由 Charlie 原创撰写,发布于 sodebug.com。未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。

独立开发者,在 WordPress、Nginx 和各种 API 之间切换。不写没用的东西。

相关文章

使用宝塔面板配置 Nginx fastcgi_cache 优化 WordPress 网站性能9 minNginx FastCGI Cache 缓存清除完整指南:为什么改了首页却看不到更新5 minCloudflare WAF 误杀 Googlebot 排查实录:一条托管规则让整站 4037 minNginx 反向代理排坑实录:Cookie 丢失、CORS 报错、WebSocket 断连10 min
$ echo "less bullshit, more debugging" | send-to-inbox
新文章直接发到邮箱,一个月 2-4 封。
订阅