
接手这个老项目优化任务的时候我第一反应是去看数据库慢查询和缓存命中率结果折腾半天都没找到大头。后来把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目标文件确定时机编译期可识别字面量运行时变量计算后确定编译器依赖分析可构建完整依赖链无法建立静态依赖图路径解析与系统调用编译期已登记运行时开销小每次请求需拼接、解析、openopcache利用效率缓存命中率高、易预优化分支多时命中率波动大安全隐患路径可控性强易被利用做任意文件包含可维护性依赖关系清晰易扫描依赖隐晦排查成本高最后还有一笔隐性成本动态包含会让整个项目的依赖关系变得非常难追踪。做静态代码扫描、自动测试、调用链分析的时候工具根本没法跨文件还原完整的调用图。这笔“维护税”比线上那几十毫秒的延迟更贵只是大多数团队要等项目腐化到一定程度才感觉得到。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_cli1 bench/static_inc.php php -d opcache.enable_cli1 bench/dynamic_inc.php我在一台普通云主机上测出来的相对趋势差不多是这样测试场景静态include动态include关闭opcache0.42s0.71s开启opcache0.28s0.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.modeprofile xdebug.output_dir/tmp/xdebug xdebug.start_with_requestyes跑一次入口脚本后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_cli1否则脚本里拿不到状态。下面是一份比较稳妥的opcache配置适合生产环境起步opcache.enable1 opcache.enable_cli0 opcache.validate_timestamps1 opcache.revalidate_freq60 opcache.max_accelerated_files10000 opcache.memory_consumption128 opcache.interned_strings_buffer16validate_timestamps和revalidate_freq是双刃剑。设得太频繁每次请求都要检查文件mtime文件一多开销滚雪球设成0或者太大线上代码更新又不实时生效。要根据自己发布流程来调我一般配合CDN灰度线上设0发布时reload一次PHP-FPM。3. 替代方案与改造路径别急着删include3.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; } }入口处只需要注册一次autoloaderrequire __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_userwww-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部分慢请求超过600ms40并发压测时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 性能对比数据改完后在同一台机器、同样并发条件下重新压测结果如下指标改造前改造后提升幅度平均接口耗时360ms96ms73.3%QPS40并发180620244%opcache命中率82%99.2%提升明显内存峰值42MB33MB21.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_timestamps0或revalidate_freq过大调整配置发布后reload PHP-FPMWindows正常、Linux报文件找不到文件名大小写和路径分隔符差异统一使用__DIR__拼接严格遵守PSR-4大小写命名压测工具显示include耗时很高动态路径解析和系统调用叠加对比strace系统调用优先做静态依赖化改造5.2 实操心得分享做性能优化有一个铁律先量化再动手。别靠感觉说这段代码慢先用基准脚本或者Xdebug拿到耗时占比再决定要不要改。动态包含在很多项目里都只是“帮凶”真正的主谋可能是慢SQL、循环里的IO、或者过度复杂的ORM映射。直接埋头改include容易白忙一场。还有个容易被忽略的点动态包含不光是性能问题更是安全问题。只要用户输入的任何东西能影响到文件路径就存在被构造来加载任意文件的风险。所以哪怕是临时补丁也请先用白名单映射挡住路径拼接这行改动不花多少时间但能把风险降一个数量级。真正动手改造老项目我建议分步走。第一步给所有动态包含加白名单把风险锁住第二步把过程式action文件改造成Controller类方法理顺业务逻辑第三步再上Composer自动加载彻底告别手写include。三步之间互相独立每步都能单独上线回滚没必要一把梭把整个项目推倒重来。最后再分享一个小技巧改造完成后可以在测试环境用同样的请求分别打一次strace和压测脚本把前后两组系统调用数和QPS数据存下来。这些数据不仅是给领导看的成果也是下次再做类似优化时最可靠的参考基准。