为什么你的 PHP 站慢,而加机器没用
很多个人站长的网站是 WordPress、Typecho 这类 PHP 程序。访问量大一点就卡,第一反应是升级服务器配置——从 1 核 2G 升到 2 核 4G,结果发现 TTFB 只从 800ms 降到 700ms,几乎没变化。这时候问题已经不在 CPU 和内存了,而在于:每一个访客请求,都完整地走了一遍 PHP 解释执行 + 数据库查询的流程。
一篇博客文章页,打开一次可能要执行上百个 PHP 文件、跑二十多条 SQL 查询、做几次文件 IO。如果每天有一万个访客,同样的计算你就重复了一万次。而这些页面在一小时内其实几乎没有变化——这中间的巨大浪费,就是缓存要解决的问题。
大部分站长都听说过"页面缓存插件",但插件层缓存(PHP 层面的 object cache / page cache)依然要先启动 PHP、加载框架、才能判断"这个页面有缓存,直接输出"。真正能在PHP 启动之前就把请求挡回去的,是 Nginx 的 fastcgi_cache。这篇文章专门讲它:原理、配置、缓存粒度的坑,以及"为什么我配了却没命中"的排查方法。
fastcgi_cache 和 proxy_cache 的本质区别
Nginx 的缓存分两大流派,很多人混着用却没搞清区别:
| 对比项 | proxy_cache | fastcgi_cache |
|---|---|---|
| 缓存的响应来自 | 上游 HTTP 服务(另一台 Nginx / 后端应用 / CDN 回源) | FastCGI 进程(PHP-FPM) |
| 典型配置位置 | proxy_pass 场景 | fastcgi_pass 场景(PHP 站) |
| 命中时是否启动 PHP | 是(后端服务仍在跑) | 否,Nginx 直接返回 |
| 省 CPU 效果 | 中 | 高(PHP-FPM 完全不被调用) |
| 缓存键的关键 | proxy_cache_key | fastcgi_cache_key |
关键差异在第三行。proxy_cache 缓存的是"后端吐出来的 HTTP 响应",命中缓存时后端服务依然是活着的(只是没被请求);fastcgi_cache 缓存的是"PHP-FPM 执行完的输出",命中时 PHP-FPM 连一次都没有被调用,PHP 进程的 CPU 占用直接归零。对单机跑的 PHP 博客来说,这是提升最明显的一层缓存。
第一步:定义缓存区与缓存键
在 http {} 块里(通常在 /etc/nginx/nginx.conf 或 conf.d/ 下的某个文件)定义缓存区:
# 缓存文件存放路径、目录层级、内存索引大小、磁盘上限
fastcgi_cache_path /var/cache/nginx/fastcgi
levels=1:2
keys_zone=PHP_CACHE:100m
max_size=2g
inactive=2h
use_temp_path=off;
# 缓存键:决定"什么算同一个页面"
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# 缓存锁:防止缓存过期瞬间大量请求同时回源(缓存雪崩)
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 5s;
fastcgi_cache_lock_age 5s;参数逐个解释,这几个值直接决定缓存能不能正常工作:
levels=1:2:缓存文件按两级子目录存放,避免单目录下文件过多导致文件系统变慢。这是大站标配。keys_zone=PHP_CACHE:100m:缓存索引的内存大小。100m 大约能存 80 万个键的元数据。个人站 10m–50m 就够,但不要照抄网上"1m"的配置——索引小了会导致频繁淘汰。max_size=2g:磁盘上缓存文件的上限。超过后 Nginx 会自动清理最旧的,所以不用担心写满磁盘。但要注意这个值不能设得比剩余磁盘还大。inactive=2h:2 小时内没有被访问过的缓存文件会被删除(即使还没到期)。这是"冷数据淘汰",避免长期堆积无人访问的缓存。use_temp_path=off:缓存文件先写临时目录再移动会产生额外 IO,关掉后直接写目标文件,性能更好。这是 Nginx 官方推荐的优化项。fastcgi_cache_key:最需要理解的参数。默认包含$scheme$request_method$host$request_uri,表示 https/http、GET/POST、域名、路径四者任一不同就视为不同页面。这里如果写错,要么缓存串号(不同页面返回同一内容),要么永远不命中。
第二步:在 server / location 里启用缓存
server {
listen 443 ssl http2;
server_name www.yoursite.com;
root /www/wwwroot/site;
# 复用缓存区
fastcgi_cache PHP_CACHE;
fastcgi_cache_valid 200 301 302 10m; # 200/301/302 缓存 10 分钟
fastcgi_cache_valid 404 1m; # 404 短缓存,减少无效回源
fastcgi_cache_use_stale error timeout invalid_header updating
http_500 http_502 http_503 http_504;
fastcgi_cache_background_update on;
fastcgi_cache_min_uses 1;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# 让响应头暴露缓存状态,方便排障
add_header X-Cache-Status $upstream_cache_status always;
}几个关键点:
fastcgi_cache_use_stale ... updating:当缓存过期、正在后台更新(或 PHP-FPM 出错、超时、返回 5xx)时,继续用旧缓存顶上。这是可用性的核心保障——你的 PHP-FPM 挂了,缓存过的页面依然能正常打开,用户毫无感知。这是把缓存配出"生产级"感觉的关键一行。fastcgi_cache_background_update on:缓存过期后,让一个请求去后台更新,其他请求继续拿旧内容,避免"过期瞬间请求全部卡住"。fastcgi_cache_min_uses 1:一个 URL 被请求 1 次就缓存。默认也是 1,但有些配置里被调大过,会导致"新文章第一次访问不缓存",调试时容易被误导。add_header X-Cache-Status $upstream_cache_status:排障必备。它会在响应头里告诉你这次是 HIT(命中)、MISS(未命中)、EXPIRED(过期)、BYPASS(跳过)、STALE(用了旧缓存)还是 UPDATING。没有这个头,你根本不知道缓存有没有在工作。
第三步:必须排除的"不该缓存"的请求
这是整个配置里最容易出事故的部分。如果把登录状态、后台页面一起缓存了,会导致 A 用户看到 B 用户的内容,或者管理员看不到后台。必须用一个变量把动态请求排除出去:
# 定义跳过缓存的判断逻辑
set $skip_cache 0;
# POST 请求一律不缓存
if ($request_method = POST) { set $skip_cache 1; }
# 带查询字符串的请求不缓存(URL 参数通常代表个性化内容)
if ($query_string != "") { set $skip_cache 1; }
# 后台、登录、预览、feed、sitemap 等动态路径不缓存
if ($request_uri ~* "/wp-admin/|/wp-login\.php|/xmlrpc\.php|/admin/|/action/|/preview/|/feed/|/sitemap") {
set $skip_cache 1;
}
# 登录用户不缓存(cookie 判断)
if ($http_cookie ~* "comment_author|wordpress_logged_in|typecho_authCode|PHPSESSID") {
set $skip_cache 1;
}
# 后台和登录用户同时禁止写入缓存
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;逐条说明为什么:
- POST 不缓存:表单提交、评论、登录都是 POST,缓存它们会造成灾难性后果(比如评论发一次被缓存,别人看到的是提交成功页)。
- 带 query string 不缓存:
?replytocom=、?preview=、?p=这类参数都代表动态内容。一刀切排除最简单也最安全。有人为了命中率不排除 query string,结果出现"搜索页缓存了别人的搜索结果"这类串号 bug。 - 动态路径排除:后台、登录、xmlrpc、评论提交、预览,这些要么含敏感信息要么必须实时。Typecho 的
/action/就是所有动态操作的入口。 - 登录态 cookie 排除:这是最精细的一条。同一个 URL,游客看到的是静态文章,登录用户看到的是带"编辑"按钮和管理栏的页面。如果不排除,登录用户会看到缓存的游客版本,或者更糟——管理员页面被缓存后被游客看到。Typecho 的登录 cookie 是
typecho_authCode,WordPress 是wordpress_logged_in。
fastcgi_cache_bypass 和 fastcgi_no_cache 的区别要分清:前者是"这次不读取缓存",后者是"这次不写入缓存"。两个都要设成 $skip_cache,才是完整的"这些请求既不读也不写"。
缓存粒度的两种思路:全站缓存 vs 分层缓存
配置方式有两种,各有取舍:
思路一:全站统一缓存。把 fastcgi_cache 放在 http 块,对所有 PHP 请求生效,只靠 $skip_cache 排除动态请求。优点是配置简单、命中率高;缺点是一旦 $skip_cache 规则有遗漏,就会出现"该动态的页面被缓存了"的问题。
思路二:分层缓存(推荐)。默认不缓存,只对文章详情页、分类页、首页这类"确定性静态内容"显式开启:
# 默认:所有请求都不缓存
fastcgi_cache_bypass 1;
fastcgi_no_cache 1;
# 只在这个 location 里缓存"文章详情页"
location ~ ^/[0-9]+\.html$ {
fastcgi_cache PHP_CACHE;
fastcgi_cache_valid 200 10m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# ... fastcgi_pass 相关配置 ...
}
# 首页和分类页
location = / {
fastcgi_cache PHP_CACHE;
fastcgi_cache_valid 200 5m;
# ...
}分层思路的核心逻辑是:默认拒绝,按需放行。只有你明确知道"这个 URL 对所有人返回相同内容"的才缓存。个人站长如果不确定自己的程序哪些页面是动态的,用这个思路最安全——至少不会把后台缓存了。代价是命中率不如全站缓存,需要白名单化的积累。
缓存没命中的五大原因(排查手册)
配置写完了,但 X-Cache-Status 一直是 MISS?按顺序排查:
- 请求带了 Cookie。游客也可能带着
PHPSESSID之类的 cookie。如果你用$http_cookie ~* "PHPSESSID"做跳过判断,那几乎所有"带 cookie 的访客"都被跳过了。检查方式:curl -I不带 cookie 测试,看是否 HIT。如果无 cookie 时 HIT、带 cookie 时 BYPASS,就是这个问题。解决:只针对真正的登录 cookie 判断,不要用宽泛的PHPSESSID。 - 响应带了 Set-Cookie。Nginx 默认不缓存带
Set-Cookie头的响应,因为那意味着内容是个性化的。PHP 程序如果给每个响应都种 cookie(比如统计插件),缓存就永远 MISS。检查:curl -I https://site/1214.html看响应头有没有Set-Cookie。有的话,要么在 PHP 层去掉,要么用fastcgi_ignore_headers Set-Cookie;强制忽略(慎用,可能造成串号)。 - 响应头带了
Cache-Control: no-cache/private。同样会被 Nginx 尊重而不缓存。用fastcgi_ignore_headers Cache-Control Expires;覆盖。注意这也是一把双刃剑——程序明确说"不要缓存",你强行缓存就要自己承担后果。 - URL 里有 query string 或尾斜杠差异。
/1214.html和/1214.html是一样的,但/1214.html?和/1214.html的$request_uri不同,缓存键也就不同。CDN 或分享链接带了跟踪参数(?from=wechat)时尤其明显。解决办法是用map把 query string 归一化后再进缓存键。 - 磁盘缓存目录权限问题。Nginx worker 用户(通常是
www-data或nginx)对/var/cache/nginx/fastcgi没有写权限,缓存写不进去,也不会报错。检查:ls -ld /var/cache/nginx/fastcgi,属主必须是 Nginx 运行用户。同时看看 error.log 里有没有 "failed to open cache file" 之类。
缓存更新:改了文章怎么立刻生效
缓存带来的副作用是"我改了文章,前台还是旧内容"。处理方式有三种,按推荐度排序:
方式一:发布时主动 purge(推荐)。在发布脚本里,文章发布后立刻把对应的缓存键删掉。因为缓存文件按 md5(cache_key) 命名,可以用 md5 反算路径:
# cache_key = "httpsGETwww.yoursite.com/1214.html"
KEY="httpsGETwww.yoursite.com/1214.html"
HASH=$(echo -n "$KEY" | md5sum | awk '{print $1}')
# 路径 = 最后1位/倒数2-3位/完整hash
PATH_CACHE="/var/cache/nginx/fastcgi/${HASH: -1}/${HASH: -3:2}/${HASH}"
rm -f "$PATH_CACHE"
echo "purged: $PATH_CACHE"更稳妥的做法是装 ngx_cache_purge 模块,配置一个内部 PURGE location,用 HTTP 请求触发清理,不用自己算路径。
方式二:缩短缓存时间。把 fastcgi_cache_valid 200 10m 改成 1m 或 30s。简单粗暴,代价是回源次数增加,PHP 又要开始忙了。适合内容更新频繁的站。
方式三:按内容类型分时长。首页、分类页更新频繁 → 缓存 1–5 分钟;文章详情页一旦发布基本不改 → 缓存 1–6 小时。这是最合理的配置,也是我最终采用的方案:
# 文章详情页缓存 1 小时
location ~ ^/[0-9]+\.html$ {
fastcgi_cache_valid 200 1h;
}
# 首页/分类页缓存 3 分钟
location ~ ^/(page/[0-9]+/?|category/.*)?$ {
fastcgi_cache_valid 200 3m;
}效果验证与容量监控
上线后要持续确认三件事:
# 1. 命中率:连续请求同一页面,第二次必须 HIT
curl -sI https://www.yoursite.com/1214.html | grep -i x-cache-status
curl -sI https://www.yoursite.com/1214.html | grep -i x-cache-status
# 2. 缓存目录实际占用了多少、有多少文件
du -sh /var/cache/nginx/fastcgi
find /var/cache/nginx/fastcgi -type f | wc -l
# 3. PHP-FPM 的压力是否真的降了(对比上线前后的 active processes)
systemctl status php8.2-fpm --no-pager | head -20
# 或者看 slowlog 里还有没有慢请求判断缓存是否真正见效,不要只看"页面变快了",而要看 PHP-FPM 的 CPU 占用有没有下降。如果页面变快但 PHP-FPM CPU 没降,说明请求依然打到了 PHP,只是别的地方快了。缓存真正生效的标志是:命中缓存的请求完全不出现 PHP-FPM 的访问记录。可以用 strace -p $(pgrep -f php-fpm | head -1) 观察,或者直接看 PHP-FPM 的 access log 条数是否与 Nginx 请求数出现明显差值。
常见坑位速查
| 现象 | 根因 | 解决 |
|---|---|---|
| 状态永远 MISS | 响应带 Set-Cookie,或 cookie 判断过宽 | 检查响应头,精确化 $skip_cache |
| 后台被缓存,登录错乱 | 没排除 /admin/、登录 cookie | 补全排除规则,后台路径加 no_cache |
| 游客看到别人的内容 | 缓存键没区分登录态,或强制忽略了 Set-Cookie | 登录用户走 bypass,别用 ignore_headers 硬来 |
| 改了文章不生效 | 缓存未过期 | 发布时 purge,或缩短 valid 时间 |
| 磁盘被缓存写满 | 没设 max_size | 设置 max_size + inactive |
| 过期瞬间卡死 | 缓存雪崩,大量请求同时回源 | fastcgi_cache_lock on + background_update |
| PHP-FPM 挂了全站 502 | 没配 use_stale | 加 fastcgi_cache_use_stale ... http_502 http_503 |
| 缓存目录写不进去 | 权限问题,静默失败 | 确认目录属主为 Nginx worker 用户 |
小结:这一层缓存值不值得做
fastcgi_cache 的收益很直接:命中时 PHP 完全不执行,一台 1 核 2G 的小机器能扛住的并发量级可能提升 5–10 倍,TTFB 从几百毫秒降到几十毫秒。对一个跑在低配 VPS 上的个人网站来说,这往往是"花最少的钱、得到最大性能提升"的一步。
但它的风险也很直接:配错了会串号、会缓存后台、会让改内容不生效。所以正确的心态是——先按"默认拒绝、白名单放行"的分层思路做,只用 X-Cache-Status 逐个验证,确认一个页面缓存正确后再扩大范围。不要一上来就全站开缓存然后祈祷不出事。
按顺序落地:装好模块 → 定义 keys_zone 和 cache_key → 加 X-Cache-Status 头 → 先只对文章详情页开缓存 → 用 curl 验证命中 → 补全 skip_cache 规则 → 配 purge 机制 → 观察一周 PHP-FPM 负载。走完这套,你会发现自己那台最便宜的 VPS,其实远没有到需要换机器的程度。