一次 FPM 重启,让首屏慢了 700ms——这不是玄学
有台跑 Typecho 的 PHP 站,平时 TTFB 稳定在 90ms 左右。每次发布新版本代码要 systemctl reload php8.2-fpm,reload 之后的前十几分钟,前台 TTFB 会蹿到 400~800ms,然后慢慢回落到 90ms。日志里没有任何错误,慢查询日志也是空的,CPU 和内存都很闲。很多人遇到这个现象会去查 PHP-FPM 的进程池、查 MySQL、查磁盘,方向全错。
原因很简单:PHP 的 opcode 缓存被清空了。PHP 每次执行一个 .php 文件,都要经过「读文件 → 词法分析 → 语法分析 → 编译成 opcode → 执行」这条链。OPcache 把编译后的 opcode 存在共享内存里,让第二步到第四步只在第一次发生。FPM 进程池重启(哪怕只是 reload)会重新映射共享内存段,缓存归零,第一个访问的请求要把所有涉及的文件全部重新编译一遍。
一个 Typecho 站点前台请求会加载 30~60 个 PHP 文件,加上主题的十几个文件,加起来 50~80 次编译。这活儿在冷缓存状态下就是这个几百毫秒的来源。而它又恰好发生在「你刚发布完内容、正在刷新页面看效果」的时刻——所以你感觉每次发文章网站都卡,其实是缓存冷启动。
这篇文章只讲一件事:怎么让 OPcache 在 FPM 重启后不用重新预热。做法有三层,从最省事的 file_cache 到最彻底的启动预热,逐层展开。
第一层:先把当前状态量出来,别凭感觉调
调 OPcache 的第一原则和所有性能调优一样:先有数字。OPcache 自带一个状态页,但默认不开放。不要用那些需要装扩展的第三方面板,直接开内置的:
; /etc/php/8.2/fpm/conf.d/10-opcache.ini 里追加
; 注意:opcache.status 需要 opcache.enable=1(默认已是)
; 只在内网/本机开放,别暴露到公网<?php
// /var/www/html/opcache-status.php —— 用完请删除
header('Content-Type: text/plain; charset=utf-8');
$s = opcache_get_status(false);
if (!$s) { exit("opcache not enabled\n"); }
$used = $s['memory_usage']['used_memory'];
$free = $s['memory_usage']['free_memory'];
$waste = $s['memory_usage']['wasted_memory'];
printf("enabled: %s\n", $s['opcache_enabled'] ? 'yes' : 'no');
printf("cached files: %d\n", $s['opcache_statistics']['num_cached_scripts']);
printf("hits: %d\n", $s['opcache_statistics']['hits']);
printf("misses: %d\n", $s['opcache_statistics']['misses']);
printf("hit rate: %.2f%%\n", $s['opcache_statistics']['opcache_hit_rate']);
printf("memory used: %.1f MB\n", $used / 1048576);
printf("memory free: %.1f MB\n", $free / 1048576);
printf("memory waste: %.2f%% (%d bytes)\n",
$s['memory_usage']['current_wasted_percentage'],
$s['memory_usage']['wasted_memory']);
printf("max keys: %d\n", $s['opcache_statistics']['max_cached_keys']);
printf("restarts: oom=%d hash=%d manual=%d\n",
$s['opcache_statistics']['oom_restarts'],
$s['opcache_statistics']['hash_restarts'],
$s['opcache_statistics']['manual_restarts']);
几个指标要看懂:
- hit rate 低但 num_cached_scripts 也不高 → 缓存容量根本没满,是别的问题(比如
validate_timestamps导致反复失效)。 - oom_restarts > 0 → 缓存池不够大,OPcache 一直在自我清空重建。这是性能灾难,比不开还糟,因为它会周期性地把所有缓存扔掉。
- hash_restarts > 0 →
opcache.max_accelerated_files设置得太小,装不下文件数。这个值和缓存内存是两回事,很多人只调大了内存,没调这个。 - memory waste > 10% → 碎片化严重,需要
opcache.max_wasted_percentage或者重启。
标准输出长这样(一个中小型 Typecho 站):
enabled: yes
cached files: 412
hits: 2481903
misses: 1633
hit rate: 99.93%
memory used: 38.4 MB
memory free: 89.6 MB
memory waste: 0.00% (0 bytes)
max keys: 16229
restarts: oom=0 hash=0 manual=0注意 max keys: 16229——这个数字是 next_pow2(max_accelerated_files) 出来的,不是原值。如果 max_accelerated_files 设成 10000,内部会算到 16229。而 cached files: 412 远低于它,说明容量充足。这就是「先量再调」的意义:你这台机器可能根本不需要动 max_accelerated_files。
第二层:file_cache——让 opcode 落在磁盘上,重启后直接复用
这是本文的核心技巧,也是绝大多数 PHP 配置教程里完全没提的一个选项。opcache.file_cache 让 OPcache 把编译后的 opcode 序列化写入磁盘,共享内存丢失后,下次请求先查磁盘上的副本,命中就直接映射,不用重新编译。
opcache.file_cache=/var/cache/php-opcache
opcache.file_cache_only=0
opcache.file_cache_consistency_checks=1
opcache.file_cache_fallback=1四个参数的含义和取舍:
file_cache:目录路径。这个目录会按「文件路径的哈希」分层建子目录,看起来像一堆十六进制名字。目录必须对 FPM 运行用户可写(Debian 上是 www-data)。
sudo mkdir -p /var/cache/php-opcache
sudo chown www-data:www-data /var/cache/php-opcache
sudo chmod 700 /var/cache/php-opcache
# 确认目录能被写入
sudo -u www-data touch /var/cache/php-opcache/.probe && echo OK
sudo rm -f /var/cache/php-opcache/.probefile_cache_only=0:保持共享内存为主、磁盘为备份。设成 1 就只用磁盘,那性能会很差(每次都要读盘 + 反序列化),没有任何理由这么配。
file_cache_consistency_checks=1:读磁盘缓存前检查源文件是否被改动。这是安全默认值,代价是每次缓存命中要 stat 一次源文件。如果你的发布流程是「整个目录原子替换」而不是「原地改文件」,可以设成 0 换一点性能,但大多数站不值得冒这个险。
file_cache_fallback=1:共享内存不可用时降级到磁盘缓存。这个才是让「重启后不冷」生效的关键开关,必须开。
配好之后重启 FPM,验证磁盘缓存真的在写:
sudo systemctl restart php8.2-fpm
curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\n" https://example.com/
curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\n" https://example.com/
find /var/cache/php-opcache -type f -name '*.bin' | wc -l
du -sh /var/cache/php-opcache预期结果:访问几个页面之后,.bin 文件数量应该和 cached files 数量接近。如果目录是空的,检查三件事——FPM 用户有没有写权限、opcache.file_cache 路径在 ini 里有没有被引号包住(不要包)、以及是否被 open_basedir 限制(某些面板默认开着 open_basedir,会把 /var/cache 挡在外面)。
file_cache 的效果到底有多大
实测数据,同一个站(Typecho + 412 个 PHP 文件),操作是「重启 FPM 后立刻请求首页」:
# 无 file_cache
run 1: 0.784s
run 2: 0.512s
run 3: 0.331s
run 4: 0.118s
# 有 file_cache(重启 FPM,共享内存已清空)
run 1: 0.141s
run 2: 0.122s
run 3: 0.115s
run 4: 0.113s第一次请求从 784ms 降到 141ms。剩下的 28ms 差距是磁盘读 + 一致性检查的固定开销。对「每天重启几次 FPM」的场景,这个改动收益巨大,成本只是配四行 ini。
file_cache 的三个坑
坑一:换 PHP 版本后缓存不兼容。 opcode 格式和 PHP 版本强绑定。从 8.1 升 8.2,旧的 .bin 会被静默忽略(版本号在文件头里,不匹配就跳过),所以不会出错,但也没起作用——首次请求仍然是冷的。升级 PHP 之后记得清空目录:
sudo systemctl stop php8.2-fpm
sudo rm -rf /var/cache/php-opcache/*
sudo systemctl start php8.2-fpm坑二:目录无限增长。 每个被编译的文件路径对应一个缓存文件,包括你后来删掉的那些文件的残留。opcache.validate_timestamps 相关逻辑会清理但很保守。建议给它一个定期的 tmpfiles.d 清理规则,只清「7 天未访问」的:
# /etc/tmpfiles.d/php-opcache.conf
d /var/cache/php-opcache 0700 www-data www-data -
注意 tmpfiles.d 的 d 类型只保证目录存在和权限正确,不会清内容。真想清理,用 find ... -atime +7 -delete 挂到自己的维护脚本里,并且只在 FPM 停止时执行——运行中删除缓存文件本身是安全的(已被映射的文件不受影响),但会让下次请求重新写一遍,纯粹浪费。
坑三:容器化部署时目录必须持久化。 如果 PHP 跑在 Docker 里,/var/cache/php-opcache 是容器内路径,容器重建就没了,file_cache 完全失效。必须挂一个 volume。这也是很多人「配了但没效果」的真实原因——在他们的容器里,FPM 重启等于容器重建,路径本身消失了。
第三层:主动预热——把冷启动彻底消掉
file_cache 解决「共享内存丢了但磁盘还在」。但有两种情况它救不了:首次部署(磁盘也是空的)、以及 file_cache_consistency_checks=1 时源文件改动导致缓存失效。
彻底的办法是「在流量到来之前,主动把文件编译一遍」。原理简单:用同样的 PHP 版本、同样的 ini 配置,把项目里所有 .php 文件「编译但不执行」,把 opcode 塞进缓存。
方式 A:CLI 预编译(不共享 FPM 的缓存,仅作参照)
先要说清楚一件事:CLI 和 FPM 是两个独立的 SAPIs,默认不共享 OPcache 共享内存段。 你在 CLI 里跑一个脚本把文件全 opcache_compile_file() 一遍,对 FPM 的共享内存没有任何帮助——除非把 opcache.file_cache 指向同一个目录,这时候磁盘缓存是共享的,file_cache 就成了桥梁。
这一点非常重要,也是很多「预热脚本」实际无效的原因。正确的做法是利用 file_cache:CLI 预热 → 写磁盘缓存 → FPM 读取磁盘缓存。
<?php
// /usr/local/bin/opcache-warmup.php
// 用法:php -d opcache.enable_cli=1 -d opcache.file_cache=/var/cache/php-opcache \
// /usr/local/bin/opcache-warmup.php /var/www/html
$root = rtrim($argv[1] ?? '/var/www/html', '/');
$ok = 0; $fail = 0; $skip = 0;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if (!$f->isFile() || $f->getExtension() !== 'php') { continue; }
$path = $f->getPathname();
// 跳过不该编译的:vendor 里的测试、迁移脚本、模板片段
if (preg_match('#/(tests?|Tests|migrations?|cache)/#', $path)) { $skip++; continue; }
if (!opcache_compile_file($path)) {
// 编译失败通常是语法错误或者文件里只有 HTML(没有 php 块)
$fail++;
continue;
}
$ok++;
}
printf("compiled=%d skipped=%d failed=%d\n", $ok, $skip, $fail);
要注意 opcache_compile_file() 的两个行为特点:
- 它不会执行文件,只编译。所以依赖运行时定义的常量、自动加载的类不会因此被解析。这对「预热 opcode」来说完全够用。
- 它对「只含 HTML 没有 PHP 标签」的文件会返回 false 并产生 warning。大量触发说明你扫到了模板目录,应该在跳过规则里排掉。
把这个脚本接到部署流程的最后一步:
# 部署脚本片段
sudo systemctl stop php8.2-fpm # 或 reload,视部署方式
sudo -u www-data rm -rf /var/cache/php-opcache/*
# 用 CLI 预编译,写磁盘缓存
sudo -u www-data php \
-d opcache.enable_cli=1 \
-d opcache.file_cache=/var/cache/php-opcache \
-d opcache.file_cache_only=0 \
/usr/local/bin/opcache-warmup.php /var/www/html
sudo systemctl start php8.2-fpm关键细节:CLI 的 opcache.file_cache 路径要和 FPM 完全一致,并且 opcache.file_cache_only=1 在 CLI 里其实更合理——因为 CLI 的共享内存是进程级的一次性东西,没必要占;只要能把文件写进磁盘缓存就行。file_cache_consistency_checks 也要一致,它参与缓存文件头的计算。
方式 B:HTTP 静态预热(更简单,更容易出问题)
不写脚本,直接 curl 一遍站点所有 URL。这是最直观的方式,但对动态站来说效果有限且风险不小:
# 从 sitemap 拉 URL,逐条请求
curl -s https://example.com/sitemap.xml \
| grep -oE '<loc>[^<]+</loc>' \
| sed -E 's#</?loc>##g' \
| while read -r u; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$u")
echo "$code $u"
done | sort | uniq -c -w3为什么说它风险不小:它真的会执行业务逻辑。 顺序错了会把签到、评论计数、访问统计、缓存写入全部触发一遍。尤其是有「访问量 +1」逻辑的站,预热等于给每个页面刷了一次假流量。更麻烦的是如果预热脚本并发起来,会直接把 FPM 进程池打满,反而造成一次人为的服务中断。
如果确实要用这种方式,三条纪律:串行执行(不要 xargs -P)、限速(每次之间 sleep 0.2~0.5 秒)、只预热不需要登录的公开页面。
第四层:validate_timestamps——性能与「改了代码立刻生效」的取舍
这个参数是 OPcache 调优里最常被误解的。它决定 OPcache 是否在每次请求时检查源文件的修改时间。
opcache.validate_timestamps=1 ; 默认。每次都 stat 源文件
opcache.revalidate_freq=2 ; 每 2 秒检查一次(不是每次请求)
opcache.revalidate_path=0 ; 不检查 include_path,能省一次 stat生产环境的常见建议是「设 validate_timestamps=0,性能最好」。这句话对,但需要两个前提:
前提一:你的发布流程必须有一步清缓存。 validate_timestamps=0 意味着改了文件 OPcache 永远不知道。必须靠 opcache_reset() 或者重启 FPM 让它重新编译。如果你的发布是 git pull 然后就完事,那这个配置会让你以为没生效、实际上是生效了又看不出来,最后变成「改了代码但线上没变化」的诡异 bug。
前提二:确认你的 stat 开销真的是瓶颈。 revalidate_freq=2 时,每个文件最多每 2 秒 stat 一次。412 个文件、每秒 50 个请求,实际 stat 次数非常有限,而且 stat 本身会命中内核 dentry 缓存。在绝大多数中小站点上,validate_timestamps=0 带来的收益是 1~3ms,不值得用「代码改了不生效」的风险去换。
我的建议:中小站保持 validate_timestamps=1 + revalidate_freq=2。真正的收益大头在 file_cache 和足够的缓存容量上,这里省下来的几毫秒毫无意义。
完整的 ini 配置,以及每个数字的来由
下面这份配置适用于单机、1~2GB 内存的 VPS,跑一个中小型 PHP 站(Typecho / WordPress / 自研框架):
; /etc/php/8.2/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.max_wasted_percentage=10
opcache.validate_timestamps=1
opcache.revalidate_freq=2
opcache.revalidate_path=0
opcache.save_comments=1
opcache.fast_shutdown=0
opcache.file_cache=/var/cache/php-opcache
opcache.file_cache_only=0
opcache.file_cache_consistency_checks=1
opcache.file_cache_fallback=1
opcache.huge_code_pages=0逐项说明:
memory_consumption=128:按上面实测的 38.4MB 用量,128 给了三倍余量。如果你开 file_cache,共享内存的需求可以更小,因为磁盘层兜底。不要看到「越大越好」就设成 512——它是每个 FPM 进程池一份的固定占用,不是按需分配的。
max_accelerated_files=20000:这个值决定了能缓存多少个文件条目。WordPress 装十几个插件能到 3000~5000 个文件,20000 足够。设小了会出现 hash_restarts,那比内存不够更糟。
interned_strings_buffer=16:字符串驻留池。默认 8MB,跑框架类项目很容易填满,填满后只是不再驻留新字符串(不会报错、不会重启),但性能会退。16MB 是个稳妥值。
save_comments=1:不要关。很多人为了省内存把它设成 0,结果 Doctrine、PHPUnit、Typecho 的部分注解解析全部失效。省的那点内存(通常 5% 以内)完全不值得。
fast_shutdown=0:这个名字有误导性,它不是「快速关闭」的性能开关,而是一个已被废弃的实验特性。PHP 7.2+ 官方建议保持 0。有些老教程让你设成 1,别听。
huge_code_pages=0:大页内存能减少 TLB miss,但需要系统层面先配好 vm.nr_hugepages,否则开了也没用(OPcache 会静默回退)。小站收益不明显,保持默认。
怎么验证这整套改动真的起作用了
三个层次,从易到难:
1. 重启后首次请求的 TTFB。 这是最直接的验收。写个脚本,重启 FPM,立刻测:
#!/bin/bash
URL="https://example.com/"
echo "--- before restart ---"
for i in 1 2 3; do
curl -s -o /dev/null -w " %{time_starttransfer}\n" "$URL"
done
sudo systemctl restart php8.2-fpm
sleep 1
echo "--- after restart (cold?) ---"
for i in 1 2 3; do
curl -s -o /dev/null -w " %{time_starttransfer}\n" "$URL"
done配好 file_cache 之后,重启前后的差距应该明显收窄。如果重启后第一条仍然是几百毫秒,说明 file_cache 没生效——回到前面查权限和路径。
2. 磁盘缓存有没有被读到。 OPcache 状态页里 file_cache 相关统计不在 opcache_get_status() 的默认输出里,要看 opcache_get_status(true) 的 scripts 项,每个脚本条目里有缓存命中来源。更简单的办法是看目录的访问时间:
# 找最近被访问过的缓存文件
find /var/cache/php-opcache -name '*.bin' -atime -1 | head -5
# 统计总量
find /var/cache/php-opcache -name '*.bin' | wc -l
du -sh /var/cache/php-opcache3. 长期看命中率基线。 每天抓一次 opcache_hit_rate 存下来,一周之后你能看出正常水位是多少。命中率突然从 99.9% 掉到 90% 以下,通常意味着有新部署频繁改动文件,或者缓存池被 OOM 重启了。这个基线比任何瞬时数据都有用。
回到最初那个问题
那台 TTFB 抖动的站最后怎么解决的?三步:开了 file_cache(第一次请求从 784ms 到 141ms)、把 max_accelerated_files 从默认的 10000 提到 20000(消除了 hash_restarts)、在部署脚本末尾加了 CLI 预编译(把剩下的 141ms 里的编译部分也消掉了,最终首请求 118ms)。
值得强调的是,这三步里没有一步需要改业务代码,也没有一步需要升级硬件。它们全部是「让已有的编译结果别被扔掉」——性能优化的收益,很多时候不来自让事情变快,而来自别让已经做完的事重做一遍。OPcache 的默认配置之所以保守,是因为它要兼顾共享主机那种动态环境;而你的机器是固定的、单租户的、部署流程是自己控制的。这种情况下,默认值就是欠调优的起点。