个人站长用一台 2 核 4G 的 VPS 跑博客,最常见的瓶颈不是 CPU 也不是带宽,而是 PHP 那几层缓存到底有没有真的在干活。很多人按教程装了 Redis,把文章列表塞进去,页面还是 TTFB 800ms;又或者开了 OPcache 就以为万事大吉,结果每次请求仍然在重新解析模板。这篇把单机缓存的四层拆开讲:OPcache 管字节码、APCu 管用户态数据、fastcgi_cache 管整页、Redis 管跨进程,每层负责什么、怎么观测、什么情况下会假装工作但实际没命中。
一、先搞清楚四层各管什么
PHP 的请求处理路径是一条链:Nginx 收到请求 → FastCGI 协议转给 PHP-FPM → FPM 分配一个 worker → worker 里的 Zend 引擎加载 PHP 源文件并编译成 opcode → 执行 opcode,产生 SQL 查询和模板渲染 → 输出 HTML 回 Nginx。每一段都能缓存,但缓存的东西完全不同。
Nginx ──(fastcgi_cache: 整页 HTML)──┐
↓
PHP-FPM worker
↓
Zend OPcache (opcode)
↓
APCu (用户态变量,进程共享)
↓
Redis (跨机/跨池共享)
↓
MySQL层级越靠上,命中后省下的工作越多,但也越不灵活。fastcgi_cache 命中时 PHP 根本不启动,TTFB 可以压到 10ms 以内;APCu 命中时省的是一次数据库查询;OPcache 命中省的是一次文件读取加编译,是性价比最高、也最容易被忽略的一层。
这里有个常见误区:以为 APCu 和 Redis 是二选一。它们其实不冲突,区别在于 APCu 的存储是每个 PHP 进程组(master 及其 worker)内部的共享内存段,不跨进程树、不跨机、CLI 里也拿不到。单机博客用 APCu 完全够用,只有当你要在多台 web 前面共享会话或缓存,才有必要上 Redis。
二、OPcache:最便宜也最容易配错的一层
OPcache 把所有 PHP 文件的编译结果存在共享内存里,worker 直接跳到执行阶段。判断它有没有工作时不要看 php -v 是否显示 "with Zend OPcache",要看运行时的统计:
<?php
$s = opcache_get_status(false);
printf("opcache: %s cached %d hit %.2f%% mem %.1fMB / %.1fMB\n",
$s['opcache_enabled'] ? 'on' : 'OFF',
$s['opcache_statistics']['num_cached_scripts'],
$s['opcache_statistics']['opcache_hit_rate'],
$s['memory_usage']['used_memory'] / 1048576,
$s['memory_usage']['free_memory'] / 1048576 + $s['memory_usage']['used_memory'] / 1048576);把这段放进一个只对本地 IP 开放的 opcache.php,用 curl 打十几次再看。健康的站点连续请求后命中率应该在 99% 以上。如果一直显示 0,或者数字很小,常见原因有这么几个:
一是 worker 数量除以 OPcache 内存后的容量不够。OPcache 的 opcache.memory_consumption 默认只有 64MB 或 128MB,一个 WordPress + 若干插件的站点轻松超过 3000 个脚本文件,一旦内存写满,新的脚本就进不来,旧脚本被反复淘汰,命中率断崖下跌。观测方式是看 opcache_get_status() 里 memory_usage.free_memory 是否接近 0,以及 interned_strings_usage 是否打满。写满后唯一的有效动作是加大 memory_consumption 并重启 FPM,而不是去调 max_accelerated_files——那个参数只是哈希表大小,不影响能存多少字节。
二是每个请求都在改文件时间戳。如果你的发布流程是 rsync 到服务器再改配置,文件的 mtime 每变一次,OPcache 就会把对应脚本作废重新编译。反过来,如果部署方式是"解压新目录再切软链",旧文件的 inode 变了但路径没变,opcache.validate_timestamps=1 会按路径判断,可能一直认为是旧脚本,导致改了代码不生效。生产环境更稳的做法是 opcache.validate_timestamps=0 加 opcache.revalidate_freq=0,然后在每次部署后显式 reload FPM:
sudo systemctl reload php8.2-fpm另外,CLI 和 FPM 是完全不共享 OPcache 的。你在命令行跑一个脚本测性能,量到的永远是冷启动,别拿它推 FPM 的表现。
三、APCu:命中了和没命中,差多少
APCu 存的是 PHP 变量,适合放"查一次能用几十分钟"的数据:导航菜单、分类列表、站点配置、热门文章 ID 数组。它不支持 TTL 精确到秒的淘汰策略,而是用分段哈希表,超过 apc.ttl 的条目在内存紧张时才被回收。所以第二个常见坑来了:以为设了 TTL 就会按时过期,实际上只要内存没撑满,过期条目会一直躺在那里。
正确的用法不是依赖 TTL 精确性,而是自己做版本号。比如整站配置缓存,键名里带一个版本号,改配置时只改版本号常量,旧数据自然失效,不需要逐条删除:
define('CONF_VER', '20261020a');
function site_conf() {
$key = 'conf_' . CONF_VER;
$v = apcu_fetch($key, $ok);
if ($ok) return $v;
$v = $GLOBALS['db']->fetchAll('SELECT k,v FROM site_options');
apcu_store($key, $v, 0);
return $v;
}注意 apcu_store 第三个参数传 0 表示永不过期,配合版本号使用最省心。但索引页这类数据必须带 TTL,否则发布新文章后一直不刷新:
function hot_posts($limit = 10) {
$key = 'hot_' . $limit . '_' . date('YmdH');
if ($v = apcu_fetch($key)) return $v;
$v = $GLOBALS['db']->fetchAll(
'SELECT id,title,views FROM posts ORDER BY views DESC LIMIT ?', [$limit]);
apcu_store($key, $v, 3600);
return $v;
}键名里带 date('YmdH') 是个小技巧:每小时自然形成新键,旧键在一小时后被淘汰,既不会读到脏数据,也不需要写清理逻辑。
四、命中率低的时候,去查这五个原因
APCu 装上之后最常见的情形是:apcu_cache_info() 里的 num_hits 一直是 0,num_misses 每请求都涨。按概率排,原因大致是这几个。
键里带了每次请求都变的东西。比如 md5($_SERVER['REQUEST_URI'] . microtime())、带 session_id、带用户 ID。这种写法看起来在缓存,实际每次都是冷启动,还把内存塞满,命中率永远是 0。判断方法很简单:连续打两次同一个 URL,看 num_entries 是不是涨了 2。涨了就是键设计错了。
TTL 设得太短。设 5 秒、10 秒的缓存在没有流量的站点上等于没有,因为两次请求间隔超过 TTL。个人博客日 PV 几千的话,至少给 300 秒起步。
存了不可序列化的东西。闭包、PDO 连接、资源句柄、SimpleXML 对象都存不进 APCu,apcu_store 会静默返回 false 而不是抛错。整个缓存函数看起来在运行,实际什么都没存。开发时在 store 之后加个 if (!$ok) error_log('apcu store failed: '.$key);,能立刻发现问题。
缓冲区被撑爆。APCu 默认 apc.shm_size=32M,一个页面如果缓存了 500 条文章全文很容易超。超了之后是"部分丢失、无报错",表现为命中率忽高忽低。看 apcu_cache_info()['mem_size'] 和 apcu_sma_info()['num_seg'],如果 avail_mem 只剩几 KB,说明已写满,加到 128M 更合适。开启 apc.smart=1 可以在内存不足时主动丢弃新写入,避免整个缓存区被淘汰。
代码路径没走到缓存函数。这个最隐蔽。模板里 if (is_home()) 分支才走缓存,但首页被 CDN 缓存住了、或者被 fastcgi_cache 挡在前面,PHP 根本没执行,APCu 自然没有命中数。这时候不是 APCu 的问题,是你已经用更外层的缓存解决了问题,属于好事。
五、fastcgi_cache:php 前面的一堵墙,也是最容易伤到自己的一堵
如果页面 TTFB 仍然在 500ms 以上,而数据库查询次数已经很少,就该考虑整页缓存了。Nginx 的 fastcgi_cache 会给原样缓存 PHP 的输出,命中时 PHP 完全不启动:
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=PHPZONE:64m
inactive=30m max_size=2g use_temp_path=off;
map $request_method $skip_cache {
default 1;
GET 0;
HEAD 0;
}
map $http_cookie $has_login {
default 0;
"~(wordpress_logged_in|comment_author|PHPSESSID)" 1;
}
map $request_uri $skip_uri {
default 0;
"~^/(admin|wp-admin|login)" 1;
}
server {
location ~ \.php$ {
fastcgi_cache PHPZONE;
fastcgi_cache_key "$scheme$request_method$host$request_uri$has_login";
fastcgi_cache_valid 200 301 10m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $skip_cache $has_login $skip_uri;
fastcgi_no_cache $skip_cache $has_login $skip_uri;
fastcgi_cache_use_stale error timeout updating http_500 http_502 http_503;
fastcgi_cache_lock on;
add_header X-FastCGI-Cache $upstream_cache_status always;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
}几个细节值得说一下。fastcgi_cache_key 里必须包含能区分用户的东西,如果站点有登录态,键里带一个"是否登录"的标记就够,不要把整个 Cookie 塞进去——那样每个访客一个键,缓存等于没有。fastcgi_cache_bypass 和 fastcgi_no_cache 必须同时配,只配前一个会出现"绕过读取但把带 Cookie 的响应写进缓存"的事故,登录用户看到别人的页面就是这么来的。
fastcgi_cache_lock on 是防雪崩的关键。没有它,缓存过期那一刻如果有 20 个并发请求同时到达,20 个都会穿透到 PHP 重新生成,把刚缓过劲的数据库再打一顿。开了之后只有一个请求去生成,其余等待并复用结果。fastcgi_cache_use_stale 配 updating 可以让过期期间继续发旧内容,用户完全无感。
上线后验证就看响应头:X-FastCGI-Cache: HIT 是命中,MISS 是回源了,BYPASS 是被规则跳过,STALE 是用的旧副本。如果发现 HIT 比例很低,先看是不是 Cookie 规则太宽,再看看 Cache-Control: no-cache、Set-Cookie 是否被 PHP 输出带上了——fastcgi_cache 默认会尊重 Set-Cookie,很多主题在页脚设置一次 Cookie 就导致整页永远不入缓存,加 fastcgi_ignore_headers Cache-Control Expires Set-Cookie; 可以覆盖,但前提是你确实不需要那个 Cookie。
六、四层如何配合:一条典型的请求路径
一个配好的单机站点,正常访客请求首页的流程应该是这样的:Nginx 查 fastcgi_cache,命中就直接返回 10ms 出头的 HTML;没命中才转给 FPM,FPM 里的代码从 APCu 取导航和热门文章,从 OPcache 拿已编译的 opcode,只对真正需要实时的部分查一次 MySQL,生成 HTML 回给 Nginx 写进缓存。整个过程里数据库最多被打一次。
所以排障顺序应该是从外往内:先看 fastcgi_cache 命中率(grep 'upstream_cache_status' access.log | awk '{print $NF}' | sort | uniq -c),再看 APCu 的 hits/misses,最后才看 OPcache 和慢查询。反过来从里往外调,往往是花了三个小时优化一条查询,外层缓存一命中,这条查询压根没被执行过。
还有一点容易被忽略:如果站点已经上了 CDN,CDN 那层缓存命中时,源站连 Nginx 都不进,上面四层的命中率看起来都会很低。别把这当成故障,先看访问日志里来自 CDN 回源节点的比例,回源请求只占 5% 的话,源站缓存命中率低是完全正常的。
七、上线顺序与回滚
这四层不建议一次全开。更稳的顺序是:先开 OPcache 并观察一周(它几乎不可能出问题,出问题也只需 reload);再开 APCu,只针对两三个确认为热点的函数加缓存,观察内存和命中率;接着开 fastcgi_cache,并且一开始只对文章详情页生效,用 location 精确限定,别全局套;最后如果确实需要多机共享,才引入 Redis。
每一步都要留回滚开关:APCu 用 apc.enabled=0,fastcgi_cache 用 fastcgi_cache 指令注释掉后 reload。所有改动都要在流量低谷做,每次改完用 curl -sI 看响应头和 TTFB,压测两分钟,确认没有 5xx 再进入下一步。
最后提醒一个运维细节:fastcgi_cache 的 keys_zone 是共享内存,改大小必须 reload 甚至 restart Nginx;max_size 是磁盘上限,写满后 Nginx 会按 LRU 清理,inactive 时间内没被访问的条目也会被删。如果发现磁盘上 /var/cache/nginx/fcgi 一直在涨而命中率不涨,多半是 fastcgi_cache_key 里混进了每次都变的东西,这个坑和 APCu 键带时间戳是同一类错误,只是发生在不同层。