调优这件事最怕一上来就开药。一个业务逻辑算不上复杂的PHP接口线上平均耗时却摆在1200ms压测数据更难看——这种场景我遇到过好几次。先别急着怀疑SQL也别急着甩锅给第三方接口优先级最高的怀疑对象往往是PHP-FPM正在反复编译同一套代码。这次调优全程没有改一行业务逻辑只把Opcache从默认配置改成适配项目的参数接口平均耗时就从1200ms压到了180ms。这篇文章把整个排查和配置过程完整复盘一遍适合正准备给PHP接口做性能调优、又不太确定Opcache到底能帮上多大忙的后端同学参考。1. 调优不是从配置开始是证明1200ms烧在编译上1.1 先排除掉两个最容易甩锅的嫌疑网络和数据库任何性能问题都先做粗定位不然配置改了一堆也不知道改的是什么。我用curl先把耗时拆开看一遍curl -o /dev/null -s -w DNS解析: %{time_namelookup}s\nTCP连接: %{time_connect}s\nTTFB: %{time_starttransfer}s\n总耗时: %{time_total}s\n http://api.example.com/user/profile结果里TTFB和总耗时几乎相等说明时间基本都烧在服务端处理上不是网络传输的锅。这一步特别重要很多人看到接口慢就先怀疑带宽或者DNS其实90%的PHP接口慢问题都在进程内部。接下来是数据库。我打开了MySQL的慢查询日志把所有超过200ms的SQL捞出来逐条EXPLAIN。查下来发现一个新问题都没有该走的索引都走了单条SQL基本都在几毫秒到几十毫秒级别谈不上瓶颈。这里要提醒一下你确认数据库没慢查询的时候一定要看压测期间的实际慢查询日志而不是业务平峰期的。我用wrk同时塞了100个并发慢查询日志里依旧空空如也才放心把数据库排除掉。1.2 用top和strace把嫌疑锁定在PHP-FPM进程把网络和数据库排掉之后再回到服务端看资源。压测期间打开top按CPU排序一堆php-fpm进程的CPU使用率都顶在90%以上机器负载比平时高了一大截。Python日志、Nginx日志、PHP-FPM慢日志都会留下线索。PHP-FPM自带的慢日志功能值得设置一下; /etc/php/8.1/fpm/pool.d/www.conf slowlog /var/log/php-fpm-slow.log request_slowlog_timeout 2s这个配置能在请求超过2秒时打印当时的PHP调用栈能看到压测那一瞬间是卡在哪个函数上的。更重要的一步我用到了strace。对一个PHP-FPM worker进程做短时间采样strace -f -e tracefile -p 12345 -o /tmp/php-strace.log日志里非常壮观一个请求会反复打开几百个.php文件而且很多文件在同一个请求里被多次打开、反复解析。磁盘读写不一定是最大的开销但每次请求都要重新加载源码、重新编译这个事实是实打实的。到这里嫌疑基本锁定了瓶颈不在业务代码而在PHP进程每次请求都重复做的编译工作。1.3 一个更直观的复现开掉Opcache前后同一段代码耗完全不同为了把这个结论锤实我做了个最简单的验证。在一个标准的框架入口文件上用CLI模式执行两次记录耗时# 未开启Opcache php -f public/index.php # 第一次 0.9s php -f public/index.php # 第二次 0.9s # 临时打开 opcache.enable_cli1 后 php -f public/index.php # 第一次 1.0s包含编译 php -f public/index.php # 第二次 0.21s命中缓存CLI和FPM的缓存是独立的所以这个实验只是为了证明编译开销确实存在并且非常大不是为了直接代表线上情况。但同一个入口文件四次执行分别是两种模式差异一眼就看出来了第二次执行因为走了opcode缓存耗时只有第一次的四分之一不到。这种先证明问题再动手调的流程是这次调优没有白费功夫的关键。直接拍脑袋开Opcache开完发现没效果大概率是方法错了。2. Opcache的共享内存里到底缓存了什么2.1 PHP从源码到执行中间经历了完整的编译流水线很多人以为把PHP代码加载一下就能跑。实际上一次标准的PHP请求要经历四步源码读取与词法分析、语法解析、编译成opcode、zend引擎执行opcode。前两步把代码打散成token再组装成抽象语法树第三步把你写的PHP代码转成zend虚拟机认识的字节码第四步才是真正执行。没有Opcache的情况下这四个步骤每次请求都会完整做一遍。哪怕是一段从来没有变过的框架代码也会被反复读取、反复做词法分析、反复编译。这就像每次客人点单你都要重新买菜、洗菜、切菜、配菜哪怕客人点的永远都是同一道菜。Opcache做的就是一件事把第二步到第四步之间的产物——opcode——放到共享内存里。请求再来的时候如果文件没有变化直接从内存取出已经编译好的字节码执行。切配好的半成品放在冰箱里点单后直接下锅不用再从头备菜。2.2 它缓存的是字节码不是业务结果这是最容易被误解的地方。Opcache不缓存变量值不缓存数据库查询结果更不缓存HTML页面。它缓存的是可执行的opcode。一个循环很重的计算型代码开启Opcache之后性能提升依然有限因为不管有没有缓存循环本身都要执行。但如果项目有大量的PHP文件要加载、解析和编译Opcache的效果会立竿见影。再说直白一点Opcache解决的是重复编译同一套没变过的代码的开销。业务代码本身的执行时间、数据库时间、外部接口时间它一点都帮不上。2.3 为什么框架型项目收益最大一个典型的主流PHP框架项目少说也有几千个PHP文件。就算一次请求真正用到的类只有几百个每个请求也要走一遍自动加载、类定义、路由解析、容器初始化这一整套流程。这些步骤本质上是PHP代码执行前的大量准备工作准备工作的内容每次都一模一样。所以框架型接口优化前后差异极大。我做过一个对比同样的框架项目开启Opcache并调好参数后单个请求里文件openat系统调用数量明显下降CPU占用率直接掉了约一半。反过来一个只有十几个文件的手写裸PHP脚本开Opcache的收益就很小因为编译本来就不贵。2.4 缓存命中与失效机制决定配置怎么调Opcache的每个缓存条目并不是永远有效。当validate_timestamps开启时它会按照revalidate_freq设定的间隔去检查源文件的时间戳如果文件变了缓存条目会被作废并重新编译。另外共享内存满了之后也有淘汰策略老条目会被挤出去。这两个机制单独拎出来好像都挺好理解但它们组合在一起才是生产环境容易踩坑的地方。为什么很多团队开了Opcache之后依然感觉快了但没完全快多半是缓存文件数上限不够项目文件太多内存里放不下导致一部分文件反复被淘汰、反复重新编译命中率上不去。这个我在后面第三节详细说。3. 我用于生产环境的Opcache配置以及每个参数的依据3.1 环境说明与最终配置全文这次用的环境是Nginx 1.24 PHP-FPM 8.1.22项目是一个基于主流框架的JSON接口服务业务逻辑不重但框架文件非常多。先看出问题的最终配置[opcache] opcache.enable1 opcache.enable_cli0 opcache.memory_consumption256 opcache.interned_strings_buffer32 opcache.max_accelerated_files17000 opcache.max_wasted_percentage5 opcache.validate_timestamps0 opcache.revalidate_freq0 opcache.fast_shutdown1 opcache.consistency_checks0 opcache.jit_buffer_size128M opcache.jittracing这些参数不是拍脑袋出来的每一个都有对应的现场判断依据。下面逐个拆开讲。3.2 核心参数拆解文件数、内存、时间戳先说max_accelerated_files。这个参数决定共享内存里最多缓存多少个PHP文件的opcode。默认值在多数版本是10000左右对于大型框架项目根本不够。设置它之前先数一下项目里的所有.php文件find /data/www/ -name *.php | wc -l我们项目当时统计出来是12500个左右。按项目文件总数的1.3到1.5倍设置留出未来增量空间我给了17000。这里有个经验不要为了保险起见设成好几万甚至十万。每一个文件对应一个哈希槽位超过合理上限不仅浪费内存而且可能触发奇怪的开销。建议先按文件数乘以1.3跑一周观察命中率再调整。memory_consumption也是同样的思路。我先把值设成128M压测结束之后调出opcache状态页看内存使用量发现used_memory已经超过100M剩余空间太紧。随后加到256M内存占用率降到了50%左右处于一个很舒服的区间。intermented_strings_buffer很容易被忽略。PHP内部会缓存大量字符串字面量比如类名、方法名、key名这个值设成32M比较稳妥。如果项目里字符串做特别多可以试64M但一般32M就够了。validate_timestamps和revalidate_freq是两个必须捆绑理解的参数。默认情况下validate_timestamps1revalidate_freq60意思是每60秒检查一次源文件时间戳发现文件变了就自动重新编译。这样做的好处是代码更新后不需要手动清缓存坏处是每次请求都要做相关检查带来额外的开销。我最终在生产环境选择了validate_timestamps0也就意味着revalidate_freq完全无效Opcache不再检查源文件时间戳性能最干净。但这样做的前提是发布流程必须配套手动刷新Opcache。这个风险我在第五节会专门讲不是所有团队都适合这么干。如果你怕部署流程出问题先保持validate_timestamps1、revalidate_freq60也是可接受的两者性能差异在这个场景下通常只有几毫秒。fast_shutdown1让请求结束时不用做大量的引用计数清理对FPM这种短生命周期进程很友好。consistency_checks0关闭一致性检查这个检查在生产环境没必要开开了反而多花开销。3.3 JIT该不该开什么时候开PHP 8.1里Opcache还自带JIT这是另一个层面的优化。JIT会把热点代码在运行时编译成机器码让CPU密集的循环运算大幅提速。实话实说这个项目本身是接口服务大部分时间花在框架初始化、数据库调用和JSON输出上JIT带来的提升并没有传说中那么夸张。我开了opcache.jittracing、jit_buffer_size128M压测对比下来收益在百分之个位数但也没有负面影响所以就保留了。如果你做的是计算密集、大量循环的场景JIT值得认真测。如果像我一样是IO密集的Web接口可以不开省下那128M内存给别的用。3.4 配置改完不是万事大吉确认生效这一步不能省Debian系的PHPCLI和FPM的配置目录经常是分开的。改错地方CLI看着生效了FPM根本没加载之前说的那些就全白费了。改完之后务必检查一下FPM实际加载的Opcache配置php-fpm -t systemctl reload php-fpm php -i | grep opcache.enable这里有个细节php -i是CLI的配置要看FPM的最准确的办法是写一个临时phpinfo()页面通过Web访问看PHP-FPM SAPI下实际的加载结果。4. 压测前后对比从1200ms到180ms中间还发生了什么4.1 压测口径与测试条件这次对比压测用的wrk直接在服务器本机回环地址跑排除网络因素干扰wrk -t4 -c100 -d30s http://127.0.0.1/api/user/profile压测开始前先放了10秒预热请求让Opcache的冷启动miss先过去再统计稳定段数据。京东云上的实例如果磁盘性能波动大压测结果会上下跳所以我跑了三轮取了稳定值而不是单次结果。4.2 数据对比不止快了几倍CPU也掉下来了指标未开启Opcache开启并调优Opcache平均TTFB1180ms - 1250ms172ms - 195msPHP-FPM CPU占用率压测期间约95%约35% - 40%压测单请求P90耗时1320ms210ms参考QPS本机回环、10并发约68约180说明一下这个QPS提升幅度依赖本机的并发模型和FPM进程配置不同机器上数字会很不一样我更看重的是耗时和CPU的变化。原来CPU快被打满说明大量时间耗在编译这种纯CPU工作上现在CPU降下来了说明线程大量重复劳动被砍掉了。延迟的数字从平均1200ms到180ms符合标题说的那个结果。我不能保证任何项目开完Opcache都有这个幅度的提升但在这个业务简单、框架文件多的场景下这个幅度是真实的。4.3 验证命中率的正确方式别用CLI查FPM的状态调优后要确认缓存真的在工作最常看的指标是命中率。写一个临时PHP脚本?php $status opcache_get_status(false); if (!$status) { exit(Opcache不可用); } $stats $status[opcache_statistics]; $mem $status[memory_usage]; printf(命中率: %.2f%%\n, $stats[opcache_hit_rate]); printf(缓存脚本数: %d\n, $stats[num_cached_scripts]); printf(已用内存: %.2fMB\n, $mem[used_memory] / 1048576);通过Web方式访问这个脚本拿到的是PHP-FPM进程的缓存状态。注意用CLI直接执行php -r只能看到CLI进程的Opcache状态和FPM完全是两套很多人就是在这里被坑了。当时我这个页面显示命中率99.2%num_cached_scripts约14000内存在100MB左右比较健康。如果命中率低于90%基本说明配置有问题要么文件数上限太小要么内存不够导致频繁淘汰。除了临时脚本也可以直接下载cachetool通过FastCGI查询php cachetool.phar opcache:status --fcgi/run/php/php8.1-fpm.sock这个工具好处是不会暴露HTTP入口适合内网巡检。4.4 剩下的180ms里还剩什么调优完成后我又再做了一层profile看看这180ms都花在哪。抽样下来大概是这样环节估算耗时Composer自动加载与容器初始化50 - 70ms路由匹配与中间件执行15 - 25ms业务SQL查询含数据库往返60 - 90msJSON序列化与响应输出20 - 40ms可以看到Opcache帮我们省掉的是框架自动加载和容器初始化里很大一部分编译开销。剩下的这些时间还想继续压就得走另一层优化了要么给配置和路由做静态缓存要么对SQL执行结果做Redis缓存要么上preload。这些我放在下一节讲。5. 一路踩过的坑命中率上不来、部署失效、JIT的边界5.1 三种开了等于没开的情况第一个坑没定位瓶颈就开。如果你那个接口慢是因为数据库有个全表扫描的查询Opcache开得再好也救不回来。调优必须先用第一节的方式把问题定位清。第二个坑CLI状态查得欢FPM根本没生效。配置改到了CLI的conf.d目录Web请求走的FPM压根没加载。查配置一定要用Web方式看phpinfo()不要只看php -i。第三个坑max_accelerated_files或者memory_consumption太小缓存频繁淘汰。这种情况的典型症状是命中率一直在80%上下徘徊压测数据忽高忽低。遇到这种就按第三节的办法重新算参数给足余量。5.2 部署后代码不生效是validate_timestamps0的代价这是我必须把风险讲透的地方。validate_timestamps0之后Opcache不会再关心源文件变化你发新版代码上去线上的opcode还是老的。所以发布流程里必须要有一行命令每次部署完成之后强制刷新缓存php cachetool.phar opcache:reset --fcgi/run/php/php8.1-fpm.sock我们当时的做法是在CI/CD流程里发完代码后自动执行这条命令。如果你的团队没有自动化发布或者发布流程还不健全我更建议老老实实保持validate_timestamps1、revalidate_freq60让代码更新自己生效。性能差异在这个场景下没有大到命悬一线。如果你不想引入cachetool也可以做一个小型的内网清理脚本但务必做访问控制?php if (($_GET[token] ?? ) ! getenv(OPCACHE_RESET_TOKEN)) { http_response_code(403); exit; } opcache_reset(); echo ok;这个脚本千万不要暴露到公网token用强随机值并且最好只允许内网IP访问。用完就删。5.3 想再快一截框架缓存、Composer优化、preload组合着用Opcache是基础但不要停在原地。框架本身的缓存也值得做。以Laravel为例php artisan config:cache php artisan route:cache php artisan view:cache配置和路由这些东西不开缓存的情况下每次请求都要重新解析一遍开完之后这部分开销基本消失。ThinkPHP也有类似的php think optimize命令项目用什么框架就翻一下框架文档这一步的成本很低收益非常稳定。Composer的autoload优化也不能漏composer dump-autoload -o --classmap-authoritative这个命令会把classmap预先生成好避免运行时在目录里做路径探测省掉一部分文件系统的扫描开销。更进阶一点是opcache.preloadPHP 7.4以后支持。可以写一个preload脚本在PHP-FPM启动时把核心框架文件一次性加载进内存?php $files [ /data/www/vendor/autoload.php, /data/www/vendor/laravel/framework/src/Illuminate/Foundation/Application.php, ]; foreach ($files as $file) { opcache_compile_file($file); }然后配置opcache.preload/data/www/preload.php opcache.preload_userwww-data注意一个坑preload加载过的类在进程生命周期内不会再检查源文件变化代码更新只能靠重启PHP-FPM。所以preload适合对稳定、核心、不常改的库做不适合对业务代码做。而且preload不是所有项目都能一把过得逐个文件试验遇到类之间存在依赖关系时可能启动失败。我自己的经验是先在临时环境试通确认没有任何问题再上生产。5.4 什么时候不适合上JIT和preload如果你手头的机器内存只有1G还跑着好几个FPM池JIT的128M buffer就是一笔不小的开销而接口业务是IO密集型的收益却不明显。这时候真的没必要强上。preload就更要慎重。如果团队发布很频繁又没有标准的重启流程preload会变成一块烫手山芋改动不生效、清缓存也救不了必须重启FPM。小步快跑的团队可以先用框架缓存和Composer优化把preload放到最后再考虑。还有一点如果项目里用了和Opcache有兼容问题的扩展或者某个扩展版本和Opcache的共享内存冲突轻则警告重则直接502。每当升级PHP版本或者扩展版本时最好都把Opcache状态页重新看一眼别让升级把缓存机制搞挂了。我自己现在再遇到PHP接口慢已经不会一上来就翻业务代码而是先把耗时拆开看看到底是哪一部分吞掉的CPU。Opcache不是银弹但在这种典型的框架加载场景里它是最便宜、最有效的一笔投入。最后再分享一个小技巧如果你按照上面的配置改完发现接口没有明显变化不要急着加内存或者调大文件数上限先回去看命中率。命中率上不来说明你的调优根本没踩在点子上。先定位再开药速度才能真掉下来。