PHP 8.4 升级踩坑实录:QPS 从 1020 调到 1790,JIT 只贡献了 1.2%

🔑 关键词:PHP 8.4 升级, opcache 配置优化, PHP-FPM 调优, opcache.jit, Laravel Octane

📖 摘要:两周时间把一个 12 万行 Laravel 项目从 PHP 7.4 升到 8.4,跑了 27 组压测。8.4 默认配置下 QPS 反而比 7.4 低 18%,调完 opcache 后涨到 1790。附完整环境参数、版本横评数据和 4 个升级坑的排查过程。

先说结论,免得有人看到一半就跑了:PHP 8.0 到 8.4 这几代版本,对一个典型 Web 请求的性能提升加起来大概 30% 出头,但如果你 opcache 没配好,这 30% 能被吃掉一大半。 我花了两周把一个 12 万行的 Laravel 9 项目从 7.4 升到 8.4,中间跑了 27 组压测,最后出来的数字和我最开始的预期差得挺远,所以想完整记一笔。

图片

先交底测试环境,不然后面数字没法聊:

项目 配置
机器 阿里云 ecs.c7.2xlarge,8 vCPU / 16 GiB
CPU Intel Xeon Platinum 8369B @ 2.7GHz(lscpu 看的)
系统 Ubuntu 22.04.4 LTS,内核 5.15.0-105-generic
Web Nginx 1.24.0 + PHP-FPM
数据库 MySQL 8.0.36,同机房 RDS 8核32G,不占本机资源
缓存 Redis 7.2.4,本机
压测 wrk 4.2.0
固定命令 wrk -t8 -c256 -d60s --latency http://127.0.0.1/api/products?page=1&size=20

被压的接口返回 20 条商品,走 Redis 缓存,实测命中率 98.6%,MySQL 那边基本没参与,所以这组数字主要反映 PHP 和框架本身的开销,不是 IO 瓶颈。

坑一:7.4 到 8.0,花括号字符串偏移直接是语法错误

$str{0} 这种写法在 8.0 被彻底删掉了,注意是语法错误,不是弃用警告,文件直接编译不过。我扫的时候用的是:

grep -rn --include=*.php -E '\$[a-zA-Z_][a-zA-Z0-9_]*\{' app/ resources/views/ | wc -l

出来 37 处,大部分在 Blade 模板里,是当年为了省事写的 $row{0} 取首字母。同一批被删的还有 each(),我们有个 2016 年写的导出工具类还在用,改成了 foreach ($arr as $k => $v),顺手把那个文件其他几处也清了。

坑二:8.1 的隐式可空参数,213 处

图片

function foo(int $x = null) 在 8.1 会报 deprecated,必须写成 ?int $x = null。这个我们项目里真实存在 213 处,是 PHPStan 拉到 level 5 之后跑出来的,但 PHPStan 抓不到反射调用和 call_user_func 那部分,我是靠 error_reporting=E_ALL 跑了一整轮集成测试的日志才补全的,最后又改了 19 处。

坑三:8.2 动态属性,最难缠的一个

Creation of dynamic property X::$y is deprecated。Laravel 的 Eloquent 模型自己不受影响,因为它走 __set,但我们自己写的 DTO、Excel 导入用的临时对象、还有几个从接口 JSON 直接映射的类,全是直接赋值。

我最后的处理方式分三类:能从构造函数传的补属性声明;框架基类挂了 #[\AllowDynamicProperties];Excel 那几个类干脆加了 __set 并写进 $_extra 数组里。三类加起来动了 60 多个文件。

坑四:8.4 把 E_STRICT 删了

这个坑比较阴。一个第三方支付 SDK 里写着:

error_reporting(E_ALL | E_STRICT);

8.4 里 E_STRICT 这个常量没了,直接 Undefined constant 致命错误。那个包 Packagist 上最后一次更新是 2019 年 11 月,作者早就不维护了,最后只能 fork 一份到公司私有仓库改掉这一行。同类问题还有 MYSQLI_ASYNC 和一批 MYSQLI_REFRESH_* 常量,只是 deprecated 不致命,但 CI 开了 E_ALL 会刷屏。

图片

第一次压测:QPS 反而不如 7.4

升级完第一时间跑压测,结果很打脸。

老机器跑的是 PHP 7.4.33,opcache 是几年前调过的:opcache.memory_consumption=512、opcache.max_accelerated_files=32531、opcache.validate_timestamps=0、opcache.interned_strings_buffer=64,QPS 稳定在 1240。

新机器装的是 PHP 8.4.1,用的全是默认值:128M / 10000 / 1 / 8。第一次压测出来 1020,比 7.4 还低 18%。

原因不复杂:opcache.max_accelerated_files 默认 10000,而我们这个项目 composer 的 classmap 加上框架文件一共 2 万多个 PHP 文件,缓存装不下,一直在淘汰和重编译。opcache.validate_timestamps 还用默认的 1,每个请求都在 stat 文件,256 并发下这个开销不小。

