接手这个老项目优化任务的时候,我第一反应是去看数据库慢查询和缓存命中率,结果折腾半天都没找到大头。后来把PHP的请求链路拆开,才发现一个被很多人忽略的细节:模板块里大量使用了动态包含——也就是把include/require的参数写成变量,让PHP在运行时才决定到底加载哪个文件。接口平均耗时三百多毫秒,压测QPS上不去,罪魁祸首就是它。这篇文章我打算把动态包含的性能开销原理、定位方法、改造方案和实测数据一次性讲透,适合正在做PHP性能优化,或者想把手头老项目代码结构理清楚的朋友参考。
先说清楚:我不会劝你把include全部消灭,这既不现实也没必要。关键在于搞清楚动态包含什么时候会成为瓶颈,以及怎么用可落地的方案把它替换掉。
1. 动态包含的典型写法与性能开销根源
1.1 动态包含的几种典型写法
所谓的“动态包含”,指的是PHP在编译和执行脚本时,无法预先确定具体要引入哪个文件,必须等代码跑起来、变量算出来之后才知道。最常见的写法有四种。
第一种是按模块拼路径,这也是老项目里最普遍的做法:
$module = $_GET['module'] ?? 'home'; $action = $_GET['action'] ?? 'index'; include "modules/{$module}/{$action}.php";第二种是用条件表达式或三目运算动态决定包含哪个文件,常见于多主题、多模板切换的场景:
include ($theme === 'dark' ? 'dark.php' : 'light.php');第三种是include_once和变量类名组合,多见于早期的手写类加载器:
require_once $className . '.php';第四种是循环批量加载,比如插件系统启动时遍历插件目录,把每个插件的入口文件包含进来:
foreach ($plugins as $plugin) { include "plugins/{$plugin}/bootstrap.php"; }这些写法本身都没错,初期效率很高:不用维护路由表,目录即结构,加一个页面就是加一个文件。但问题是,它把“代码依赖关系”藏在了运行时,机器无法提前预判,性能隐患和代码隐患会一起种下来。
1.2 性能开销到底从哪里来
很多人以为动态包含只是多了个变量拼接,能贵到哪去?其实它贵的不是拼接那点CPU,而是后面一连串的连锁反应。
首先是编译期无法建立静态依赖关系。include和require在PHP里本来就是运行时指令,不是编译期指令。你写include "a.php",编译器至少知道目标文件是谁;你写include $file,编译器完全不认识$file的值。没有静态依赖图,优化器很多跨文件的优化手段就用不上,整个请求的编译和Cache命中效率都会打折扣。
其次是运行时路径解析链路变长。变量拼出来的路径要先做字符串拼接,再做合法性检查,接着调用realpath一类的路径解析,最后才发起文件系统调用。静态include的路径是字面量,引擎在编译期就能记住路径信息,运行时的工作量少得多。系统调用看似不起眼,一旦QPS上来,秒级成千上万次,差距立刻被放大。
然后是opcache的实际命中率会变差。别误解,opcache对动态include的文件也能缓存,前提是请求真正执行到了那一行,并且最终解析出的文件路径是稳定一致的。但动态包含往往搭配多分支、多文件切换,路径变化频繁,就会反复触发文件状态校验。再加上有些环境开着validate_timestamps和revalidate_freq,文件mtime检查次数跟被包含文件数量直接挂钩。结果就是缓存策略本身没问题,但你的代码结构让缓存很难发挥效果。
此外,include_once和require_once会额外增加已加载文件的去重查找操作。动态路径意味着这个查找列表可能很长,每次都要比对路径字符串,累计开销也不小。
我把静态include和动态include放在一张表里对比,大家看得更清楚:
| 对比维度 | 静态include | 动态include |
|---|---|---|
| 目标文件确定时机 | 编译期可识别字面量 | 运行时变量计算后确定 |
| 编译器依赖分析 | 可构建完整依赖链 | 无法建立静态依赖图 |
| 路径解析与系统调用 | 编译期已登记,运行时开销小 | 每次请求需拼接、解析、open |
| opcache利用效率 | 缓存命中率高、易预优化 | 分支多时命中率波动大 |
| 安全隐患 | 路径可控性强 | 易被利用做任意文件包含 |
| 可维护性 | 依赖关系清晰,易扫描 | 依赖隐晦,排查成本高 |
最后还有一笔隐性成本:动态包含会让整个项目的依赖关系变得非常难追踪。做静态代码扫描、自动测试、调用链分析的时候,工具根本没法跨文件还原完整的调用图。这笔“维护税”比线上那几十毫秒的延迟更贵,只是大多数团队要等项目腐化到一定程度才感觉得到。
2. 如何定位并量化动态包含的性能损失
2.1 用基准测试脚本做可复现对比
要优化一个问题,第一步永远是量化它。口说无凭,先搭一个独立小项目,把动态包含和静态包含的差距跑出来。本地环境我建议用phpstudy或者宝塔这类面板工具管理多个PHP版本,切换PHP 7.4、8.0、8.2对比差异,比手工编译快得多。
建一个bench目录:
bench/ ├── static_inc.php ├── dynamic_inc.php └── files/ ├── a.php ├── b.php ├── c.php └── d.php每个文件里放一个简单的变量赋值,模拟真实业务中模板文件或配置文件的加载:
<?php $file_a_loaded = true;static_inc.php的内容:
<?php $t0 = microtime(true); $dir = __DIR__ . '/files'; for ($i = 0; $i < 10000; $i++) { include $dir . '/a.php'; include $dir . '/b.php'; include $dir . '/c.php'; include $dir . '/d.php'; } printf( "static include: %.4f s, peak mem: %.2f MB\n", microtime(true) - $t0, memory_get_peak_usage(true) / 1024 / 1024 );dynamic_inc.php的内容:
<?php $t0 = microtime(true); $dir = __DIR__ . '/files'; $targets = ['a.php', 'b.php', 'c.php', 'd.php']; for ($i = 0; $i < 10000; $i++) { $file = $targets[$i % 4]; include $dir . '/' . $file; } printf( "dynamic include: %.4f s, peak mem: %.2f MB\n", microtime(true) - $t0, memory_get_peak_usage(true) / 1024 / 1024 );跑的时候分两种情况:开opcache和关opcache。默认CLI模式下opcache是关闭的,需要手动开启:
php -d opcache.enable_cli=1 bench/static_inc.php php -d opcache.enable_cli=1 bench/dynamic_inc.php我在一台普通云主机上测出来的相对趋势差不多是这样:
| 测试场景 | 静态include | 动态include |
|---|---|---|
| 关闭opcache | 0.42s | 0.71s |
| 开启opcache | 0.28s | 0.49s |
具体数值跟你机器有关,不用纠结绝对值,要看趋势:动态包含在两种情况下都慢,而opcache对静态include的放大优化更明显。这个结果说明动态包含不仅自身开销大,还会拖累缓存策略发挥。
2.2 用火焰图、strace和Xdebug确认瓶颈
如果你面对的是一个大型项目,没法像上面那样单独隔离测试,那就用工具从侧面抓证据。
Linux环境下,strace是很好的系统调用统计工具。分别对静态和动态两个脚本跑一遍,重点观察openat、stat、fstat这些文件相关调用次数:
strace -c -f php bench/dynamic_inc.php strace -c -f php bench/static_inc.php动态版本的文件打开和状态查询次数通常会明显偏多。这直接说明动态包含在文件系统层多消耗了资源。
Windows环境或者不方便用strace的话,用Xdebug生成profile文件,再用Qcachegrind打开,也能定位include相关节点的时间占比。Xdebug 3的配置:
xdebug.mode=profile xdebug.output_dir=/tmp/xdebug xdebug.start_with_request=yes跑一次入口脚本后,output_dir下会生成cachegrind.out.*文件,导入可视化工具,看include和require相关函数占了多少总时间,一目了然。
想更细的话,可以上火焰图。用perf record抓调用栈,再转换成火焰图,动态包含的路径解析、系统调用、编译动作都会在栈上留下痕迹。不过对多数业务项目来说,strace加Xdebug已经足够拍板了。
2.3 检查opcache配置与命中情况
动态包含性能差,很多时候还叠加了opcache配置不当。先看当前opcache状态,用phpinfo()或者命令行:
php -i | grep opcache再写个小脚本,直观拿命中率:
<?php $status = opcache_get_status(false); if ($status) { $stat = $status['opcache_statistics']; echo 'hits: ' . $stat['hits'] . PHP_EOL; echo 'misses: ' . $stat['misses'] . PHP_EOL; echo 'hit rate: ' . round($stat['opcache_hit_rate'], 2) . '%' . PHP_EOL; }注意,opcache_get_status要装了opcache扩展且开启后才好用,CLI下记得用-d opcache.enable_cli=1,否则脚本里拿不到状态。
下面是一份比较稳妥的opcache配置,适合生产环境起步:
opcache.enable=1 opcache.enable_cli=0 opcache.validate_timestamps=1 opcache.revalidate_freq=60 opcache.max_accelerated_files=10000 opcache.memory_consumption=128 opcache.interned_strings_buffer=16validate_timestamps和revalidate_freq是双刃剑。设得太频繁,每次请求都要检查文件mtime,文件一多,开销滚雪球;设成0或者太大,线上代码更新又不实时生效。要根据自己发布流程来调,我一般配合CDN灰度,线上设0,发布时reload一次PHP-FPM。
3. 替代方案与改造路径:别急着删include
3.1 先看一张方案对比表
很多人一听动态包含性能差,第一反应是把所有include改成switch-case静态分支。这确实有用,但项目一大,人工改起来很痛苦。更合理的思路是理解各类方案的适用边界,按需组合。
| 方案 | 静态分析友好性 | opcache效益 | 安全隐患 | 改造成本 | 适用场景 |
|---|---|---|---|---|---|
| 保留动态include | 差 | 差 | 高 | 零 | 不推荐长期使用 |
| 统一入口+路由分发 | 好 | 好 | 低 | 中 | Web MVC项目改造 |
| Composer自动加载 | 好 | 好 | 低 | 低-中 | 新项目、类库管理 |
| opcache.preload | 好 | 极好 | 低 | 中 | 核心类库稳定、CLI常驻场景 |
综合来看,Web项目最推荐的组合是“统一入口+路由分发+Composer自动加载”,然后根据实际压测情况,决定要不要再上preload。
3.2 统一入口加路由分发改造示例
前端控制器模式是替代动态包含最经典的做法。还是那个按参数拼路径的例子,改造后是这样:
<?php $route = [ 'home' => [ 'controller' => App\Controller\HomeController::class, 'method' => 'index', ], 'list' => [ 'controller' => App\Controller\ListController::class, 'method' => 'show', ], ]; $page = $_GET['page'] ?? 'home'; if (!isset($route[$page])) { http_response_code(404); exit('Not Found'); } $target = $route[$page]; $controller = new $target['controller'](); $controller->{$target['method']}();这个写法把“按参数找文件”变成了“按参数找类和方法”,彻底消灭了动态路径拼接。好处是多重的:首先是性能上,入口文件、路由文件、控制器类文件都变成可静态分析的依赖,opcache能稳定命中;其次是安全上,用户输入被路由表约束,不再参与路径拼接,任意文件包含的问题从根上断了;再次是工程上,每个Controller是个独立类,可以单测,调用关系清晰可见。
唯一要适应的是目录结构和命名规范,初期改造需要点耐心,但收益非常明显。
3.3 用Composer自动加载替代手写include
就算不彻底改MVC,把include链换成Composer自动加载也是一大进步。先建composer.json:
{ "autoload": { "psr-4": { "App\\": "src/" } } }然后执行:
composer dump-autoload -o-o参数会生成优化过的类映射表。所谓优化,就是composer把“类名到文件路径”的映射关系预先构建成一张大映射表,运行时按类名直接查表,不需要扫描目录。再用composer dump-autoload --classmap-authoritative可以完全关闭对PSR-4目录的实时扫描,性能更好,代价是新增类后必须重新dump。
使用自动加载后,代码里不再有手写include:
<?php namespace App\Controller; class HomeController { public function index(): string { return 'home page'; } }入口处只需要注册一次autoloader:
require __DIR__ . '/vendor/autoload.php';autoloader虽然本质上也是运行时动态加载文件,但它的加载规则稳定、类映射明确,opcache友好度远高于手写动态include。而且你彻底不需要关心类文件的加载顺序了,这是手写include最容易出错的地方。
3.4 如果必须动态包含:白名单映射加静态分支
有些场景,比如插件系统必须加载外部扩展文件,真做不到完全消灭动态包含。这种时候至少要做到“动态输入、静态目标”,把变量约束到一个白名单里。
<?php $allowedPages = [ 'login' => __DIR__ . '/views/login.php', 'profile' => __DIR__ . '/views/profile.php', 'report' => __DIR__ . '/views/report.php', ]; $page = $_GET['page'] ?? 'login'; if (!isset($allowedPages[$page])) { http_response_code(404); exit('Invalid page'); } include $allowedPages[$page];这个写法里,用户输入只能决定选哪个预定义好的文件,不再参与路径拼接。目标文件集合固定,路径解析的开销降到最低,security问题也被锁死在白名单内。如果性能要求再高一点,可以把白名单数组定义成类常量,配合opcache,效果接近静态include。
另外一个细节:业务代码里能多require就尽量别用include。include一个不存在的文件只给个warning,脚本继续跑,后面大概率打出更奇怪的错误,排查起来难受。require失败会直接终止,至少问题暴露得干脆。
3.5 PHP 7.4+ opcache.preload预加载提升到另一个层次
如果你的项目依赖关系已经理清,而且核心类库比较稳定,可以考虑PHP 7.4引入的opcache.preload。它的原理是把指定文件在PHP服务启动时就编译进共享内存,请求进来直接复用,连运行时include操作都省了。
php.ini里配置:
opcache.preload=/var/www/html/preload.php opcache.preload_user=www-datapreload.php大致这样写:
<?php $files = [ __DIR__ . '/src/Controller/HomeController.php', __DIR__ . '/src/Controller/ListController.php', __DIR__ . '/src/Repository/UserRepository.php', ]; foreach ($files as $file) { opcache_compile_file($file); }注意这里用的是opcache_compile_file,不是include。include会执行文件里的代码,opcache_compile_file只负责把文件编译进共享内存,不产生副作用,安全得多。
但preload有几个坑必须先知道。预加载的类如果在业务代码里被重新声明,会直接报fatal error。这意味着每次部署新代码,只要类定义有变化,就得reload PHP-FPM,否则新旧状态冲突。另外,opcache.preload在CLI模式下意义不大,它主要服务常驻的PHP-FPM进程池。用得好是性能利器,用不好会把自己坑进线上事故,前期准备不充分不建议轻易上。
4. 真实案例:一个模块化CMS接口耗时优化实录
4.1 项目背景与症状
这个案例是我经手的一个老牌模块化CMS后台接口。它的后台管理侧一直采用“模块/动作”的目录风格,每个模块一个文件夹,按参数动态加载对应action文件。调用方反馈说后台列表接口经常转圈,监控数据显示:首页接口平均耗时360ms,部分慢请求超过600ms,40并发压测时QPS只有一百八十。
一开始怀疑是SQL慢查询,但慢日志里并没有特别离谱的语句。数据库索引也检查过,没发现明显问题。于是把视角从数据库转向PHP执行本身。
4.2 定位过程和数据表现
用strace对压测过程中的PHP-FPM进程做采样,发现openat和stat相关系统调用次数高得异常。再用Xdebug profile跑同一个接口,include相关节点的耗时占比达到37%,接近四成。配合opcache_get_status检查,命中率只有82%,对于一个核心接口来说,这个命中率非常不健康。
顺着代码链路往下追,发现这个问题接口对应的动态包含结构大致是这样:
<?php $module = preg_replace('/[^a-z0-9_]/i', '', $_GET['module'] ?? ''); $action = preg_replace('/[^a-z0-9_]/i', '', $_GET['action'] ?? ''); if (!$module || !$action) { exit('bad request'); } include $baseDir . "/modules/{$module}/{$action}.php";虽然对参数做了正则清洗,但这仍然是地地道道的动态包含。每个接口进来,PHP都要现拼接路径、解析文件、发起文件操作。而且这个action文件还会继续include自己的子模块文件,一层套一层,整个请求成了一个隐晦的“动态包含树”。
4.3 改造过程
改造分三步走。
第一步,把业务逻辑按Controller类重写。原来每个action文件里是一堆过程式代码,现在整理成HomeController、ListController等类,方法内实现原来action的逻辑。
第二步,引入Composer,按PSR-4规范做自动加载。原来手工include的类库全部挪进src/目录,composer dump-autoload -o生成优化映射表。
第三步,入口脚本改成路由分发。不再根据参数拼文件路径,而是根据路由表实例化控制器并调用方法:
<?php require __DIR__ . '/vendor/autoload.php'; $router = [ 'dashboard' => [App\Controller\DashboardController::class, 'index'], 'list' => [App\Controller\ListController::class, 'index'], ]; $action = $_GET['action'] ?? 'dashboard'; if (!isset($router[$action])) { http_response_code(404); exit('Not Found'); } [$controllerClass, $method] = $router[$action]; $controller = new $controllerClass(); $controller->{$method}();这里借用PHP 7.1+支持的array destructuring语法,代码更简洁,但如果你还在PHP 5.6时代,就老老实实用两行赋值。
4.4 性能对比数据
改完后在同一台机器、同样并发条件下重新压测,结果如下:
| 指标 | 改造前 | 改造后 | 提升幅度 |
|---|---|---|---|
| 平均接口耗时 | 360ms | 96ms | 73.3% |
| QPS(40并发) | 180 | 620 | 244% |
| opcache命中率 | 82% | 99.2% | 提升明显 |
| 内存峰值 | 42MB | 33MB | 21.4% |
需要说明的是,这组数据是单机环境多次压测取的平均值,不能直接照搬到你的项目里,但它反映的趋势是明确的:动态包含不是唯一瓶颈,但它是叠加buff,把SQL、IO、框架本身的性能问题全都放大了。改掉之后,整个接口的延迟基数降下来了。
4.5 改造过程踩过的坑
这个项目改造不像文章写得这么顺,中间至少踩了三个坑。
第一个坑是preload上线翻车。当时为了让接口再快一点,上了opcache.preload,结果第二天发布新功能时,新增的一个类跟预加载缓存的旧类定义冲突,整个PHP-FPM报fatal error,接口大面积502。后来才明白,preload之后的类生命周期完全由常驻进程管理,代码更新必须同步reload。解决方案很简单:调整发布脚本,部署完成后统一重启PHP-FPM。
第二个坑是PSR-4大小写问题。本地Windows环境开发时,类文件名大小写写错了也不报错,一上Linux线上环境,Composer自动加载立刻找不到类。这个在Windows下开发、Linux部署的项目里特别常见,我的建议是尽早换成大小写敏感的文件系统习惯,或者在CI流程里加一道文件名校验。
第三个坑是opcache的revalidate_freq调太大,导致线上代码一直不生效。本地改了文件,刷新页面老样子,以为是缓存插件的问题,折腾半天才想起来自己把validate_timestamps设成了0。调试期千万别图省事把缓存时间拉满,不然改代码全靠猜。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 接口慢、CPU偏高 | 动态包含文件数量多且opcache命中率低 | 开启opcache,改造为路由分发或自动加载 |
| 使用include变量路径后页面白屏 | 文件不存在,include只产生warning | 改用require,或在包含前用白名单校验 |
| 动态包含时类重复定义报错 | 同一类文件被多个动态路径加载 | 统一用autoload,避免手写include_once |
| 改代码后线上不生效 | opcache.validate_timestamps=0或revalidate_freq过大 | 调整配置,发布后reload PHP-FPM |
| Windows正常、Linux报文件找不到 | 文件名大小写和路径分隔符差异 | 统一使用__DIR__拼接,严格遵守PSR-4大小写命名 |
| 压测工具显示include耗时很高 | 动态路径解析和系统调用叠加 | 对比strace系统调用,优先做静态依赖化改造 |
5.2 实操心得分享
做性能优化有一个铁律:先量化再动手。别靠感觉说这段代码慢,先用基准脚本或者Xdebug拿到耗时占比,再决定要不要改。动态包含在很多项目里都只是“帮凶”,真正的主谋可能是慢SQL、循环里的IO、或者过度复杂的ORM映射。直接埋头改include,容易白忙一场。
还有个容易被忽略的点:动态包含不光是性能问题,更是安全问题。只要用户输入的任何东西能影响到文件路径,就存在被构造来加载任意文件的风险。所以哪怕是临时补丁,也请先用白名单映射挡住路径拼接,这行改动不花多少时间,但能把风险降一个数量级。
真正动手改造老项目,我建议分步走。第一步给所有动态包含加白名单,把风险锁住;第二步把过程式action文件改造成Controller类方法,理顺业务逻辑;第三步再上Composer自动加载,彻底告别手写include。三步之间互相独立,每步都能单独上线回滚,没必要一把梭把整个项目推倒重来。最后再分享一个小技巧:改造完成后,可以在测试环境用同样的请求分别打一次strace和压测脚本,把前后两组系统调用数和QPS数据存下来。这些数据不仅是给领导看的成果,也是下次再做类似优化时最可靠的参考基准。