做了这么多年 PHP 接口,最怕看到的就是线上接口平均耗时 1200ms 这个数字。用户等得烦躁,前端超时重试,监控告警刷屏,领导来问“怎么回事”。大多数情况下,我们第一反应是加机器、加索引、拆服务,但其实有一件成本极低、收益却非常直接的事被很多人忽略了,那就是 Opcache 调优。这篇文章不聊虚的,就用一个真实的接口优化案例,从定位瓶颈、理解原理、调整参数到处理各种坑,完整记录我是怎么一步步把接口从 1200ms 压到 180ms 的。如果你手头也有一个慢腾腾的 PHP 服务,这篇内容可以直接照着操作。
1. 先把慢的源头揪出来:为什么第一反应是 Opcache
1.1 1200ms 到底耗在哪了
在动手优化之前,得先搞清楚时间花在哪。一个 HTTP 接口的响应时间,粗略可以切成这几段:网络传输、Web 服务器处理、PHP 执行、数据库查询、外部服务调用。很多人一上来就查 SQL,或者直接看代码逻辑,但有时候问题根本不在这些地方。
我当时的排查路径是这样的。先看 Nginx access log 里记录的 upstream_response_time,如果这个值和客户端感知的响应时间基本一致,说明网络和浏览器侧没太大问题,瓶颈在 PHP-FPM 这一层。再看慢日志(slowlog配置),如果经常出现某个请求卡在框架初始化阶段,而不是卡在数据库查询,思路就要往“框架启动开销”这个方向去靠。
这时候需要回答一个问题:同样的代码逻辑,线上跑 1200ms,为什么本地开发环境快得多?其中一个非常常见的原因,就是线上环境没有开启或者没有正确配置 Opcache。PHP 每次请求都要重新读取文件、解析语法、编译成字节码,框架项目可能涉及几百上千个文件,这些重复劳动在请求量大的时候会被明显放大。
1.2 PHP 每次请求都在重复“编译”这件事
很多人把 PHP 简单理解成“解释执行”,但其实完整的执行过程分为几步:PHP 引擎读取 .php 源文件,进行词法分析、语法分析,生成 opcode(字节码),然后在 Zend 虚拟机上执行这些 opcode。关键点在于,如果没有 Opcache,这一步对同一个文件来说每次请求都会从头做一遍,哪怕文件内容一个字都没改。
打个比方:你每天做同一道菜,菜谱已经贴在墙上几百次了,但每次做菜之前你还是坚持重新看一遍菜谱、重新洗一遍菜、重新把调料全部摆一遍。有这时间,菜都炒完了。Opcache 干的事就是把这些固定动作缓存下来,下次直接起锅烧油,省掉了大量重复的准备工作。
对于现代框架,比如 Laravel、ThinkPHP、Symfony,一个请求从入口开始往往要 include 几十上百个核心文件,加上 service provider 和 facade 的加载、注解解析、路由匹配,代码量非常庞大。开启 Opcache 之后,最直观的变化就是:这些文件不再需要反复编译了,CPU 占用率明显下降,接口响应时间也会随之改善。
1.3 先验证再说:怎么看 Opcache 有没有在工作
判断 Opcache 是否在正常工作,最直接的就两条命令和一个函数。
php -m | grep -i opcache如果输出里有Zend OPcache,说明扩展已经装了。再跑一下:
php -i | grep opcache.enable看到opcache.enable => On,说明命令行环境下开启了。但要特别注意,命令行下开启不代表 PHP-FPM 也开启了,得看 phpinfo() 页面或者调用opcache_get_status()。
<?php print_r(opcache_get_status());如果输出里hits和misses都有比较大的数字,num_cached_scripts大于 0,说明 Opcache 确实在缓存脚本。重点要算命中率:
命中率 = hits / (hits + misses)如果命中率长期低于 90%,比如只有 60% 到 70%,那很可能问题出在 Opcache 配置上,比如内存太小导致缓存被频繁清除,或者缓存文件数上限太低,很多脚本根本没被缓存进来。
2. Opcache 原理和参数:把缓存调得恰到好处
2.1 Opcache 缓存了什么,没缓存什么
理解了 PHP 执行流程之后,Opcache 的缓存对象就很好理解了:它缓存的是源文件编译之后的 opcode 字节码,以及一部分被 intern 过的字符串,还维护着类、函数、文件路径的映射关系。简单说,它让“解析和编译”这个过程只发生一次。
但有一点必须清楚:Opcache 不缓存运行时的数据。变量值、数组内容、数据库查询结果、session 状态,这些都不归 Opcache 管。所以如果你有一个接口每次查询几十万行数据然后循环拼接,Opcache 再猛也救不了你,因为瓶颈在代码执行效率和数据量上,而不是编译开销。
明白这一点非常重要,因为很多人在开了 Opcache 之后发现接口还是慢,就开始怀疑 Opcache 没用。其实不是没用,是问题本来就不在编译阶段。这也是为什么后面调优时要结合框架缓存一起做。
2.2 核心参数逐个拆解
先看几个最常用的配置项,各有各的脾气,乱设反而容易出问题。
第一个是opcache.enable,这是总开关。第二个是opcache.enable_cli,默认是 0,意思是命令行执行 PHP 脚本时不用缓存。大多数情况下这个保持默认就行,没必要给 CLI 开缓存,因为 CLI 脚本生命周期极短,缓存了也用不上,反而白白占内存。
然后是opcache.memory_consumption,这是 Opcache 共享内存的总大小,默认值是 128M。这个值大小取决于项目脚本数量和单次编译结果的大小。项目越大、文件越多,需要的共享内存就越大。如果设置太小,Opcache 会不断把旧的缓存内容清除掉给新文件腾位置,导致命中率暴跌,CPU 又飙上去,性能甚至比不开还差。
opcache.interned_strings_buffer控制内部字符串缓存的占用,默认 8M 或 16M。这个参数通常不需要动太大,但如果你代码里字符串常量非常多,可以适当加大到 32M,能省一点内存使用。
opcache.max_accelerated_files限制最多缓存多少个 PHP 文件。这个非常关键。默认值在不同 PHP 版本里不一样,老版本可能只有 2000,新版本是 10000。如果项目实际文件数超过这个上限,多出来的文件就不会被缓存,每请求依然走完整编译流程。怎么估算需要设置多少?最简单的方法是在项目根目录跑一下统计命令:
find . -name "*.php" | wc -l拿到文件总数之后,留出一些余量,再把max_accelerated_files设成接近的值。注意这个值建议设置成质数,具体原因众说纷纭,但大量线上实践表明质数能减少哈希冲突概率。
opcache.validate_timestamps和opcache.revalidate_freq是一对容易踩坑的参数。前者控制是否检查文件的更新时间,后者设置检查周期。生产环境如果关闭validate_timestamps,PHP 不会再检查源文件是否变化,所有代码修改都必须靠手动清缓存才能生效,这样可以换取一点点性能提升。开发环境千万别关,否则改了代码半天不生效,你会怀疑人生的。
opcache.fast_shutdown这个参数也比较关键,开启后可以加速请求结束阶段的处理流程,默认已经是开启状态,一般保持就行。
opcache.preload是 PHP 7.4 之后加入的功能,可以在服务启动时直接把指定的 PHP 文件编译并载入共享内存,避免运行时再编译。效果很直接,但坑也不少,后面实操部分单独说。
2.3 一份可直接抄的 php.ini 配置
基于上面的原理,我当时在生产环境用的是下面这套配置,针对的是文件数约在 3000 到 4000 之间的中型 PHP 项目:
opcache.enable=1 opcache.enable_cli=0 opcache.memory_consumption=256 opcache.interned_strings_buffer=16 opcache.max_accelerated_files=8000 opcache.validate_timestamps=0 opcache.revalidate_freq=0 opcache.fast_shutdown=1 opcache.save_comments=0简单解释一下每个选择:memory_consumption=256是因为统计完项目文件数和字节码大小之后,128M 处于临界状态,上调到 256 比较稳妥;validate_timestamps=0是为了省掉每次检查文件 mtime 的开销,代价是发布代码时需要手动清缓存;save_comments=0是为了节省内存,不保存代码注释,这个选项对没有用到反射的项目影响不大,自带的效果是 opcode 体积变小一点。
注意:
validate_timestamps=0只建议在有部署流程、发布后能统一opcache_reset()的环境使用。如果没有任何清缓存手段,代码更新后线上会一直跑旧代码,这个坑我后面还会重点说。
3. 从 1200ms 到 180ms 的实操路线
3.1 第一板斧:开启 Opcache 后发生了什么
按上面配置调整后,先做最小验证:不碰任何业务代码,直接重新压测同一个接口。这个阶段的结果通常会让不少人意外,接口耗时开始明显下降,但还没有到质的飞跃。
我用 ab 做了 1000 个请求的压测,命令大概是这样的:
ab -n 1000 -c 20 -t 60 http://你的接口地址/开启并调好 Opcache 之后,接口平均耗时从原来的 1200ms 降到了 700ms 左右。这已经让当时项目组的人很兴奋了,但我知道真正的大头还没来,因为 700ms 依然不够健康。这个阶段的核心收获是:编译阶段的开销确实被吃掉了,可框架启动后还有一大堆运行时逻辑在拖后腿,比如配置加载、路由匹配、依赖注入、门面类的初始化。
3.2 第二板斧:补齐框架侧的缓存,让 Opcache 效果最大化
Opcache 解决的是“编译”问题,但框架初始化过程中还有不少“纯业务计算”的耗时。以 Laravel 为例,每次请求都要加载 config 文件、解析路由、注册服务提供者。这些工作不是简单的 opcode 缓存能解决的,它们是已经编译成字节码之后依然要跑一遍的逻辑。
所以这一步我做了三件事:生成配置缓存、生成路由缓存、优化 Composer 自动加载。
php artisan config:cache php artisan route:cache composer dump-autoload -oconfig:cache会把所有配置合并编译到一个文件里,请求进来时不用逐个加载 .php 配置文件;route:cache把路由匹配逻辑直接序列化缓存,省掉正则解析和匹配表构建;Composer 的-o参数会把 PSR-4 的目录搜索转换成 classmap 映射表,加载类文件时直接按绝对路径去 require,效率高很多。
这三步做完之后没增加任何硬件成本,接口耗时就从 700ms 降到了 380ms 左右。这里有一个值得强调的点:Opcache 和框架缓存不是竞争关系,而是叠加关系。Opcache 减少编译开销,框架缓存减少运行时初始化开销,两者组合才让性能真正上一个台阶。很多项目的调优文章只讲其中一项,其实是不完整的。
3.3 第三板斧:Opcache 参数与 preload 进阶
到了 380ms 之后,我开始盯 Opcache 本身的状态。通过opcache_get_status()看数据,发现num_cached_scripts已经接近配置上限了,而且 wasted memory 有增长趋势,说明有些缓存被无谓占用。这时候做了两件事:一是重新计算了项目文件数,把max_accelerated_files调整到更合理的值;二是准备上 preload。
preload 是 PHP 7.4 引入的能力,它和普通 Opcache 缓存有一个本质区别:preload 的文件在 php-fpm 启动阶段就已经加载到内存里,之后请求过来连“从磁盘读文件”这一步都省了,而且这些类常驻共享内存,用完之后不会销毁。效果非常接近常驻内存型语言对热路径的优化。
我当时配置的 preload 脚本大概长这样:
<?php $files = [ __DIR__ . '/vendor/autoload.php', __DIR__ . '/app/Providers/AppServiceProvider.php', __DIR__ . '/app/Http/Kernel.php', // 其他核心文件 ]; foreach ($files as $file) { opcache_compile_file($file); }然后在 php.ini 里加一行:
opcache.preload=/var/www/preload.php这里有几个容易踩的坑:preload 文件必须由 php-fpm 启动进程读取,改完必须重启 php-fpm 才生效;preload 文件里不能加载带有副作用逻辑的普通业务脚本,否则整个进程组都可能受影响;开发环境不要开 preload,否则改完代码想要生效,每次都只能重启 php-fpm,体验非常差。
preload 的作用范围和项目结构关系很大,如果你的项目框架本身已经通过 Composer classmap 优化得很干净,preload 带来的边际收益可能不会有想象中那么大。但对我们的项目来说,加上 preload 之后,接口平均耗时从 380ms 降到了 250ms 左右。
3.4 最终压测结果对比
有了前面的几步基础,最后再把数据库侧几个明显慢的查询索引优化了一轮,去掉了几次重复请求,最终接口耗时稳定在了 180ms。
为了直观一点,我把整个调优过程中关键节点的数据整理了一下:
| 阶段 | 平均耗时 | P95 耗时 | Opcache 命中率 | 内存占用 |
|---|---|---|---|---|
| 原始状态(未开启 Opcache) | 1200ms | 1500ms | - | - |
| 开启 Opcache | 700ms | 900ms | 91% | 60M |
| 框架配置缓存 + route cache | 380ms | 520ms | 95% | 120M |
| preload + 参数精调 | 250ms | 340ms | 98% | 210M |
| 数据库索引优化后 | 180ms | 240ms | 98% | 210M |
这份数据最有说服力的部分不是最终值,而是每一步的增量。它说明了一个很现实的问题:性能优化从来不是某一个单项配置的魔法,而是多个环节共同作用的结果,并且每走一步,都需要用压测数据来验证方向是否正确。
4. 调优路上踩过的坑,和几个临门一脚的小工具
4.1 改完配置没效果?先检查这几个地方
调优过程中最让人心态爆炸的,不是配置不好使,而是改了配置发现没反应。我遇到过的典型情况有下面几种。
第一种是改错了 ini 文件。PHP 命令行和 PHP-FPM 可能加载的是不同的 php.ini,命令行下看php --ini找到的文件不一定就是 FPM 用的那个。一定要在 phpinfo() 页面里看Loaded Configuration File这一项,确认改的是不是这个路径。
第二种是改了配置没重启 PHP-FPM。Opcache 的共享内存是在 FPM 启动阶段创建的,大部分参数修改后必须重启才生效。注意service php-fpm restart和reload不完全一样,reload 会重新加载配置,但对于某些 Opcache 参数,restart 更保险。
第三种是容器部署场景。如果是 Docker 部署,php-fpm 进程重建后 Opcache 缓存是空的,需要预热。如果不做预热,刚发布完的一段时间命中率会很难看,接口也会有一波耗时抖动。这时候可以写一个预热脚本,发布完成后访问一批核心接口,让缓存赶紧跑起来。
4.2 validate_timestamps 和 revalidate_freq 的微妙取舍
前面提到生产环境可以关掉validate_timestamps,但这里有一个非常经典的坑:关闭之后,代码部署上去不生效,而且不报任何错误。看起来像是线上环境一切正常,实际上跑的永远是旧代码,排查起来非常隐蔽。
我现在的做法是:生产环境不直接关掉validate_timestamps,而是把revalidate_freq设成一个合理值,比如 60 秒或 120 秒。这样代码发布之后,最多等一两分钟新代码自然生效,不用每次发布都手动清缓存。对于严格追求性能的场景,再考虑完全关闭,但配套一定要有发布脚本里自动执行清缓存的环节。
清缓存很简单,三种方式任选:
- PHP 脚本里调用
opcache_reset() - 命令行执行
cachetool opcache:reset - 重启 php-fpm
如果用的是 Laravel 这类框架,也可以在部署脚本里加一条命令,让每次代码发布完成之后自动把 Opcache 缓存清一遍。这样既能享受validate_timestamps=0带来的性能收益,又能避免“代码改了不生效”这种隐形炸弹。
4.3 内存不够、浪费率高、文件数超限
Opcache 调优里最容易忽略的是内存和文件数是否足够。内存设小了,命中率会掉;文件数设小了,大量脚本压根不会被缓存。而且这两个问题在运行初期不一定暴露,随着项目迭代文件越来越多,性能会慢慢劣化。这个现象特别隐蔽,因为曲线是渐进的,不容易被监控报警捕获到。
怎么判断出了问题?还是看opcache_get_status()。重点关注两个值:misses是不是在持续增长,wasted memory是不是接近memory_consumption。如果在内存没满的情况下 miss 频繁,大概率是脚本文件数超过了max_accelerated_files。
排查完问题之后,推荐用一个小工具直接看可视化状态,省得每次手写 PHP 脚本。opcache-gui这个项目只有单个 PHP 文件,放到 web 目录下就能访问,可以看到命中率、缓存占用、key 分布情况,排查问题效率高很多。
还有一个经验:不要贪心把memory_consumption拉到 1G。Opcache 用的是共享内存,分配太大并不会让你的应用更快,反而会挤压系统其他进程的资源。最合适的值是“装下所有缓存脚本 + 30% 余量”,这个可以通过状态页里的实际占用数据反推。
4.4 压测工具的选择和 TTFB 的意义
最后补充一下压测工具。很多人喜欢用浏览器 DevTools 看请求耗时,但这个数字包含了网络延迟、浏览器渲染、图片加载,不能直接用来衡量接口性能。更可靠的是用 ab、wrk 直接压 PHP-FPM,或者至少用 curl 看响应时间。
curl -w "time_total: %{time_total}s\ntime_starttransfer: %{time_starttransfer}s\n" http://你的接口地址其中time_starttransfer就是 TTFB,也就是从请求发出到第一个字节返回的时间。这个数值能比较准确地反映服务端处理能力。调优前后对比时,优先看 TTFB,而不是总耗时。
压测的时候注意控制并发数,不要一上来就 100 并发把服务打死。我习惯先用-c 20看看稳定表现,再逐步往上加。压测的同时配合观察php-fpm进程 CPU 占用、Opcache 命中率这两个指标,能比单纯看响应时间更快定位瓶颈。
整个调优流程走下来,我最深的体会是:优化不是同一个时刻把所有参数都改一遍,然后祈祷奇迹发生。一次只改一个变量,跑一次压测,记录一次数据,确认收益再动下一个参数,这样才能知道每一步到底是什么起了作用。Opcache 是 PHP 性能优化里最基础也最容易被忽略的环节,但它不是万能药,需要和框架缓存、代码逻辑、数据访问各个方面配合,才能发挥出真正的价值。如果你正在为接口慢发愁,可以先从 Opcache 的状态页开始,把命中率拉起来,说不定第一步就能感受到惊喜。