上一篇把 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 崩而整体 502background_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 对象缓存,那是另一个话题(而且这个站只有一个登录用户,就是我自己)
九、小结
这次三件事,按收益排序:
- 上 fastcgi_cache:254 ms → 46 ms,最大的那一块
- 把 no-store 改成 no-cache:单看数字没变化,但它决定了以后接 CDN 时缓存能不能生效
- 缓存区参数:决定容量和失效行为,一次配好、长期不用管
如果你也在宝塔上做这件事,最容易卡住的地方不是配置本身,而是先搞清楚你的 PHP 到底由哪个 location 处理。别照着教程往 vhost 里瞎加:加错了 nginx -t 会报 duplicate location,加对了也可能因为 include 的关系压根没生效。
最后两条纪律:改之前备份 vhost,改完先 nginx -t 再 reload。这两步能省掉绝大部分事故。









暂无评论内容