PHP OPcache 预热与 file_cache 持久化实战:FPM 重启后首屏从 784ms 降到 141ms 的完整配置

一次 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/.probe

file_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-opcache

3. 长期看命中率基线。 每天抓一次 opcache_hit_rate 存下来,一周之后你能看出正常水位是多少。命中率突然从 99.9% 掉到 90% 以下,通常意味着有新部署频繁改动文件,或者缓存池被 OOM 重启了。这个基线比任何瞬时数据都有用。

回到最初那个问题

那台 TTFB 抖动的站最后怎么解决的?三步:开了 file_cache(第一次请求从 784ms 到 141ms)、把 max_accelerated_files 从默认的 10000 提到 20000(消除了 hash_restarts)、在部署脚本末尾加了 CLI 预编译(把剩下的 141ms 里的编译部分也消掉了,最终首请求 118ms)。

值得强调的是,这三步里没有一步需要改业务代码,也没有一步需要升级硬件。它们全部是「让已有的编译结果别被扔掉」——性能优化的收益,很多时候不来自让事情变快,而来自别让已经做完的事重做一遍。OPcache 的默认配置之所以保守,是因为它要兼顾共享主机那种动态环境;而你的机器是固定的、单租户的、部署流程是自己控制的。这种情况下,默认值就是欠调优的起点。

Last modification:October 19th, 2026 at 08:24 pm

Leave a Comment