给 WordPress 上 fastcgi_cache:TTFB 从 493 ms 压到 46 ms

上一篇把 PHP 从 7.4 升到 8.2,首页的 PHP 生成时间从 519 ms 掉到 179 ms,OPcache 命中率 99.75%,「跑得快」这件事基本做到头了。但同一台机器、同一种测量方法,TTFB 还是 400 ms 往上。

这次换个方向:与其让 PHP 跑得更快,不如让它根本不用跑。在 Nginx 层挂上 fastcgi_cache 之后,同一个 URL 第二次请求的 TTFB 是 46 ms,而且 PHP 进程一次都没被叫醒。

配置本身不长,真正的难点在两处:一是这台机器的 Nginx 结构和网上教程里的不一样;二是「哪些请求绝对不能缓存」,漏一条就可能把别人的登录态发给你。

一、先量,别猜

这台机器:2 核 / 3.6 GB,Ubuntu 22,宝塔面板,Nginx 1.30.5,PHP 8.2.35,MySQL 5.7.43。首页要跑 25 条 SQL,文章页 44 条。

测量方法就一句,在服务器本机跑,量 time_starttransfer 就是 TTFB:

curl -s -o /dev/null -w "%{http_code} ttfb=%{time_starttransfer} total=%{time_total}n" https://mcstudio.top/

一定要在本机量。走公网量出来的数字里混着 RTT、TLS 握手和运营商链路,拿来对比「缓存前 vs 缓存后」会失真——这两种情况的网络路径完全一样,变的只有服务器侧的处理。

环节 未命中 命中
PHP-FPM 调度 + WordPress 引导 有 无
25 条 SQL + 主题渲染 有 无
gzip 后的 16 KB HTML 现场压缩 缓存直接回放
本机 curl 量到的 TTFB 254 ms 46 ms

也就是说,这 254 ms 里有 208 ms 花在「每次都要重新算一遍」上。只要文章没改,这 208 ms 就可以整块省掉。

二、顺手挖出一个真问题:响应头写着 no-store

上缓存之前先看响应头,结果很意外:

HTTP/2 200
expires: Thu, 19 Nov 1981 08:52:00 GMT
cache-control: no-store, no-cache, must-revalidate
pragma: no-cache

no-store 的意思是「连存都不许存」。有这个头在,不管 Nginx 配得多好、以后接不接 CDN,缓存层都会被无视。

这个头不是 WordPress 发的,也不是子比主题故意发的——是 PHP session。PHP 的 session.cache_limiter 默认值是 nocache,只要代码里出现 session_start(),就会自动带上上面这三行。

确认方法:

php -i | grep session.cache_limiter
grep -rn 'session_start' wp-content/themes/ wp-content/plugins/

子比主题里有多处在用 session(外链跳转的 go.php、支付、OAuth 登录),所以每个页面都被带上了这个头。

正确的头应该是 no-cache:允许缓存,但每次使用前必须回源确认。它和 no-store 的区别是「能不能进缓存」,而不是「内容会不会变」。用一个 12 行的 mu-plugin 改掉:

add_action('send_headers', function () {
    if (headers_sent()) { return; }
    header_remove('Expires');
    header_remove('Pragma');
    header('Cache-Control: no-cache, must-revalidate, max-age=0');
}, 100);

为什么挂在 send_headers 而不是 init:session 是在主题加载阶段才启动的,挂在 init 上会被 session 发的头覆盖回去。send_headers 在真正输出响应头之前执行,改了就生效。

三、为什么用 fastcgi_cache 而不是缓存插件

WP Super Cache、W3 Total Cache 这类插件也能做页面缓存,但它们的命中路径是:请求 → Nginx → PHP-FPM → WordPress 引导 → 插件判断命中 → 读缓存文件 → 输出。PHP 该启动还是得启动,省掉的只是数据库查询和主题渲染。

Nginx 的 fastcgi_cache 则是:请求 → Nginx 命中 → 直接返回缓存文件(含 gzip)。PHP-FPM 完全没被叫醒,也没有 WordPress 引导。

