1. 先搞清楚Preload 到底干了什么事跟 OpCache 有什么本质区别1.1 从 OpCache 说起你熟悉的那个“缓存”只是事后优化聊 Preload 之前得先把 OpCache 的定位掰扯清楚。很多 PHP 项目升到 7.4 之后第一件事就是去翻 php.ini 里有没有opcache.enable1看到是开着的就以为万事大吉。这其实只做对了一半。OpCache 的作用是当 PHP 脚本第一次被执行时Zend 引擎把源码解析、编译成 opcode这个结果会存进共享内存后续请求再遇到同一个文件直接复用 opcode省掉 parse 和 compile 两个阶段。但注意OpCache 省掉的只是“编译”这一步。当一个请求走到new FooController()的时候Zend 引擎仍然需要去查找这个类在哪个文件、触发 autoload、把类声明注册到类表里这些流程并不归 OpCache 管。哪怕字节码已经在共享内存里了类的解析、加载、依赖检查照样每次请求都要走一遍。在高并发场景下这部分开销虽然比不上编译那么重但积累起来非常可观。Laravel 这种类多、服务多、Composer 依赖一堆的框架一次请求里光 autoload 涉及的类文件就可能上百个这些“加载开销”才是 Preload 想干掉的东西。1.2 Preload 的核心逻辑服务启动时就把类装进内存Preload 的思路跟 OpCache 完全不同。它不等到“第一个请求来”才去编译而是在 PHP-FPM 的 master 进程启动阶段通过一个自定义脚本把一批 PHP 文件提前编译并且把编译结果里的类、接口、函数直接注册进共享内存里的类表。之后 fork 出来的所有 worker 进程一出生就自带这些类定义请求里再遇到这些类连 autoload 都不用触发直接就能用。打个比方。OpCache 相当于你把菜做好放进冰箱客人点单的时候好歹还得从冰箱里拿出来热一下。Preload 相当于开餐前把所有菜全部摆到自助餐台客人进来直接拿连从冰箱取这一步都省了。听起来确实诱人对吧问题在于这桌菜是开店之前就摆好的如果某道菜出锅之后变了味你没法中途换掉——只能把整个店关了重新摆。1.3 关键差异不可变、全局共享、启动期执行Preload 和 OpCache 有三个本质差异这三个差异直接决定了它会在 Laravel 项目里埋下哪些坑。第一不可变。Preload 的类一旦在 master 启动时装入共享内存在这个进程池的整个生命周期内就是固定的。你在业务代码里改了该类文件哪怕把opcache.validate_timestamps打开、把文件时间戳检查打开对这个类也无效。想让它生效只能重启 PHP-FPM。这就意味着 Preload 特别不适合那些频繁发布、代码天天在变的应用。第二全局共享。Preload 类定义不是每个 worker 一份而是所有 worker 共用同一份。听起来省内存但这也意味着静态属性是被所有请求共享的。Laravel 里不少类有静态属性比如 Collection 的内部缓存、Str 的一些静态配置。如果 preload 脚本在启动阶段就赋了值或者运行时代码改动了这些静态属性那么所有 worker、所有请求看到的都是同一份被改过的状态——这不是你熟悉的每个进程隔离的 PHP 版本而是“所有请求共用一个全局变量”的模型。第三启动期执行。Preload 脚本本身是启动时执行的 PHP 代码。只要脚本里写了会对外产生副作用的代码比如连接数据库、执行文件读取、初始化容器这些副作用都会在 master 进程启动时发生一次然后遗留到整个进程池的生命周期里。Laravel 项目如果错误地在 preload 脚本里 require 了bootstrap/app.php等于是把整个应用的“启动现场”永久固化了下来后患无穷。讲清楚这三条后面的坑就都明白了。2. Laravel 项目里三类典型 Preload 陷阱的现场还原2.1 陷阱一全量预加载 vendor内存按 worker 数成倍放大我见过最多的配置失误是照着网上那些“一行配置开启 Preload”的文章直接把vendor/整个目录递归opcache_compile_file了一遍。这么干的同学通常会得到一个非常惨烈的结果PHP-FPM 直接 OOM或者服务器响应慢到像死机。原因很简单。Preload 编译后的类定义确实放在共享内存里但每个 worker 进程在处理请求时一旦用到某个类并修改了它的运行时状态触发写时复制COW进程自己的 RSS 就会增长。更直接的是preload 统计信息里的memory_consumption虽然显示的是共享内存占用量但如果你的opcache.memory_consumption配置不够大preload 区根本塞不下那么多类直接就报 “Cannot allocate memory for the interned string” 之类的错。以 Laravel 为例跑一个中等规模的项目vendor/下所有 PHP 文件编译后的 opcode 加起来很容易超过 300MB。如果opcache.memory_consumption还停在默认的 128MBpreload 一跑起来就把内存干满了OpCache 被迫开始清缓存性能反而比不开还差。即便你把内存调到 512MB想象一下线上 8 个 worker每个 worker 在运行期因为 COW 额外占用几十 MB整体内存压力也相当可观。我自己踩过这个坑是在一个包了一堆 Composer 包的 Laravel 电商后台项目上。当时开了全量 preload第二天早上收到监控告警服务器可用内存从 4GB 掉到不足 500MBps aux里几乎每个 php-fpm worker 的 RSS 都涨到了 700MB 以上。你没看错是每个 worker 都 700MB。后来一算问题就出在全量预加载 请求里大量修改了那些类的运行时状态COW 导致每个 worker 都把 preload 区复制了一份自己的版本。2.2 陷阱二preload 脚本里误跑框架容器和 Facade 静态属性被“冻住”这个坑更隐蔽也更要命。很多同学写 preload 脚本的时候觉得光opcache_compile_file还不够怕有些类不会被正确加载于是在脚本里加了一句require base_path(bootstrap/app.php)想着顺便把 Laravel 容器初始化了请求来了能更快。这个操作等于亲手把 Laravel 装进了一个“琥珀”里。preload 脚本是在 master 进程启动阶段执行的你这一句require会把整个应用容器创建出来绑定一堆单例、注册一堆服务提供者、连接可能的数据库连接。然后这个容器实例就存在 preload 共享内存里了。之后每个 worker 处理的每个请求拿到的都是同一个容器实例。听起来“复用容器”很高效但 Laravel 的请求生命周期是完全围绕每次请求重新构建容器设计的。同一个容器被多个请求同时使用意味着请求间的状态会互相污染。最常见的症状用户 A 登录之后用户 B 的请求发现自己也登录了或者某个服务在请求 A 里设置了什么缓存请求 B 看到了不该看到的数据。我在测试环境复现过一次两个不同用户的 session 混在一起当时以为 Redis 配置出了问题排查半天才发现是 preload 脚本把$app固化成了全局单例。更麻烦的是 Facade。Laravel 的 Facade 底层的getFacadeRoot()方法会从容器里解析实例如果你 preload 了容器而且 Facade 的静态容器引用也在启动期被设置好那么所有请求的 Facade 访问都会命中同一套静态状态。这种跨请求共享的模型跟 PHP-FPM 的进程隔离模型完全顶着干一旦出现就是极其诡异、极难排查的线上问题。2.3 陷阱三改了代码不生效线上一直跑旧逻辑这个坑属于“后知后觉”型的很多人踩完才发现自己根本没理解 Preload 的不可变特性。场景是这样的某天你上线了一个 preload 配置把几个高频类加了进去。上线后第一天一切正常性能还小有提升。第二天改了个业务 buggit pull拉代码刷新页面发现改了跟没改一样。去服务器上确认文件内容是对的php -l检查语法也没问题但线上跑的就是旧逻辑。如果你之前只接触过 OpCache第一反应肯定是 “OpCache 的 time stamp 检查不是开着吗为什么不重新编译”。但 Preload 类压根不走 OpCache 的文件变更检查机制。只要这个类被 preload 了它在共享内存里的定义就是启动时的版本你改文件它根本不知道。想让它生效必须重启 PHP-FPM。问题来了有多少团队记得每次发布后重启 PHP-FPM如果发布脚本里没有这个步骤那这就是一个“上线了但没完全上线”的幽灵 bug。反向还有一个坑preload.php 本身如果写错了PHP-FPM 启动会直接失败。你正想更新代码做个热发布改完 preload.php 一重启服务直接起不来页面全部 502。你第一反应是刚才改了业务代码导致的回滚业务代码、重启还是起不来。最后才意识到是 preload 脚本本身的问题。这一来一回线上故障时间直线拉长。2.4 陷阱四defer 用错以为预加载了其实没有PHP 8.0 给opcache_compile_file增加了一个OPCACHE_COMPILE_DEFER标志也就是延迟加载。初衷是好的不要一次性把所有类都塞进共享内存等到某个类第一次被真正用到的时候再加载从而降低 preload 的内存占用。但很多同学理解成了“加了 defer 就万事大吉可以放心全量预加载了”。这个理解是有问题的。defer 只是改变类注册的时机并没有改变 preload 的不可变和跨进程共享属性。你在脚本里对 1000 个文件加了 defer如果其中某些类在启动后立刻被高并发请求命中它们照样会在极短的时间内被全部加载进共享内存内存压力并不会减少多少。反过来如果类用了 defer 但整个请求生命周期里几乎没被用过那预加载它对性能毫无帮助还白白让 preload 脚本的扫描和编译过程多花时间。PHP 7.4 就更没得选那个版本根本没有 defer 这个标志只能全量加载。所以在 Laravel 项目上7.4 时代做 preload 尤其得克制目录范围。3. 从“代码不生效”到“进程内存雪崩”的完整排查链路3.1 症状确认不是所有慢都叫 preload 的问题那次内存告警我印象很深。先看监控面板PHP-FPM 的可用内存直线往下掉部分请求耗时从平时的 180ms 涨到了 2 秒网站还能打开但能明显感觉到卡顿。一开始我怀疑是不是 MySQL 慢查询暴增看了一遍慢查询日志没有异常又看了 Redis 的连接数也没问题。直到我注意到php-fpm.log里出现反复重启 worker 的记录才想到会不会是 PHP 层面的内存问题。判断 preload 是不是元凶的第一步是打开一个只输出 PHP 运行信息的探针脚本直接调用opcache_get_status()看preload_statistics段的memory_consumption、scripts数量、classes数量。如果memory_consumption已经占到opcache.memory_consumption的大半同时scripts数量明显超出你预期那基本可以确定是 preload 范围失控了。3.2 一步步定位从 RSS 到 preload 脚本里的具体文件确认方向后用命令逐层往下查。第一步看每个 worker 的真实内存ps -eo pid,ppid,rss,cmd | grep php-fpm | grep pool正常情况一个 Laravel worker 的 RSS 在 40~80MB 浮动我那次看到的是清一色 600MB 起步。RSS 异常高位结合 preload 统计信息基本坐实了内存不是被普通请求堆上去的而是被 preload 的类定义 COW 放大的。第二步通过pm.status_path看 PHP-FPM 的进程状态确认是否存在大量 worker 都处于 busy 但实际吞吐很低的情况curl http://127.0.0.1/fpm-status?plain看到active processes飙满而max children reached也频繁出现说明 worker 在内存压力下反复被杀死、重启请求自然越来越慢。第三步核验到底哪些文件被 preload 了。自己写一个临时页面输出 preload 的脚本清单或者直接在 CLI 里跑php -r var_export(opcache_get_status()[preload_statistics][scripts]);这时候问题就很直观了清单里能看到大量不属于业务核心路径的第三方类甚至能看到bootstrap/app.php这种根本不该出现在编译清单里的文件——有人把它混在 preload 脚本里 require 了。3.3 根因确认不只是“加载太多”而是“加载了不该加载的”继续查 preload 脚本本身。我们当时用的 preload.php 长这样问题非常典型$files scan_all_php_files(base_path(vendor)); foreach ($files as $file) { opcache_compile_file($file); } require base_path(bootstrap/app.php); // 这行引发了灾难opcache_compile_file本身是安全的它只负责编译不执行文件顶层逻辑。但后面那句require就直接把 Laravel 的整个启动流程在 preload 阶段跑了一遍。等于是把容器、服务提供者、数据库连接全在 master 进程启动时初始化了然后所有 worker 共享同一个“启动现场”。最终结论是两层问题叠加一是扫描范围太宽不加筛选地把所有 vendor 文件都编译进了 preload 区导致共享内存被塞爆二是那个多余的require把 Laravel 框架的启动副作用固化进了共享内存出现了容器状态跨请求共享的隐患。两条碰上一起内存告警和诡异逻辑问题同时爆发。3.4 应急处理先摘掉 preload恢复服务这种场景下的应急手段必须快速直接把 php.ini 里的opcache.preload配置注释掉然后重启 PHP-FPM。sed -i s/^opcache.preload.*/;opcache.preload/ /etc/php/7.4/fpm/php.ini systemctl restart php7.4-fpm重启后马上看内存和响应时间。我们当时重启完内存回落响应恢复正常基本就证实了 preload 是罪魁祸首。注意一点opcache.preload_user也要一并注释或改好否则重启后 PHP-FPM 可能因为 preload 脚本报错直接启动失败——这个坑在线上出过不止一次。4. 可落地的 Laravel Preload 配置目录清单、defer 用法和发布流程4.1 配置原则只预加载“定义阶段零副作用、调用频率极高”的类踩完坑之后我把 Laravel 项目里的 preload 推翻重做定了一条铁律不允许 require 任何业务代码不允许初始化任何服务容器只通过opcache_compile_file编译指定目录下的类文件。真正适合 preload 的类必须同时满足两个条件。第一这个类的文件加载阶段没有副作用——没有数据库连接、没有环境变量读取、没有容器绑定就只是纯粹定义类和方法。第二这个类在请求路径中的调用频率足够高——高到预加载它带来的收益能覆盖内存成本。按这个标准筛下来Laravel 项目里真正值得 preload 的目录其实很有限主要集中在框架底层的基础组件上。4.2 php.ini 里的配置比很多人想象中简单但容易漏preload 相关的 php.ini 配置就两个关键项opcache.preload/var/www/html/preload.php opcache.preload_userwww-dataopcache.preload指定 preload 脚本路径注意是绝对路径opcache.preload_user指定以哪个系统用户执行这个脚本。如果漏了preload_user而 PHP-FPM 的 master 进程恰好以 root 启动脚本执行时会直接报一个安全警告preload 不生效。还有一个容易忽略的点opcache.memory_consumption要留够余量。preload 区也是存储在opcache.memory_consumption配置出的这块共享内存里的。你 preload 越大留给运行时 OpCache 的空间就越小。实测下来Laravel 项目如果 preload 控制得当建议opcache.memory_consumption至少设成 256MB否则很容易出现“编译好了但塞不进共享内存”的尴尬。4.3 preload.php 的正确写法用清单别用递归全量扫写 preload 脚本最讲究的是“克制”。不要用递归扫描整个 vendor我最终采用的方案是手工维护一份类文件清单按目录分类只加载真正高频的框架基础类。?php // preload.php // 核心原则只编译不执行不 require 任何业务代码。 $preloadFiles [ // Laravel 基础组件请求生命周期里几乎每次都会用到 /var/www/html/vendor/laravel/framework/src/Illuminate/Collections/Collection.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Collections/Arr.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Support/Str.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Support/Stringable.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Container/Container.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Http/Request.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Http/Response.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Routing/Route.php, /var/www/html/vendor/laravel/framework/src/Illuminate/Routing/Router.php, ]; // PHP 8.0 建议用 OPCACHE_COMPILE_DEFER 降低内存峰值 foreach ($preloadFiles as $file) { if (is_file($file)) { opcache_compile_file($file); // PHP 8.0 可改为 // opcache_compile_file($file, OPCACHE_COMPILE_DEFER); } }这只是一个最小的示例。真实项目里清单可以做成一个独立文件放在服务器某个固定位置preload 脚本读取后批量处理。这样发布的时候只更新清单文件不用改 PHP 代码风险更小。4.4 为什么不能加载 ServiceProvider 及其实现类很多同学会觉得Laravel 的 ServiceProvider 在请求里注册频率很高为什么不 preload 它们原因很简单ServiceProvider 的实现类虽然文件加载时没有副作用但它的实例化、注册、boot 过程必须在每个请求的容器里执行否则它绑定的服务、监听的模型事件、注册的路由就乱套了。Preload 只能帮你省掉“类定义加载”这一步不能帮你省掉“服务注册”这一步。你把 ServiceProvider 的文件预加载了无非是让new SomeProvider($app)这行代码省了一次文件查找但整个 provider 的实例化和注册照样要跑收益极低风险却很高——因为这些类内部往往会触发config()、env()这类依赖请求上下文的调用。config/、routes/、app/目录下的文件我是一律不碰的。这里面的文件大量依赖env()、闭包和容器绑定属于请求期才能确定状态的东西preload 阶段一碰就出事。4.5 发布与回滚流程别让 preload 变成发布流程里的“盲盒”preload 配置的变更必须走和业务代码一样的发布流程而且要格外小心。我现在的做法是把 preload 配置写进发布脚本每次发版自动检查并执行#!/bin/bash # release.sh 片段 set -e cd /var/www/html git pull origin main composer install --no-dev --optimize-autoloader # 生成 Laravel 的配置与路由缓存 php artisan optimize # 校验 preload.php 语法 php -l /var/www/html/preload.php # 重启 PHP-FPM让 preload 真正生效 systemctl restart php7.4-fpm # 健康检查确认服务起来且 preload 统计正常 curl -fsS http://127.0.0.1/health-check || exit 1这里最关键的就是php -l和systemctl restart。php -l能提前发现 preload 脚本语法错误避免重启后服务起不来systemctl restart是让 preload 变更生效的唯一手段必须写进发布流程里否则上线等于没上。回滚策略也要准备好如果 preload 上线后出现异常先把 php.ini 里的 preload 配置注释掉再重启 PHP-FPM比回滚代码快得多。5. 实测数据与几条铁律什么场景真的需要 Preload5.1 实测Preload 在 Laravel 项目里的性能收益有多大为了不被“网上说能提升 30%”这种话术带节奏我自己在测试环境跑过一轮对比。机器配置是 4 核 8G 的虚拟机PHP 7.4.30 Laravel 8接口是一个典型的后台数据查询接口涉及 Eloquent 查询、Collection 处理、JSON 响应。用 wrk 压测200 个并发跑 30 秒结果如下场景吞吐量 (req/s)P95 延迟 (ms)worker 平均 RSS无 preload仅 OpCache8128265MBpreload 核心框架类8457889MBpreload 全量 vendor721104600MB数据很说明问题按正确姿势 preload 核心框架类提升大约 4%在测试误差范围内全量 preload 反而因为内存压力和 COW 导致性能下降。后面我又试了一个纯字符串处理密集的脚本比如对数组做大量collect()和Str::操作这时候 preload 的收益才明显一点大约能提升 12% 左右。说实话对于 Laravel 这类“请求时组装容器”的框架preload 不是一个性价比高的优化手段。瓶颈通常在数据库查询、跨服务调用、模板渲染上类加载的耗时占比并没有想象中那么高。反过来如果你用的是 Swoole、RoadRunner 这类常驻内存框架或者自己写 CLI 常驻脚本preload 的价值会更大因为类加载的开销会被放大在常驻进程的每次循环里。5.2 几条铁律照做能避开 90% 的 preload 坑第一新手先别碰 preload。先把 OpCache 的命中率调上去把php artisan optimize这类 Laravel 自带的优化手段用好性能通常已经够用。preload 是在这些手段都用尽之后才考虑的进阶方案。第二preload 脚本禁止有任何副作用。不 require 业务文件、不实例化容器、不连接数据库、不读取.env。脚本里只允许出现文件路径处理和opcache_compile_file调用。第三改 preload 配置必须走发布流程。不能直接在生产服务器上改 preload.php因为语法错误会导致 PHP-FPM 启动失败而且这个失败非常隐蔽容易误导排查方向。发布脚本里的php -l检查必须保留。第四监控preload_statistics。上线后通过opcache_get_status()[preload_statistics]定期观察memory_consumption和classes数量。如果发现内存持续走高、命中异常大概率是 preload 范围失控了。第五CLI 环境下注意 preload 的局限。php artisan tinker或者php artisan queue:work这类 CLI 命令默认不会加载 php-fpm 配置里的 preload 设置。如果你想让某个 CLI 常驻脚本也吃上 preload 的红利得在脚本入口手动放一个opcache_compile_file的逻辑或者单独给 CLI SAPI 配一份 php.ini。5.3 我的最终落地方案Laravel 反而关了 PreloadCLI 场景保留踩完这一圈坑之后我最终在生产环境的 Laravel Web 项目上把 preload 关了保留的是 OpCache JIT 的组合。原因很简单Laravel 的请求模型和 preload 的“跨请求共享”模型天然存在冲突为了那 4% 的吞吐提升去承担容器状态污染、发布不生效、内存放大的风险不值得。但在另一个 CLI 常驻任务项目里我保留了 preload而且效果很好。那个项目是一个基于 Swoole 的 WebSocket 服务进程常驻循环处理高并发消息。preload 了核心的消息解析和路由类之后内存占用平稳单进程吞吐提升了 15% 左右。原因是 Swoole 常驻进程里类的加载开销是持续存在的不像 PHP-FPM 每次请求结束后内存会释放省掉这部分开销的收益被放大了。所以我的最终体会是preload 本身不是银弹它在 PHP-FPM Laravel 这个组合里更像一把双刃剑用不好会伤到自己。如果你真的打算在 Laravel Web 项目里用务必控制范围、严守副作用底线并且把发布流程里的重启步骤写死。如果你有常驻进程的场景那 preload 值得认真投入研究收益会比传统 Web 场景明显得多。
