PHP 改了不生效:OPcache 的失效判定、内存参数与发布后校验

站点改了一行 PHP,刷新页面发现改动没生效;把文件传上去,过了十分钟还是旧内容;重启 PHP-FPM 之后好了,于是把这个动作记成了习惯。这类「改了不生效,重启就好」的现象,九成以上不是文件没传上去,而是 opcode 缓存在起作用——PHP 把编译后的字节码留在共享内存里,只要它认为文件没变,就不会重新编译。

OPcache 是 PHP 5.5 之后内置的组件,在绝大多数发行版与面板的一键环境里默认开启。它的收益很高(通常能把脚本执行耗时压掉一半以上,还能显著降低磁盘 IO),但默认配置对「经常改代码」的站点并不友好。这篇文章按「确认它到底有没有生效、缓存什么时候失效、参数怎么配、上线后怎么观察」四层来讲。

一、先确认它到底有没有在跑

不要凭 phpinfo 里出现 OPcache 的段落就断定它在工作——那段信息在模块被编译进来时就会显示,哪怕运行时被关闭了。真正的判据是运行状态里缓存是否在用。

php -i | grep -E 'opcache.enable|opcache.enable_cli|opcache.memory_consumption'
php -r 'print_r(opcache_get_status(false)["opcache_enabled"] ?? "no-status");'

需要注意 PHP-FPM 和命令行是两个独立的进程空间,opcache.enable_cli 默认关闭,所以命令行的检查结果不代表 FPM 的状态。要确认 FPM 侧的情况,最稳的办法是临时放一个只读的诊断页面(访问后立刻删除),或者在站点里挂一个受 IP 限制的脚本,输出 opcache_get_status() 的关键字段。

更省事的方法是装一个可视化面板,把状态页挂在一个只有自己能访问的路径上。要看四个数:opcache_hit_rate(命中率)、used_memory 与 free_memory(内存是否吃紧)、cached_scripts 数量、以及 oom_restarts 与 hash_restarts 两个重启计数。命中率长期低于 95%、或者重启计数一直在涨,都说明配置有问题,而不是「OPcache 没用」。

二、缓存什么时候失效:三条判定路径

OPcache 判断一个文件要不要重新编译,看的是文件的时间戳与大小,而不是内容。默认配置下,它每次请求都会去 stat 一下文件(opcache.validate_timestamps=1,检查间隔由 opcache.revalidate_freq 控制,单位秒)。这解释了三种最常见的现象。

第一,改了文件但 stat 都没变。用某些编辑器或部署工具覆盖文件时,如果保留了原文件的 mtime 和大小,OPcache 会认为文件没变。修改一个字符长度不变的内容(比如把 A 改成 B)恰好会命中这种情况。这就是「明明改了却没生效」最典型的成因。

第二,revalidate_freq 设得太长。生产环境常见的推荐值是 60 甚至 300,意思是最多等五分钟才检查一次文件。对静态发布的站点完全合理,对还在调试的站点就很折磨。开发与预发环境应该设成 0(每次请求都检查),生产环境可以设成 2 到 60 之间。

第三,validate_timestamps 直接被关掉了。追求极致性能的配置会把 opcache.validate_timestamps=0,此时 OPcache 只在进程启动时读一次文件,之后再也不检查——任何代码改动都必须重启 PHP-FPM 或显式调用缓存清理才生效。这是性能与运维敏捷性之间的取舍,不是「更专业」的做法,上线流程里必须配套「发布后 reload」这一步,否则迟早出事故。

还有一个容易被忽略的机制:opcache.revalidate_path。当站点用 include_path 或者大量依赖自动加载时,同一个相对路径可能在不同目录下解析出不同文件,关掉这个选项会让路径解析结果被缓存住,出现「新加的文件找不到」的怪现象。老项目里这个值曾经默认是 0,新版本已改成 1,迁移环境时值得确认一下。

三、内存参数:oom_restarts 与 hash_restarts

OPcache 的内存是按「脚本数量 × 单个脚本字节码大小」占用的。默认 opcache.memory_consumption=128(MB)对小型站点够用,但一旦装了插件多的 CMS,或者用了一个依赖上千个文件的框架,128MB 很快见底。

内存吃满的后果不是性能抖动,而是整个缓存被清空重建。这就是 oom_restarts 的含义:一旦新脚本放不进去,OPcache 会把共享内存整体重置。表现出来就是「站点每隔一段时间突然变慢一阵,然后恢复正常」——因为那段时间所有请求都在重新编译,命中率归零。如果 oom_restarts 在持续增长,唯一的解法是加大 memory_consumption,常规建议是在实际 used_memory 的基础上留 50% 余量。