在这台机器上,两者的差距就是 254 ms 和 46 ms 的差距。代价是插件有「发文章自动清缓存」这类便利功能,Nginx 没有,得自己处理(后面讲)。

四、第一个坑:这台机器没有「主 PHP location」

网上教程都让你在 location ~ .php$ 里加几行。我先写脚本把 vhost 里所有候选 location 按缩进列了一遍,结果是:

候选 php location: 5
  行70  缩进8  location ~ .php$ {
  行82  缩进8  location ~ .php$ {
  行97  缩进8  location ~ .php$ {
  行109 缩进8  location ~ .php$ {
  行121 缩进8  location ~ .php$ {

五个全部嵌套在最外层缩进 8 的位置,也就是说它们都躲在 ^/kj/、^/mhtml/、^/pass/、^/cpu/、^/mn/ 这些前缀块里面。首页和文章页根本没有属于自己的 PHP location,它们靠的是这一行:

include enable-php-82.conf;

宝塔把 PHP 处理块单独放在 /www/server/nginx/conf/enable-php-82.conf,内容是标准的:

location ~ [^/].php(/|$)
{
    try_files $uri =404;
    fastcgi_pass unix:/tmp/php-cgi-82.sock;
    fastcgi_index index.php;
    include fastcgi.conf;
    include pathinfo.conf;
}

这个文件是所有使用 PHP 8.2 的站点共用的,直接改它,别的站会跟着一起变。所以做法是复制一份、加料、再把 include 换掉:

# 1. 复制
cp /www/server/nginx/conf/enable-php-82.conf 
   /www/server/nginx/conf/enable-php-82-wpcache.conf

# 2. 在复制件末尾的闭合括号前,加上缓存指令(见下一节)

# 3. vhost 里换掉 include
include enable-php-82-wpcache.conf;

这样改动完全局限在这一个站,回滚也只是把这一行改回去。

五、三处配置

1)http 层:声明缓存区。宝塔的 vhost 文件本身是被 include 进 http {} 的,所以写在文件最顶上是合法的:

fastcgi_cache_path /www/server/nginx/fs_cache levels=1:2
                   keys_zone=wpcache:100m inactive=60m max_size=600m
                   use_temp_path=off;

100m 是键索引区(大约能记 80 万个 URL),max_size=600m 是缓存文件的上限,inactive=60m 表示 60 分钟没人访问就回收。

2)server 层:什么情况不准缓存。这一段是最容易出事的,宁可多跳几条:

set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~* "/wp-admin/|/wp-json/|/xmlrpc.php|/feed/|sitemap|wp-login|/api-proxy/") { set $skip_cache 1; }
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in|wordpress_sec") { set $skip_cache 1; }

逐条说:

  • POST 不缓存——评论、点赞、登录提交都走这条路
  • 带问号的一律不缓存——搜索结果、跟踪参数、支付回调都在这里。宁可少缓存,不可缓错
  • 后台和接口不缓存——wp-admin、wp-json(编辑器和小程序都靠它)、xmlrpc.php、RSS 订阅、sitemap(它是动态生成的 XML)
  • 带登录态 cookie 的不缓存——wordpress_logged_in_* 一出现就说明是登录用户,绝不能把缓存里的别人的页面发给他

3)php location 里:真正开启缓存。

fastcgi_cache wpcache;
fastcgi_cache_key $scheme$request_method$host$request_uri;
fastcgi_cache_valid 200 301 302 5m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout invalid_header updating http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;
fastcgi_ignore_headers Set-Cookie Cache-Control Expires;
  • cache_key 带上 $scheme 和 $host,是因为同一台机器可能还有别的域名解析过来,别让两个站共用一个键
  • cache_valid ... 5m:200 / 301 / 302 缓存 5 分钟。为什么不设 1 小时?改完文章要等 1 小时才生效太难受,5 分钟是个能接受的平衡
  • use_stale:PHP 挂了、超时了、返回 500 了,就把旧缓存拿出来顶上。站不会因为 PHP 崩而整体 502
  • background_update + cache_lock:缓存过期时只放一个请求回源,其他请求继续吃旧缓存,避免「缓存刚好失效」那一瞬间打出并发风暴
  • ignore_headers Set-Cookie 是关键一步:WordPress 每个页面都会 set-cookie,不忽略的话 Nginx 会判定「带 cookie 的响应不能缓存」,结果什么都存不进去

六、验证:别靠感觉,靠日志

先定义一个带 $upstream_cache_status 的日志格式,只挂在 PHP location 上:

log_format wpcachefmt '$remote_addr "$request" $status cache=$upstream_cache_status rt=$upstream_response_time';

# 在 php location 里
access_log /www/wwwlogs/mcstudio.cache.log wpcachefmt;

然后连打几个地址:

8.146.230.105 "GET / HTTP/2.0" 200 cache=MISS rt=0.204
8.146.230.105 "GET / HTTP/2.0" 200 cache=HIT rt=-
8.146.230.105 "GET /osi-seven-layers/ HTTP/2.0" 200 cache=MISS rt=0.197
8.146.230.105 "GET /osi-seven-layers/ HTTP/2.0" 200 cache=HIT rt=-
8.146.230.105 "GET /?s=vlan HTTP/2.0" 200 cache=BYPASS rt=0.213

rt=- 就是「没有回源」,PHP 一次都没跑。BYPASS 是带查询串的搜索页,符合预期。

最终结果,同一时刻、同一台机器、同一个 curl:

页面 第一次(MISS) 命中(HIT)
首页 254 ms 46 ms
文章页 262 ms 47 ms

缓存目录只占了 100 KB——存的是 gzip 后的 HTML,一页 16 KB 左右,600 MB 装几万个页面都够。

七、三个必须知道的副作用

1)缓存响应里的 Set-Cookie 不会被剥掉。fastcgi_ignore_headers 只是让 Nginx「不因为这个头而放弃缓存」,并不会把它从响应里删掉。结果是匿名访客拿到的都是缓存里存着的那个 PHPSESSID,等于所有人共用一个 session。

