PHP OPcache 把编译后的脚本字节码缓存起来,避免了重复编译,性能提升非常明显。但这也带来了一个让人头疼的问题:线上代码更新后,旧的缓存还在,导致新代码不生效。很多人习惯重启 PHP-FPM 或者等缓存过期,这在生产环境里要么代价太大,要么时间不可控。要让更新后的脚本立即生效,核心思路不是关闭 OPcache,而是用一套精准的缓存失效机制,做到只刷新变动的文件,对用户请求零影响。

利用 opcache_invalidate 精准清除单个文件缓存

最直接、最精细的方案就是调用 OPcache 内置函数 opcache_invalidate()。这个函数接收脚本路径作为参数,强制让该文件的缓存失效。下次请求这个文件时,PHP 会重新编译它并缓存新的字节码。你可以在代码发布系统的最后一步执行一个 PHP 脚本,遍历所有变更过的文件列表,逐个调用 opcache_invalidate()。

具体做法是,先在发布流程里生成一个变更文件列表,比如通过 Git diff 拿到两次发布之间修改过的 PHP 文件路径。然后写一个刷新脚本,内容大致如下:



这里第二个参数设为 true 表示强制刷新,即使文件正在被其他进程使用也会立即失效。如果不设成 true,OPcache 可能会延迟处理。要注意,这个脚本必须在目标服务器上通过 CLI 或者 HTTP 请求来执行,因为 OPcache 的缓存是进程间共享的,但不同 SAPI 之间的缓存空间可能隔离。如果你的 PHP-FPM 和 CLI 共享同一块 OPcache 内存区域,那 CLI 下执行就能影响 FPM 的缓存;如果不共享,就需要通过 FPM 处理一个内部请求来触发刷新。

通过内部 HTTP 请求触发缓存刷新

很多生产环境里,CLI 和 PHP-FPM 的 OPcache 配置是分开的,或者出于安全考虑禁用了 CLI 下的 OPcache。这种情况下,更可靠的做法是让 PHP-FPM 自己来执行刷新。你可以在项目里放置一个受保护的刷新接口,由发布系统在部署完成后调用它。

这个接口可以接收一个文件路径或者一批文件路径,内部调用 opcache_invalidate()。为了安全,可以限制只能从本地或者内网调用,并加上一个简单的密钥验证。示例代码如下:



发布脚本在完成代码同步后,用 curl 从服务器本地请求这个地址,把变更文件列表传过去。这样就能保证缓存刷新在正确的 SAPI 环境下执行,而且对线上正在处理的请求几乎无感知。

利用 opcache_reset 做全量刷新并规避其副作用

opcache_reset() 可以一次性清空整个 OPcache 缓存,简单粗暴。但它的副作用很明显:清空瞬间,所有后续请求都会触发重新编译,短时间内 CPU 负载会飙升,响应时间也会增加。如果网站请求量大,这会造成明显的性能抖动。所以,全量刷新只适合在低流量时段操作,或者配合一些技巧来减轻影响。

一个折中办法是,在多台服务器组成的集群里,逐台执行 opcache_reset()。从负载均衡器里先摘掉一台服务器,等它完成缓存重置并预热后,再重新加入集群,然后依次处理下一台。这样对用户来说服务始终可用,只是部分请求会稍微变慢。如果只有单台服务器,可以考虑在凌晨低峰期执行,并在重置后立即用脚本预热关键页面,把常用文件的缓存重新建立起来。

基于文件时间戳的自动验证机制

OPcache 提供了几个配置指令来控制缓存验证行为,合理配置这些参数可以在不手动干预的情况下,让更新后的脚本自动生效,而且性能损耗很小。

opcache.validate_timestamps 默认是开启的,这意味着 PHP 会检查脚本文件的修改时间,如果文件变了就重新编译。检查频率由 opcache.revalidate_freq 控制,默认是 2 秒。也就是说,文件更新后最多 2 秒,缓存就会自动失效。对于绝大多数业务场景,2 秒的延迟完全可以接受。如果你把这个值设成 0,PHP 会在每次请求时都检查文件时间戳,性能会下降,不推荐。

有人担心开启时间戳验证会拖慢性能,实际上这个检查只是做一次 stat 系统调用,开销极小。除非你的应用对毫秒级延迟极度敏感,否则保持默认开启是性价比最高的选择。把 opcache.validate_timestamps 设为 0 虽然能榨取最后一点性能,但代价是代码更新后必须手动重置缓存或者重启服务,反而增加了运维复杂度。