hash_restarts 是另一回事:它统计的是「哈希表容量不足」导致的重建。相关参数是 opcache.max_accelerated_files,默认 10000。这个数字必须大于站点的 PHP 文件总数,实际取值会自动向上取整到质数集合里的某个值(比如 16229、32531)。判断方法很简单:看 cached_scripts 是否已经接近 max_accelerated_files,如果接近,直接调到 32531 甚至 65536。

opcache.interned_strings_buffer 是第三个常被忽略的参数,默认 8(MB)。它存放的是编译期字符串的合并结果,框架类项目很容易吃满。吃满的代价是「字符串不能被合并」,内存占用上升但不会明显报错,属于隐性成本。中型站点给 16 到 32 更稳妥。

; /etc/php/8.2/fpm/conf.d/10-opcache.ini  (按实际版本改路径)
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=32531
opcache.validate_timestamps=1
opcache.revalidate_freq=2
opcache.save_comments=1
opcache.fast_shutdown=0
opcache.file_cache=/var/cache/php-opcache
opcache.file_cache_only=0

其中 save_comments=1 建议保持开启:很多框架和 CMS 依赖注解(docblock)做路由与 ORM 映射,关掉它能省一点内存,但会让部分功能直接失效。这是「优化到坏掉」的经典案例。

四、FPM 与 CLI 的边界,以及队列进程的坑

PHP-FPM 的工作进程会共享同一块 OPcache 内存,重启 FPM(systemctl reload php8.2-fpm)会让所有 worker 平滑退出并重建缓存,这是一次性成本,通常几秒内完成。

但队列消费者、常驻脚本、定时任务这些从命令行启动的进程不共享 FPM 的缓存,而且不会随 FPM 重启而重启。这是「后台任务用的还是旧代码」的根源:页面上的改动已经生效,队列里处理的却还是重启前加载的旧类。命令行进程的解法只有两个:让这些进程在发布后自己退出重启(supervisor 的 autorestart 策略),或者在任务开始前主动检测代码版本并自杀。

另一个常见误区是「重启 FPM 就能清缓存」。如果站点使用了 opcache.file_cache(把字节码落到磁盘,用于进程重启后的快速预热),那么重启后 OPcache 会优先从磁盘加载,理论上仍可能拿到旧内容。真正彻底的清理要用 opcache_reset(),或者在停服务时把 file_cache 目录一并清掉。做发布脚本时把这一步写进去,比事后到处找「为什么重启还没生效」省事得多。

顺带提醒:opcache_reset() 只能清掉当前这个进程能看到的那块共享内存。如果你有多个 PHP 版本、多个 FPM 池各用独立的 OPcache 实例,必须每一个都清。多站点服务器上,清了一个池子而另一个池子还挂着旧代码,是很常见的诡异现象。

五、上线之后怎么观察,以及一次真实排查

OPcache 的问题有两类:一类是「没有生效」,一类是「生效得太彻底」。前者看命中率与 cached_scripts 是否为 0,后者看改动是否立刻可见,两者的排查方向完全不同,不要混在一起查。

判断有没有生效,最快的办法是记录一次状态快照,对比 cached_scripts 与站点实际 PHP 文件数的量级。如果站点有 2000 个 PHP 文件而 cached_scripts 只有几十,说明大量脚本根本没被缓存——可能被 opcache.blacklist_filename 排除了,也可能用了 opcache.max_file_size 之外的超大文件。

判断失效是否过慢,则是观察 opcache_get_status() 里 scripts 数组中某个具体文件的 timestamp 与磁盘上的 mtime 是否一致。这一条能把「缓存没刷新」和「文件没传上去」彻底分开——前者磁盘 mtime 是新的,后者磁盘 mtime 就是旧的。我在一次排查里花了两个小时怀疑部署脚本,最后就是靠这个对比发现文件根本没上传成功。

真实案例:某站点每次发布后都有约 15 分钟「一半页面新一半页面旧」。原因是负载均衡后面挂了三台机器,发布脚本只执行了 reload 两次,其中一台因为超时被跳过。修法不是加长等待,而是把「reload 后校验」写进发布流程:reload 之后请求一个带版本号的健康检查接口,返回的版本号与本次发布一致才算这台机器完成。三台机器全部返回一致,才允许流量切入。OPcache 本身的配置没有错,错的是发布流程假设了「reload 一定成功」。

最后给一组可以直接抄走的判据:命中率低于 95% 查内存与黑名单;oom_restarts 非 0 且持续增长就加内存;hash_restarts 非 0 就加 max_accelerated_files;改了代码不生效先看磁盘 mtime 再看 revalidate_freq;队列进程行为异常先确认它有没有跟着重启。这五条能覆盖 OPcache 相关的绝大多数现场。

Last modification:November 11th, 2026 at 10:24 pm

Leave a Comment