低流量站基本无害(session 里本来也没存用户相关的东西),但如果以后要开支付、验证码,就必须处理。两种改法:加 fastcgi_hide_header Set-Cookie; 彻底不发 session cookie(注意会连带影响支付、OAuth 这类依赖 session 的流程);或者把那些路径单独判进 $skip_cache。

2)发新文章后,首页最多 5 分钟还显示旧内容。因为 TTL 是 5 分钟。要立刻生效就清一下:

rm -rf /www/server/nginx/fs_cache/*

删文件不会让 Nginx 出错,只是下一次请求重新变成 MISS。

3)带查询串的页面永远不缓存。搜索结果、参数化 URL 全在这个范围。这是刻意的取舍:判断「哪些查询串是安全的」比放弃这部分缓存的成本高得多。

八、46 ms 之后还能再快吗

46 ms 已经是「Nginx 读文件 + 回包」的极限,继续抠收益很低。瓶颈换成了网络:从国内访问这台机器,单程 RTT 就要 30–50 ms。用户实际感知的 TTFB 大约 80–100 ms,剩下的全在链路上。

到这一步该做的不是继续调 Nginx,而是:

  • 静态资源加长缓存——CSS / JS / 封面图设 immutable,让浏览器别回源。这一步对首屏的影响比 TTFB 更大
  • 接 CDN——把 46 ms 变成离用户十几毫秒的边缘节点
  • 登录用户走不了这条缓存——他们需要的是 Redis 对象缓存,那是另一个话题(而且这个站只有一个登录用户,就是我自己)

九、小结

这次三件事,按收益排序:

  1. 上 fastcgi_cache:254 ms → 46 ms,最大的那一块
  2. 把 no-store 改成 no-cache:单看数字没变化,但它决定了以后接 CDN 时缓存能不能生效
  3. 缓存区参数:决定容量和失效行为,一次配好、长期不用管

如果你也在宝塔上做这件事,最容易卡住的地方不是配置本身,而是先搞清楚你的 PHP 到底由哪个 location 处理。别照着教程往 vhost 里瞎加:加错了 nginx -t 会报 duplicate location,加对了也可能因为 include 的关系压根没生效。

最后两条纪律:改之前备份 vhost,改完先 nginx -t 再 reload。这两步能省掉绝大部分事故。

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容