把新机器的 opcache 配置对齐之后,QPS 直接跳到 1790。

版本横评的真实数据

opcache 配置全部对齐(512M / 32531 / 0 / 64),PHP-FPM 用 pm=static,max_children=64:

图片

PHP 版本 QPS P99 延迟
7.4.33 1240 231ms
8.1.27 1608 178ms
8.3.14 1745 161ms
8.4.1 1790 154ms

从 7.4 到 8.4 是 +44%,比官方说的「8.0 提升约 20%」逐代累积起来略高一点,可能跟我们代码里对象多、字符串拼接多有关。8.3 到 8.4 只有 2.6%,说实话没什么感知。

JIT:别抱期待,除非你跑的是 CLI

我也试了 JIT,opcache.jit=tracing,opcache.jit_buffer_size=128M。

Web 接口这边,1790 到 1812,涨了 1.2%,基本在噪声范围内。因为 JIT 的 tracing 编译器主要吃的是循环和算术密集的 opcode,而一个典型 Web 请求大部分时间花在 IO 等待、数组操作、字符串拼接上,这些不是 JIT 的主场。

但我顺手测了个 CLI 脚本:用 GD 处理 500 张 1200x900 商品图(缩放 + 水印 + 转 WebP)的批处理。

  • 不开 JIT:42.3 秒
  • 开 JIT:31.1 秒

降了 26.5%。这个提升是实打实的,而且 CPU 占用也从 98% 降到了 76%。

图片

所以我的观点很明确:Web 服务别开 JIT,白占内存;CLI 批处理、图像处理、数值计算这类脚本可以开,收益明显。

PHP-FPM 的 max_children,很多人设错了方向

这块我测了 4 组,其他配置固定:

配置 QPS
dynamic,max_children=25 1622
dynamic,max_children=64 1790
static,max_children=64 1834
static,max_children=64,max_requests=0 1841

先说单进程常驻内存:PHP 8.4 下我们的应用跑起来 RSS 大约 36MB,7.4 那会儿是 43MB,8.x 的对象结构优化确实省了不少。

网上一堆教程教你算:可用内存 除以 单进程内存 = max_children。16G 减掉系统和 Nginx、Redis 预留 2G,14G 除以 36MB 约等于 398。你要真设 398,8 核机器上光进程上下文切换就能把 CPU 打满,QPS 反而会崩。

我的经验是以 CPU 核数为准,取 4 到 8 倍:8 核就是 32 到 64。真正决定上限的是 CPU,不是内存。

另一个点是 pm.max_requests。网上很流行设 500,我没设,因为压测里能看到很明显的周期性毛刺——每次一批 worker 到达 500 请求集体重启,P99 会突然冒到 700ms 以上。我们的代码里没发现明显内存泄漏(跑了 24 小时 RSS 从 36MB 涨到 41MB 就稳住了),所以设成 0 不限制,P99 反而更平。

图片

关于 Laravel Octane,说句不讨喜的话

我也试了 Octane + RoadRunner,QPS 确实能到 3420,是 FPM 的将近两倍,数据很好看。

但有两个成本很多人没算。第一是内存,每个 RoadRunner worker 常驻 180MB 左右,8 个 worker 就是 1.44GB,而且这部分内存是常驻不回收的,机器上还要跑 Redis 和 Nginx。第二是状态污染,这个是真会出事的。我们压测时发现有个接口偶尔会返回别人的购物车数据,排查了半天,是某个同事把一个 Request 相关的东西存进了单例里——在 FPM 下每个请求进程独立,这个 bug 永远不会暴露;在 Octane 下 worker 复用,第 300 个请求读到了第 297 个请求留下的数据。

如果你们团队没有能力做全量的单例审查和状态清理,我建议先别上 Octane。把 opcache 和 FPM 调好,性价比高得多,而且风险是零。

最后总结一下我的判断

第一,先调 opcache,再考虑升级版本。我这次最大的教训就是新环境用默认配置,白白浪费了两周还差点得出「PHP 8.4 不如 7.4」的荒谬结论。生产环境固定要设的就四个:opcache.memory_consumption 至少 256、opcache.max_accelerated_files 按项目实际文件数往上取(我们 2.3 万文件设的 32531)、opcache.validate_timestamps=0(记得部署时 reload fpm)、opcache.interned_strings_buffer=64。

第二,JIT 不是银弹,Web 场景基本没收益,CLI 场景值得开。

第三,跨大版本升级的真正成本不在性能,在兼容性。这次两周里有 12 天在改代码,只有 2 天在压测调优。如果你还在 7.4,升到 8.1 或者 8.3 是稳赚的;8.3 到 8.4 建议等半年,等生态跟上再升,收益只有 2% 出头,风险却要自己扛。

🏷️ 标签: