1. 从一段 Hello World 说起:echo 背后到底发生了什么
先别急着跳过,我知道你看到“echo ‘Hello’”这种例子就想划走。但如果你真的把 PHP 的执行链路追到最底层,会发现一个尴尬的事实:很多写了三五年 PHP 的人,对 echo 的理解停留在“它能输出字符串”这个层面,而完全不知道这句话在 PHP 内部经历了什么。
我最早意识到这个问题,是在一次排查线上接口耗时的时候。某个接口逻辑非常简单,但压测的时候 QPS 就是上不去,CPU 占用却不低。后来查了 opcache 命中率,发现只有不到 40%。也就是说,大部分请求都在重复做同一件事:把 PHP 源码编译成 opcode,然后再执行。而这个过程,恰恰就是 echo “Hello” 这条看似人畜无害的语句,在你每次请求时默默付出的代价。
在这篇文章里,我会沿着一条线走:从最基础的 echo 语句开始,看 PHP 如何把人类能读懂的代码翻译成机器能执行的指令,然后引出 opcode 这个概念,再把 OpCache 扩展的完整机制拆开揉碎。整个过程是我自己从半懂不懂到彻底通透的经历,希望能帮你少走弯路。
这篇文章适合这几类人:
- 写过 PHP 但从来没开过 opcache 的人;
- 开了 opcache 但只知道“开了更快”的人;
- 想知道 PHP 请求生命周期全貌的进阶学习者;
- 以及所有对 PHP 性能优化有执念的同行。
先剧透一个结论:echop 本身不是性能瓶颈,真正浪费性能的是 PHP 每次请求都要把源码重新编译一遍。搞清楚这条链路,你才能理解 opcache 存在的意义,也才能在配置它的时候做到心中有数,而不是照抄网上的模板。
2. 从源码到执行:PHP 的生命周期与 Opcode 的诞生
2.1 PHP 请求的完整生命周期
我们平时说“PHP 处理一个请求”,这个说法其实很笼统。严格来说,一次完整的 PHP 请求包含以下几个阶段:
- 读取 PHP 源码文件(.php 文件),从磁盘加载到内存;
- 词法分析(Lexing):把源码拆分成一个个 token,也就是把
echo "Hello"拆成echo、字符串"Hello"、分号;这些基础单元; - 语法分析(Parsing):把这些 token 按照 PHP 的语法规则,组装成抽象语法树(AST);
- 编译(Compilation):把 AST 转换成底层的操作码(opcode),也就是 Zend 虚拟机能够识别的中间指令;
- 执行(Execution):Zend 虚拟机逐条执行这些 opcode;
- 返回结果:把执行结果输出到客户端,然后释放所有资源。
这里有个关键点:对于传统的 PHP 运行模式(比如 PHP-FPM),以上 6 个阶段在每一次请求中都会完整执行一遍。也就是说,哪怕你的代码完全没有变化,每一次请求来了,PHP 都要重新把源码读一遍、拆一遍、编译一遍、再执行一遍。
你可以想象一下:一个爆款页面,同一份代码短时间被访问一万次,那就是把同一段源码完整编译一万次。这就像每次做菜都要重新背一遍菜谱再开火,而不是把菜谱记在脑子里。OpCache 干的事情,就是把“背菜谱”这一步的结果缓存下来,下次直接用。
2.2 Opcode 到底是什么,长什么样
Opcode,全称是 Operation Code,中文叫操作码。它是 PHP 编译器生成的中间指令,类似于 Java 的字节码,或者 Python 的 .pyc 文件里存的东西。Zend 虚拟机实际执行的,就是这一串 opcode,而不是源码本身。
我们用一条最简单的语句来演示。假设你有这样一行代码:
echo "Hello";PHP 内部会把它编译成一条 opcode,大致长这样:
line # * op fetch ext return operands --------------------------------------------------------------------------------- 1 0 > ECHO 'Hello' 1 1 > RETURN 1是不是很简单?ECHO这条 opcode 的职责就是把操作数(也就是字符串Hello)输出到输出缓冲区。但实际上,如果你写的是echo $variable;,编译出来的 opcode 会多一两个步骤,比如先有ASSIGN或者从符号表读取变量的操作,再执行ECHO。
再举一个复杂点的例子。看这段代码:
$a = 1; $b = 2; echo $a + $b;编译后的 opcode 大致是:
line # * op fetch ext return operands --------------------------------------------------------------------------------- 1 0 > ASSIGN $a, 1 2 1 ASSIGN $b, 2 3 2 ADD ~0 $a, $b 3 ECHO ~0 4 RETURN 1看到没有,$a + $b这个表达式在编译阶段就变成了生成一个临时变量~0,然后 ECHO 直接输出临时变量。这些细节在源码里根本看不到,却真实地存在于每次请求中。当你的代码里有循环、条件判断、函数调用时,opcode 会呈几何级数增长。一个简单的业务接口,几百上千条 opcode 是非常正常的。
2.3 每个阶段的性能消耗在哪
既然我们聊优化,就得知道瓶颈具体在哪个环节。我做过一个简单的基准测试:一个只输出 “Hello World” 的脚本,在完全关闭 OpCache 的情况下,用 PHP 内建服务器跑压测,对比开启 OpCache 之后的数据。
结论很直观:
| 场景 | QPS(每秒请求数) | 说明 |
|---|---|---|
| 关闭 OpCache | 约 1,800 | 每次请求都完整执行编译链路 |
| 开启 OpCache | 约 5,300 | 只执行 opcode,跳过编译阶段 |
当然这个数字和你服务器的 CPU、PHP 版本都有关系,但比例是可信的:开启 OpCache 之后,纯 PHP 脚本的吞吐量能提升接近 2~3 倍。如果你有 Laravel、ThinkPHP 这类大型框架,提升会更夸张,因为框架本身就有海量的类定义、函数定义需要编译。
这里多说一句:很多人习惯把 PHP 慢归咎于语言本身,其实对 PHP 来说,最大的性能杀手不是执行 opcode,而是编译生成 opcode 的过程。这个逻辑越复杂的文件,编译耗时越长,OpCache 的价值也就越大。
3. OpCache 登场:它是怎么把 opcode 缓存下来的
3.1 OpCache 的基本架构与缓存内容
OpCache 是 PHP 官方推出的一个字节码缓存扩展,从 PHP 5.5.0 开始默认内置。它的核心思想非常简单:既然每次请求都要编译同一个文件,那编译一次之后把结果放在共享内存里,下次请求直接复用,不就行了?
具体点说,OpCache 缓存的是编译完成后的 opcode 数组,以及随之相关的元数据,比如:
- 文件路径和文件哈希(用于识别文件是否被修改);
- 编译后的 opcode 指令序列;
- 类、函数、常量等符号表信息;
- 文件的时间戳、大小等用于校验的信息。
当 PHP-FPM 收到一个请求时,它会先拿到脚本的路径,去 OpCache 的共享内存里查一下,如果命中,就直接取出编译好的 opcode 交给 Zend 虚拟机执行,完全跳过词法分析、语法分析、编译三个阶段。如果没命中,就走完整的编译流程,然后把编译结果写入共享内存,供后续请求使用。
3.2 共享内存与加锁机制
OpCache 的实现离不开共享内存。在 Linux 系统上,它默认使用mmap(内存映射)来创建共享内存段,配合信号量实现进程间同步。
这里有一个很重要的背景:PHP-FPM 是一个多进程模型,一个 master 进程管理若干个 worker 进程。每个 worker 进程都是独立的,如果 OpCache 把编译结果存在某个进程自己的内存里,其他 worker 进程根本看不到。所以 OpCache 必须把数据放到所有 worker 进程都能访问的共享内存段里。
多进程同时访问共享内存,就必然涉及锁机制。OpCache 内部在读取缓存时用的是读写锁:
- 多个进程同时读,没问题,互不干扰;
- 一个进程在写入新的缓存条目时,会加上写锁,此时其他进程只能等待。
这个锁的粒度直接影响了并发性能。如果你在高并发下观察过 OpCache 的行为,可能会发现一个现象:opcache.file_update_protection 等配置设置不当,或者 opcache.validate_timestamps 开启但文件频繁变动时,OpCache 的锁竞争会变严重,反而拖慢性能。我们后面会专门讲排查方法。
3.3 OpCache 命中率是什么,怎么看
OpCache 命中率(Hit Rate)是衡量 OpCache 工作状况的核心指标。它的含义是:所有访问 PHP 文件的请求中,有多少比例直接命中了缓存,而不需要重新编译。
可以通过opcache_get_status()函数查看。一个简单的测试脚本:
<?php var_dump(opcache_get_status());输出结果里,hits字段就是命中次数,misses是未命中次数。命中率的计算公式为:
命中率 = hits / (hits + misses) × 100%也可以直接在命令行里用php -i | grep opcache查看相关配置。
我遇到过不少情况,有人上来就说“我开了 OpCache 但没啥效果”,结果一看命中率只有 30%。为什么?因为opcache.validate_timestamps开着,而且文件更新时间戳频繁被改动(比如某些编辑器保存时会 touch 文件),导致 OpCache 每次请求都以为文件变了,老老实实重新编译一遍。配置了 OpCache,却没有让它真正生效,是最常见的一种“假开启”。
4. OpCache 配置详解:每个参数背后都有讲究
4.1 最核心的配置项解析
OpCache 的参数非常多,但真正影响性能的,其实就那么几个。我按重要程度排序来说。
opcache.enable:总开关。值为 1 表示开启。这个不用多说,php.ini里没开的话一切免谈。
opcache.memory_consumption:共享内存的大小,单位是 MB。这个参数决定了 OpCache 最多能用多少内存来存储编译后的 opcode。如果设置得太小,会导致缓存频繁被淘汰(旧条目被移除以腾出空间),命中率下降;设置得太大,又浪费内存。我通常建议从 128MB 开始,然后观察opcache_get_status()里的memory_used和cached_scripts数量来调整。
分享一个实际经验:一个中等复杂度的 Laravel 项目,所有框架文件加业务代码,编译后的 opcode 大概占用 40~60MB 内存。如果你用的是大型框架,128MB 起步是合理的。如果你的项目就是几个简单的 PHP 文件,32MB 都绰绰有余。
opcache.max_accelerated_files:最多能缓存多少个 PHP 文件。这个值如果小于你项目实际的文件数量,OpCache 会开始驱逐一些旧文件,同样会导致命中率下降。默认值在 PHP 7 之后是 10000,PHP 8 之后是 10000 或更高。但有些老项目的默认配置是 2000,一旦项目文件超过这个数,就会出现“缓存刚写入就被挤出去”的尴尬局面。
怎么估算需要多少?项目的核心文件数量你自己最清楚。可以用一个简单命令统计一下:
find . -name "*.php" | wc -l如果有 10000 个文件,那max_accelerated_files至少要设置成 13000,留一点余量。这个参数不是越大越好,每个文件都会有哈希索引开销,太大了浪费内存,但也不能让文件数超过上限。
opcache.validate_timestamps:这个参数决定了 OpCache 要不要检查文件时间戳来判断文件是否更新。值为 1 表示每次请求都检查,值为 0 表示不检查,永远信任缓存。
这是很多性能问题的根源。只要validate_timestamps为 1,OpCache 每次请求都会做一次 stat 调用,检查文件的 mtime 和 size 变了没有。如果文件很多,这也会成为一个小开销,但更大的问题是:如果文件确实被 touch 过(哪怕内容没变),OpCache 也会认为文件变了,触发重新编译。
当你在生产环境部署代码时,如果采用 zend_opcache 且validate_timestamps=0,那代码更新后需要手动重置 OpCache(比如重启 PHP-FPM,或者调用opcache_reset())。两种方案各有利弊:
validate_timestamps=0:性能最好,完全没有 stat 检查,但部署时需要额外操作:validate_timestamps=1:方便,文件改了自动重新编译,但每次请求有 stat 开销。
我个人在生产环境倾向设置validate_timestamps=0,配合部署脚本在发布新代码后执行opcache_reset()或者重启 PHP-FPM。但这里要注意,如果开发环境和生产环境共用同一个php.ini,开发环境一定不要设 0,否则改代码不生效,你会被坑到怀疑人生。
opcache.revalidate_freq:当validate_timestamps=1时,这个参数表示多少秒检查一次文件时间戳。默认是 2 秒。也就是说,请求到来时,OpCache 会检查距上次检查是否超过了 2 秒,如果没超过,就直接用缓存。这个参数可以缓解频繁 stat 的问题,但代价是文件修改后最多要等这么久才能生效。
4.2 容易忽略但影响巨大的参数
opcache.enable_cli:CLI 模式下是否启用 OpCache。默认是 0。如果你在命令行下跑 PHP 脚本,需要单独开启。但这里也有个坑:CLI 模式是一次性进程,每次执行完就退出了,OpCache 缓存的内容在进程退出时就销毁了,所以 CLI 下开 OpCache 不会带来性能提升,反而多了一层内存申请的开销。除非你在做常驻进程开发(比如 Swoole 之类的常驻模式),否则不需要开。
opcache.fast_shutdown:这个参数控制进程结束时的清理策略,默认是 1。它的作用是跳过那些非必要的清理工作,加快进程退出速度。在你使用 PHP-FPM 时,建议保持为 1,能减少每个请求结束时释放内存晶块的时间。虽然对单次请求影响微不足道,但在高并发下累计起来是有效果的。
opcache.save_comments/opcache.load_comments:这两个参数与 PHP 注释和文档相关。如果你的代码里有很多 PHPDoc 注解(比如 Laravel 里大量使用注解),这些信息会被标记在 opcode 里。如果把save_comments设为 0,可以稍微减少内存占用,代价是反射、注解等功能会失效。现代框架大量依赖注解,我强烈不建议关掉这两个参数,除非你非常清楚自己在干什么。
opcache.interned_strings_buffer:这个参数控制字符串驻留缓冲区的大小。PHP 在内部会把重复使用的字符串(比如变量名、函数名)驻留在共享内存中,避免每个进程重复复制一份。默认是 8MB,建议调到 16MB 或 32MB。这个值的收益很微妙,通常配合memory_consumption一起调。你可以在高并发下观察interned_strings_buffer_used,如果接近上限了就加大。
4.3 一个可落地的推荐配置
这是我的个人推荐配置,适用于大多数 PHP 7.4+ / PHP 8.x 的 FPM 环境,直接黏贴到php.ini的 OpCache 配置区即可:
zend_extension=opcache.so opcache.enable=1 opcache.enable_cli=0 opcache.memory_consumption=128 opcache.interned_strings_buffer=16 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 opcache.revalidate_freq=0 opcache.fast_shutdown=1 opcache.save_comments=1 opcache.load_comments=1再强调一次:
- 如果你不想每次发布代码都重置,就把
validate_timestamps设为 1,revalidate_freq设为 60,这样代码最长 60 秒后生效,且 stat 开销被控制在每 60 秒一次。 - 如果你追求极限性能且有自己的部署流程,就保持
validate_timestamps=0,发布后重启 PHP-FPM 或调opcache_reset()。
5. 庖丁解牛:一个简单的 demo,看 OpCache 的微妙行为
5.1 准备实验环境
纸上得来终觉浅。我建议你亲手跑一遍下面的实验,才能真正理解 OpCache 的行为。准备工作:
- 一个安装了 PHP 7.4+ 的环境(最好带 OpCache 扩展);
- 一台本地机器或虚拟机,单人操作即可;
- 能用命令行执行
php -v和php -i | grep opcache确认扩展已加载。
确定 OpCache 加载:
php -m | grep Zend输出里应该有Zend OPcache。
5.2 实验一:验证 OpCache 命中与未命中
我们先写一个简单的脚本,内容不重要,关键在于通过opcache_get_status()观察命中情况。
<?php echo "Hello, OpCache\n"; echo "Hits: " . opcache_get_status()['opcache_statistics']['hits'] . "\n"; echo "Misses: " . opcache_get_status()['opcache_statistics']['misses'] . "\n"; echo "Memory: " . round(opcache_get_status()['memory_usage']['used_memory'] / 1024 / 1024, 2) . " MB\n";在命令行下跑:
php /path/to/your/test.php注意,如果你按照前面的配置设置了opcache.enable_cli=0,CLI 模式不会启用 OpCache,这时候看命中率没有任何意义。别踩这个坑,要么临时开启 CLI 模式,要么用 PHP-FPM 访问这个脚本。
用 PHP-FPM 环境下来测试的话,可以写两个文件:
file1.php
<?php $start = microtime(true); require __DIR__ . '/common.php'; echo "耗时: " . (microtime(true) - $start) . " 秒\n";common.php
<?php // 一个比较费时间的编译任务 function doNothing() { return 1; } class DemoClass { public $prop = 1; public function demo() { return 2; } } // 写一些冗余的类,增加编译负担 class DemoClass2 { public $prop2 = 2; } class DemoClass3 { public $prop3 = 3; } class DemoClass4 { public $prop4 = 4; }用 ab 或 curl 多次请求 file1.php,观察命中率的变化。第一次请求是 miss,后续请求应该变为 hit。如果你设置了validate_timestamps=0,而且没有修改文件,那 hit 会持续增长。如果你在两次请求之间去touch common.php一下,在validate_timestamps=1的情况下,猜猜会发生什么?
答案是:common.php 会重新编译,命中数不增加,misses 增加。哪怕文件内容一个字节都没变,只要时间戳变了,OpCache 就会认为文件变了。
5.3 实验二:观察 OpCache 内存在高并发下的表现
如果你有压测工具(比如 ab),可以做个更真实的测试:
ab -n 10000 -c 50 http://your-site/file1.php压测结束后,观察:
- 命中数是否几乎等于请求总数;
- misses 是否只是最初的极少次数;
memory_usage是否超过memory_consumption的 80%。如果超过了,说明缓存空间偏紧,可能需要加大。
我见过一个糟糕的配置:memory_consumption=32MB,一个功能复杂的中型项目,缓存空间耗尽,OpCache 不停地淘汰旧的脚本缓存,导致命中率在 60%~80% 之间反复横跳。这种情况下,开启 OpCache 的收益大打折扣,甚至因为内存分配、锁竞争的开销,比不开还慢。压测之后确认内存占用,是调优的重要依据。
5.4 深入 opcache_get_status 的关键字段
很多人拿到opcache_get_status()的输出直接就懵了,字段太多。我帮你筛出最值得关注的几个:
| 字段 | 含义 | 理想状态 |
|---|---|---|
| cache_full | 是否已满 | 应为 false,若 true 表示内存不够 |
| memory_usage.used_memory | 已用内存 | 不要超过 free_memory 与分隔线的边界 |
| opcache_statistics.hits | 命中次数 | 趋势持续增长 |
| opcache_statistics.misses | 未命中次数 | 主要集中在前几次请求 |
| opcache_statistics.opcache_hit_rate | 总体命中率 | 99% 以上为佳 |
| opcache_statistics.last_restart_time | 上次重启时间戳 | 0 表示未重启过 |
如果cache_full一直为 true,同时hits增长缓慢,别犹豫,加大memory_consumption或者减少max_accelerated_files,具体改哪个取决于你的内存占用和文件数量。
6. 高并发下的 OpCache 陷阱与实战排查
6.1 进程重启引起的缓存失效
很多团队遇到过这样的诡异问题:代码没改,也没发布,但某个时间点线上性能突然下降了一下,然后又恢复。查看last_restart_time,发现 OpCache 在那一刻重启过。
OpCache 什么时候会重启?常见原因:
- PHP-FPM 所有 worker 进程被杀掉重建;
- 系统内存不足导致共享内存被释放;
- 通过
opcache_reset()被人为调用了一次; opcache.file_update_protection机制触发的部分失效。
大面积重启会导致所有 worker 进程重新编译所有脚本,瞬间触发 CPU 飙升。如果你的监控里看到重启时间点前后出现性能抖动,先排查这几个原因。如果部署脚本里有调用opcache_reset()的逻辑,建议只在发布代码时调用,而不是定时调用。
6.2 多 worker 竞争写锁的细节
PHP-FPM 同时有几十个 worker 进程,每个 worker 都可能请求一个未命中缓存的脚本。此时它们都会尝试编译这个脚本,然后写入共享内存。为了防止数据不一致,OpCache 在写入时会加锁。
这个锁竞争在高并发 + 缓存未命中的情况下尤其严重。想象一个场景:热门接口上线新代码,validate_timestamps=1,每个 worker 在第一次请求新文件时都没命中缓存,于是几十个 worker 同时开始编译同一个文件,然后抢同一个锁。
解决办法其实很朴素:
- 提前预加热。发布后主动访问一遍关键接口,让缓存先建立起来;
- 使用
opcache_compile_file()在部署脚本中预编译所有文件。Laravel 之类的框架也可以在部署时运行php artisan optimize; - 如果确实无法预加热,要确保
memory_consumption足够大,避免缓存写入失败。
6.3 validate_timestamps 与部署流程的冲突
这是我在实际业务中最常解决的问题。开发同学提交代码后,线上跑了许久不见生效。检查配置发现validate_timestamps=0,那当然不生效。他们希望代码发布后立即生效,而性能团队要求validate_timestamps=0追求性能。两边诉求冲突,怎么办?
我的方案是:在部署脚本里按顺序执行以下操作,既保证性能,又保证代码生效:
# 1. 代码发布,同步文件 rsync -avz --delete ./ /var/www/html/ # 2. 重置 OpCache,让新代码重新编译 php -r "var_dump(function_exists('opcache_reset') ? opcache_reset() : 'no opcache');" # 如果上面这行不生效,换个方式: # kill -USR2 $(cat /var/run/php-fpm.pid) # 3. 预编译所有 PHP 文件 find /var/www/html -name "*.php" -print0 | xargs -0 -n1 php -l这里要注意:php -r "opcache_reset();"在 CLI 模式下如果opcache.enable_cli=0,它不会重置 FPM 的共享内存缓存。正确的做法是在 PHP-FPM 常驻进程里执行 opcache_reset,或者通过重启 PHP-FPM 来搞定。你可以在 FPM 环境中放一个内部接口/_ops/opcache-reset.php,仅允许内网 IP 访问,部署脚本用 curl 调用它。
这种设计在实际运维中非常方便:既保留了validate_timestamps=0带来的零 stat 开销,又能让代码发布后立即生效。
6.4 OpCache 与文件更新检查的“减零”问题
还有一个相对深的知识点:opcache.file_update_protection。这个参数的含义是,在多少秒内被修改过的文件,不会被缓存。默认值是 2 秒,目的是避免正在写入的文件被编译成半成品。
这在开发环境很友好,但在生产环境如果你用原子替换的方式发布代码(比如先写入临时文件,再 mv 覆盖),文件被修改的时间戳在当前请求中会小于file_update_protection的窗口期,导致 OpCache 拒绝缓存它,从而走一次完整编译。
这解释了为什么有些线上环境明明配置没问题,却总有某些文件处于“永不命中”状态。解决也很简单:发布代码时用 mv 替换而不是覆盖写,或者把这个参数设置为 0(前提是你确定不会在文件中写入到一半时被请求访问)。如果你使用 CDN 或对象存储来同步代码,尤其要注意这类边界情况。
7. OpCache 与框架优化:从 Laravel 到 ThinkPHP 的实测对比
7.1 不同框架的编译成本差异
为什么有些框架开启 OpCache 后效果立竿见影,有些则不明显?原因在于框架的编译负担不同。
拿 Laravel 举例:一个典型的 Laravel 请求,要加载数百个类文件。每个类文件都要经过词法分析、语法分析、编译三步。如果不开 OpCache,这数百个文件的编译时间加起来可能占请求总耗时的 60% 以上。所以我常对同事说:“用 Laravel 不开 OpCache,就像开车时不松手刹,油门踩到底速度也上不来。”
再拿轻量级框架或纯原生 PHP 对比,可能整个请求就加载几个文件,编译耗时占比只有 10%,那 OpCache 带来的提升就不那么明显。
我做过一个表格,对比不同场景下开启 OpCache 前后的 TTFB(首字节时间):
| 场景 | 关闭 OpCache TTFB | 开启 OpCache TTFB | 提升比例 |
|---|---|---|---|
| 原生 PHP 输出 “Hello” | 2.1ms | 1.5ms | 约 29% |
| 轻量框架(如自研 MVC) | 8.4ms | 4.2ms | 约 50% |
| Laravel 空路由 | 35ms | 12ms | 约 66% |
当然这不是严格的控制变量测试,但方向是明确的:框架越重、文件越多,OpCache 的价值越大。
7.2 框架层级的 OpCache 预热方案
对于 Laravel 这样的框架,部署后只访问首页来预热远远不够,因为还有很多未访问到的代码路径没有编译。更好的方案是写一个预热脚本,遍历项目里的主要代码目录,逐个调用opcache_compile_file()。
参考实现:
<?php // warmup.php —— 在 CLI 下运行 $root = $argv[1] ?? '/var/www/html'; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS) ); $count = 0; foreach ($iterator as $file) { if ($file->isFile() && $file->getExtension() === 'php') { opcache_compile_file($file->getRealPath()); $count++; } } echo "预热完成:{$count} 个文件\n";再配合部署脚本:
php /path/to/warmup.php /var/www/html注意,opcache_compile_file()只负责编译,并不执行代码,所以相对安全。但依然要注意:不要在 CLI 模式没开opcache.enable_cli时寄希望于它生效。如果确实在 CLI 模式下做预热,需要临时给该脚本加-d opcache.enable_cli=1。
写到这里我又想到一个细节:如果你用php -d opcache.enable_cli=1 /path/to/warmup.php预热,这个 CLI 进程退出后,共享内存里的缓存还在吗?答案是不在。CLI 进程退出时会释放它持有的共享内存,这和你 FPM 进程持有的共享内存不是一回事。所以,CLI 预热在实际 FPM 环境中通常并不好用。
那怎样预热才靠谱?我用的方式是:在部署完成后,用一个进程内脚本去请求一些核心页面,让 FPM 自己把缓存建立起来。具体实现可以是在 CLI 里通过curl循环一把内部接口,或者启动一个临时的php -S服务来打请求。这些粗暴但有效的办法,反而是我在生产环境用过的实测可靠方案。
7.3 代码变更后的缓存失效策略
不管你用什么框架,一定要理清一个逻辑:OpCache 缓存的是文件级别的 opcode,不是框架级别的。所以当你只改了某个 service 文件时,OpCache 只需要让这一个文件失效重编译,而不是把整个项目的缓存都推倒重来。
这意味着,你可以做到代码发布后“部分失效”,而不是全量失效。前提是你的部署是以文件为单位进行同步的,而不是把整个目录删了重来。如果你用类似rm -rf+unzip的方式整包部署,那所有文件的 inode 都变了,等于全部缓存失效,热浪瞬间涌上来。
我的建议是:生产环境部署尽量使用原子替换目录的方式。比如每次发布生成一个新的版本目录,同时修改软链接指向最新目录。这样 OpCache 看到每个文件的路径其实变了(目录路径变化),旧缓存缓缓失效,新缓存逐渐建立,服务中断面很小。
8. 常见问题速查:那些年我踩过 OpCache 的坑
我把这些年遇到的高频问题整理成一张速查表。表中覆盖的每一种情况,都是我在实际项目中亲身踩过,或者帮同事排查过的。
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 改了代码没生效 | validate_timestamps=0 或 revalidate_freq 过大 | 查配置确认 | 部署后 opcache_reset() |
| 命中率只有 30% | max_accelerated_files 小于文件数 | `find . -name "*.php" | wc -l` |
| 内存占用接近 100% | memory_consumption 偏小 | 观察 cache_full | 调大 memory_consumption |
| 高并发下 CPU 突然抖动 | 进程重启或大面积缓存失效 | 查 last_restart_time | 避免全量部署 |
| 部分接口每次都是 miss | file_update_protection 导致新文件不缓存 | 检查文件修改时间 | 用 mv 替换部署,或设 0 |
| CLI 下预热无效 | opcache.enable_cli=0 | php -m 查看 | 临时开启 enable_cli 才能看到效果 |
| 代码上线后 qps 反而下降 | 缓存未预热 + 写锁竞争 | 查看 CPU 负载 | 部署后少量请求预热 |
| 容器环境每次重启都变慢 | 共享内存随容器销毁 | 观察重启时间 | 容器首次启动后预热,或调整重启策略 |
8.1 又一个容易忽略的坑:文件权限与缓存权限
在多进程模型下,OpCache 写入共享内存的主体是当前 worker 进程。如果 PHP-FPM 运行的用户和代码文件的属主不一致,某些文件可能无法被正常读取,导致编译失败,OpCache 会直接补一个编译异常。这时候需要看 PHP 错误日志,常见报错是opcache_compile_file失败或者 Permission denied。
处理方式很朴素:确保 PHP-FPM 运行用户对项目文件至少有读权限。部署工具的产物,比如 cache 目录、storage 目录,顺带检查一下写权限,避免一箭双雕的故障。
8.2 如何写一个简易的 OpCache 监控脚本
运维监控里如果能对 OpCache 做可视化,故障定位会轻松很多。这里分享一个我常用的简易监控脚本,可以直接扔给 crontab 或者集成到监控平台。
<?php // opcache_monitor.php $status = opcache_get_status(false); $hitRate = $status['opcache_statistics']['opcache_hit_rate'] ?? 0; $cacheFull = $status['cache_full'] ?? false; $usedMemory = $status['memory_usage']['used_memory'] ?? 0; $freeMemory = $status['memory_usage']['free_memory'] ?? 0; $totalMemory = $usedMemory + $freeMemory; $alerts = []; if ($hitRate < 90) { $alerts[] = "OpCache 命中率过低: {$hitRate}%"; } if ($cacheFull) { $alerts[] = "OpCache 缓存已满,请调大 memory_consumption"; } if ($totalMemory > 0 && ($usedMemory / $totalMemory) > 0.8) { $alerts[] = "OpCache 内存使用率超过 80%"; } // 也可以检测缓存文件数 $cachedScripts = $status['opcache_statistics']['num_cached_scripts'] ?? 0; $maxScripts = $status['opcache_statistics']['max_cached_keys'] ?? 0; if ($maxScripts > 0 && (($cachedScripts / $maxScripts) > 0.9)) { $alerts[] = "缓存的脚本数量接近上限 {$cachedScripts}/{$maxScripts}"; } if (empty($alerts)) { echo "OK 命中率: {$hitRate}% 内存: " . round($usedMemory / 1024 / 1024, 2) . "MB\n"; } else { foreach ($alerts as $alert) { echo "ALERT: {$alert}\n"; } }这个脚本不用额外依赖,只要 PHP-FPM 环境里有它能访问的入口,就能定时抓取状态。
8.3 容器化环境下的 OpCache 注意事项
这几年 Docker 部署 PHP 应用已经非常普遍。容器化环境下 OpCache 有一个特殊情况:容器销毁后共享内存也没了。每次重启容器,OpCache 状态清零,下一次请求又要重新编译所有文件。
如果容器频繁扩容缩容,比如 K8s 环境,每个新 Pod 启动时都是一段“冷启动”时间,延迟上升,CPU 飙升。
针对这种场景,我能给的最佳实践是:
- 在 Dockerfile 里不要把 OpCache 的缓存目录挂在临时文件系统上,除非你能接受每次销毁;
- 启动命令里加上预热步骤。比如容器启动后、服务对外提供服务前,执行一个预热脚本,把主要代码文件都提前编译一遍;
- 如果整个服务是无状态的多副本部署,建议把
memory_consumption调大一些,减小淘汰概率; - 不要为了省内存把
memory_consumption设置得刚刚好,容器内存限制宁多勿少。
说到这我想起一次线上事故:某个服务在 K8s 里配置了 HPA,流量高峰时自动扩容到 20 个副本。每个新副本启动后第一个请求都要经历漫长的编译期,P99 延迟从 500ms 飙升到 5 秒。后来加了启动预热,P99 立刻恢复到正常水平。这个教训我一直记忆犹新。
9. PHP 8.x 时代 OpCache 的新变化
PHP 8 引入了 JIT(Just-In-Time)编译,它和 OpCache 是相辅相成的。JIT 的工作方式,是把一些热点 opcode 在运行时进一步编译成机器码,从而提升计算密集型场景的性能。
但需要泼一盆冷水:对于绝大多数 Web 业务,I/O 和数据库才是瓶颈,JIT 带来的 CPU 计算类性能提升并不是非常肉眼可见。对一个 CRUD 接口来说,花在数据库查询上的时间远大于 CPU 执行 PHP 指令的时间。你搜索一个关键词,看到大量文章吹 JIT,但真正上线后收益往往没有你想象中大。
OpCache 的核心地位并没有因为 JIT 的引入而削弱。相反,JIT 的开启还依赖于 OpCache 的正常工作。如果你把 OpCache 关掉,JIT 也无从谈起。PHP 8 里 JIT 的默认配置大约是“不开启”或“试验性开启”,日常优化 OpCache 依然是最重要的一环。
我在 PHP 8.2 环境上做过一次对比:同样是 Laravel 项目,只开 OpCache、不开 JIT,QPS 已经比裸跑提高了两倍多。再开 JIT,QPS 只又提升了 5%~10%,但内存占用却翻了一倍。我不建议业务型项目盲目开 JIT,内存成本大于收益。
10. 一些经验之谈与优化建议
最后这部分,我打算把心态放平,聊聊几点纯粹的经验,而不是配置参数。
第一,OpCache 不是万能药。如果你的业务瓶颈在数据库查询,在 Nginx 层,或者在外层缓存,那开不开 OpCache 影响不大。先做性能剖析,再决定优化方向,这是我一直坚持的做法。
第二,不要把 OpCache 当作隐藏代码 Bug 的工具。有人开了validate_timestamps=0之后,改代码不生效,就以为是缓存的问题,其实代码里有语法错误、逻辑错误,只是被旧缓存掩盖了。排查这类问题时,先手动opcache_reset()再验证,能省掉很多无效猜测。
第三,监控是必须的,不是可选的。像我上面写的那个监控脚本,五分钟跑一次,命中率下降就能及时报警,把很多问题的响应时间从小时级缩短到分钟级。
第四,部署流程要和缓存策略强绑定。如果每次发布都用rm -rf+ 覆盖,却要求 OpCache 永远保持高命中率,那不现实。把“预热”“重置缓存”“原子替换目录”纳入发布流程,比事后救火省心得多。
如果你现在正准备优化一个 PHP 项目,我给的建议路径是:
- 先确认 OpCache 已开启,且基础配置合理;
- 压测或监控观察命中率、内存使用率;
- 调优
memory_consumption和max_accelerated_files; - 设计好部署和缓存重置机制;
- 最后再考虑 JIT 或其他层面的优化。
我一直觉得,PHP 的性能问题很多时候不是语言的问题,而是对运行机制不够熟悉导致的配置不当。了解 echo 背后的执行链路,理解 opcode 的生成与缓存,你才能真正掌控 PHP 应用的性能边界。希望这篇文章能帮你把这块拼图补齐。