利用 opcache.file_cache 实现跨进程平滑更新

PHP 7 以后引入了 opcache.file_cache 功能,可以把字节码缓存到文件系统里。这个特性原本是为了在多个 PHP 进程之间共享缓存,或者让缓存能跨越重启而设计的,但也可以用来辅助实现平滑更新。

开启 file_cache 后,即使你执行了 opcache_reset() 或者重启了 PHP-FPM,字节码文件还留在磁盘上,重新加载时速度会快很多。你可以把 file_cache 目录放在内存文件系统上,比如 tmpfs,这样读取速度接近内存。更新代码时,先删除 file_cache 里对应的缓存文件,再调用 opcache_invalidate(),这样新编译的字节码会重新写入文件缓存,后续其他进程也能直接使用新缓存。

代码发布流程中嵌入缓存刷新步骤

不管用哪种技术手段,最终都要把它固化到发布流程里,否则每次上线都靠人工操作,既容易出错也不可持续。一个典型的自动化发布脚本在完成代码拉取后,可以这样做:

#!/bin/bash
# 发布脚本片段
cd /var/www/project
git pull origin main

# 获取变更的 PHP 文件列表
changed_files=$(git diff --name-only HEAD@{1} HEAD -- '*.php')

# 将文件列表转成 JSON 数组
json_files=$(echo "$changed_files" | jq -R -s -c 'split("\n") | map(select(length > 0))')

# 调用本地刷新接口
curl -s -X POST http://127.0.0.1/secure-opcache-refresh.php \
  -H "Content-Type: application/json" \
  -d "{\"token\":\"your-secret-token\",\"files\":$json_files}"

这样每次发布,只有真正修改过的文件才会被刷新,其他文件的缓存继续保留,既保证了新代码立即生效,又不会引发全局的性能抖动。

处理符号链接和绝对路径问题

在实际项目中,很多人用符号链接来实现发布目录的切换,比如 Capistrano 或 Laravel Envoy 的部署方式。每次发布,document root 会指向一个新的 releases 目录。这种情况下,OPcache 缓存里的文件路径是真实路径,而不是符号链接路径。如果你直接用 opcache_invalidate() 并传入符号链接路径,可能会因为路径不匹配而无法命中缓存。

解决办法是,在调用 opcache_invalidate() 之前,用 realpath() 函数把文件路径解析成真实的绝对路径。同时,OPcache 配置里 opcache.revalidate_path 最好设成 1,这样即使通过符号链接访问,PHP 也能正确识别文件是否已被缓存。示例:



这个细节很容易被忽略,导致明明执行了刷新操作,线上代码却还是旧的。排查这类问题时,可以先用 opcache_get_status() 查看当前缓存里的文件路径格式,确保刷新时传入的路径完全一致。

监控和验证缓存刷新结果

刷新操作执行完之后,不能靠猜来判断是否生效。你可以用 opcache_get_status() 来验证某个文件是否还在缓存里,或者查看它的缓存时间戳。写一个简单的检查脚本,在刷新前后分别调用,对比文件状态。



把这个检查集成到发布流程的最后一步,如果发现预期应该刷新的文件还在缓存里,就触发告警或者回滚发布。这种自检机制能避免带着错误的缓存版本上线,减少线上故障。

不同场景下的策略选择

如果你的项目发布频率不高,比如一天几次,直接用 opcache_invalidate() 按文件刷新是最佳选择,精准且高效。如果发布非常频繁,或者文件之间依赖关系复杂,每次都要计算变更列表可能比较麻烦,那保持 opcache.validate_timestamps 开启并设置合理的 revalidate_freq 就足够了,2 秒的延迟对大多数业务无感。对于追求极致性能、关闭了时间戳验证的场景,那就必须依赖发布系统的自动化刷新,或者结合 opcache_reset() 和预热脚本来处理。

还有一种情况是多台服务器共享同一个文件存储,比如 NFS。这时候要特别注意,OPcache 的 stat 检查是基于本地文件系统的,NFS 上的文件修改时间同步可能存在延迟。建议在这种架构下,要么把 OPcache 的 revalidate_freq 设得稍长一点以减少 NFS 压力,要么完全依赖显式的缓存刷新,而不是靠时间戳自动检测。

总之,PHP OPcache 开启后脚本更新不生效的问题,本质上是一个缓存失效策略的问题。理解了 OPcache 的工作机制,配合自动化发布流程,就能做到既享受字节码缓存的性能红利,又让代码更新像没有缓存一样即时生效。