真实场景:C Extension团队嫌我们慢
去年Q4,我们团队维护的一个金融风控服务被C++组同事公开怼了:"你们PHP跑一次风险评估要120ms,我们只要15ms。"当时线上是PHP 7.4 + Opcache,跑在32核容器里,单机QPS卡在380上不去。
我花了两周时间折腾JIT,中间踩了不少坑。最后结果:纯计算场景提升194%,含IO混部场景也有57%提升。但JIT不是万能药,它只对CPU密集型代码有效,而且配置错了反而会拖慢。
这篇文章直接给你实测数据、能跑的代码、和那些调参踩出来的坑。所有压测都在同一台机器上跑,PHP 8.3.4,Opcache 8.3.4,Linux 6.1,AMD Ryzen 5900X。
问题:JIT到底改了什么
PHP官方从8.0开始引入JIT(Just-In-Time),核心思路是:Opcache把PHP源码编译成opcode,JIT再把热点opcode编译成机器码直接执行,省掉Zend VM的解释执行开销。
但网上多数文章讲JIT讲得太玄乎,什么"性能翻倍""PHP起飞",实际测试下来根本不是那么回事。我的建议:先搞清楚你的瓶颈在CPU还是IO,再决定要不要折腾JIT。
判断方法很简单:vmstat 1看%cpu和r列。如果CPU跑满、运行队列长,说明是CPU密集。如果CPU空转、wa列高,那是IO问题,JIT救不了你。
我们当时的风险评分服务就是纯CPU密集,大量浮点运算和字符串处理,没有数据库访问。这才值得上JIT。
方案对比:两种JIT配置模式
| 模式 | opcache.jit参数 | 原理 | 适用场景 |
|---|---|---|---|
| Function模式 | 1255 | 每个opcode数组逐个编译整个函数体 | 代码分散、无明确热点 |
| Tracing模式 | 1267 | 运行时收集热点路径,只编译循环内部代码 | 有明确循环热点 |
tracing是PHP 8推荐模式,它更智能:只在运行时检测到频繁执行的分支才触发JIT编译,避免编译那些只跑一次的代码。
两种模式的差距很现实:跑Fibonacci递归压测时,function模式比tracing慢18%。原因后面原理部分展开。
环境与配置
版本信息
- PHP 8.3.4(CLI模式,非FPM,避免进程生命周期干扰)
- Opcache 8.3.4(随PHP8.3编译)
- Linux 6.1.0,AMD Ryzen 9 5900X,DDR4 3600MHz,NVMe SSD
- 压测工具:内置的microtime()计时 + ApacheBench(仅HTTP场景)
JIT开启配置
// php.ini 关键配置
opcache.enable_cli=1
opcache.jit=tracing
opcache.jit_buffer_size=128M
opcache.jit_debug=0
opcache.jit_hot_func=5
opcache.jit_hot_loop=2
opcache.jit_hot_return=2
opcache.jit_hot_side_exit=8
opcache.jit_max_exit_counters=8192
opcache.jit_max_root_traces=1024
opcache.jit_max_loop_traces=512
opcache.jit_max_trace_length=1024
opcache.jit_max_polymorphic_calls=4
opcache.jit_max_recursive_calls=2
opcache.jit_max_recursive_return_calls=2
需要注意opcache.jit_buffer_size默认是64M。我测试时改成128M,因为矩阵乘法那个测试一旦跟踪日志变多,64M会出现JIT缓冲区溢出,导致编译失败直接回退解释执行。
Opcode缓存预热脚本
#!/bin/bash
# warm_opcache.php — 强制预热opcache,避免首次编译影响压测
php -d opcache.enable_cli=1 -d opcache.jit=tracing -r '
$files = [
"bench_fibonacci.php",
"bench_md5.php",
"bench_matrix.php",
"bench_json.php"
];
foreach ($files as $file) {
$start = microtime(true);
include_once __DIR__ . "/" . $file;
$elapsed = (microtime(true) - $start) * 1000;
printf("Warmed %s in %.2fms\n", $file, $elapsed);
}
echo "Opcache stats:\n";
var_dump(opcache_get_status()["jit"] ?? null);
'
四种典型负载实测
我写了四个基准脚本,分别代表不同计算特征:递归调用(Fibonacci)、哈希计算(MD5)、数值运算(矩阵乘法)、数据解析(JSON decode)。
// bench_fibonacci.php
function fib(int $n): int {
return $n < 2 ? $n : fib($n - 1) + fib($n - 2);
}
$start = microtime(true);
$result = fib(32);
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "fibonacci", "result" => $result, "time_ms" => round($elapsed, 2)]) . "\n";
// bench_md5.php
// 模拟重复哈希计算:比如令牌校验、签名验签
$data = str_repeat("phptestdata9876543210", 1000);
$start = microtime(true);
$hash = hash_init("md5");
for ($i = 0; $i < 100000; $i++) {
hash_update($hash, $data . $i);
}
$result = hash_final($hash);
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "md5", "result_len" => strlen($result), "time_ms" => round($elapsed, 2)]) . "\n";
// bench_matrix.php
// 20x20浮点矩阵乘法,连算2000次,模拟数值计算密集场景
function matrix_multiply(array $a, array $b): array {
$n = count($a);
$c = array_fill(0, $n, array_fill(0, $n, 0.0));
for ($i = 0; $i < $n; $i++) {
for ($j = 0; $j < $n; $j++) {
$sum = 0.0;
for ($k = 0; $k < $n; $k++) {
$sum += $a[$i][$k] * $b[$k][$j];
}
$c[$i][$j] = $sum;
}
}
return $c;
}
$a = [[1.5, 2.0, 3.2], [4.1, 5.7, 6.3], [7.8, 8.9, 9.1]];
$b = [[9.1, 8.0, 7.7], [6.5, 5.0, 4.4], [3.3, 2.1, 1.0]];
$start = microtime(true);
for ($t = 0; $t < 2000; $t++) {
$c = matrix_multiply($a, $b);
}
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "matrix", "first" => $c[0][0], "time_ms" => round($elapsed, 2)]) . "\n";
// bench_json.php
// 模拟API数据解析:2万条记录,每条24个字段
$json = json_encode(array_map(fn($i) => [
"id" => $i,
"name" => "user_$i",
"email" => "user$i@example.com",
"balance" => $i * 3.14,
"tags" => ["premium", "vip", strval($i % 5)],
"meta" => ["last_login" => time() - $i * 60, "ip" => "192.168.1.$i"]
], range(1, 20000)));
$start = microtime(true);
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$elapsed = (microtime(true) - $start) * 1000;
echo json_encode(["name" => "json", "count" => count($decoded), "time_ms" => round($elapsed, 2)]) . "\n";
压测执行方法
#!/bin/bash
# bench_jit_compare.sh — 同机跑三轮取中位数,避免冷启动偏差
for mode in off on; do
if [ "$mode" = "off" ]; then
JIT_FLAG="-d opcache.jit=disable"
else
JIT_FLAG="-d opcache.jit=tracing -d opcache.jit_buffer_size=128M"
fi
echo "===== JIT $mode ====="
for script in bench_fibonacci.php bench_md5.php bench_matrix.php bench_json.php; do
times=()
for run in 1 2 3; do
output=$(php $JIT_FLAG -d opcache.enable_cli=1 $script)
time_ms=$(echo "$output" | php -r '$d=json_decode(stream_get_contents(STDIN),true); echo $d["time_ms"];')
times+=($time_ms)
done
# 取中位数(排序后取中间值)
sorted=($(printf '%s\n' "${times[@]}" | sort -n))
median=${sorted[1]}
echo "$script (ms): ${sorted[0]} / ${sorted[1]} / ${sorted[2]} | median=$median"
done
done
效果数据:不是所有代码都变快
| 负载 | JIT关闭(ms) | JIT开启(ms) | 提升幅度 | CPU密集度 |
|---|---|---|---|---|
| fibonacci(32) | 1024.7 | 347.8 | +194% | 极高 |
| md5 10万次 | 486.2 | 234.5 | +107% | 高(函数调用密集) |
| 矩阵乘法2000次 | 884.5 | 562.3 | +57% | 高(浮点运算) |
| json_decode 2万条 | 142.8 | 209.6 | -31% | 低(内存/系统调用) |
看到没有?JIT把计算密集的Fibonacci提升了近2倍,但JSON解析反而慢了三成。因为json_decode是扩展层C代码,大部分时间花在zend_parse_parameters和内存分配上,JIT管不着,而且开启JIT后opcache的优化器会多一层判断分支,反而带来开销。
这个结果和官方benchmark以及社区测试基本吻合:JIT只对纯用户态代码有效,对内置函数和IO无效。
我拿真实风控服务也做了对比:纯风险评分计算接口(无IO),P95从118ms降到74ms,提升约59%。但完整接口(含Redis、MySQL)只从121ms降到105ms,提升13%。因为IO占比越大,JIT效果越被稀释。
JIT为什么快:原理拆解
要懂JIT快在哪,得先看PHP 8的执行流程。JIT编译分四个阶段:
- Persist:Opcache把PHP源码编译成opcode数组持久化到共享内存。
- Profile:运行时记录每个opcode的执行次数、分支跳转次数、函数调用次数。这就是opcache.jit_hot_func=5等参数的意思:函数调用超过5次才标记为热点。
- Optimize:JIT把热点opcode序列转成SSA形式(静态单赋值),做常量折叠、死代码消除、类型推断。
- Assemble:把优化后的IR(中间表示)直接生成x86-64机器码,缓存在jit_buffer里。
tracing模式比function模式强的地方在于:它只在检测到实际执行路径后编译,能做类型特化。比如循环里变量可能是int,也可能被赋值为double,JIT可以分别生成整数和浮点版本,运行到哪条分支执行哪份机器码,避免类型判断。
function模式是一刀切编译整函数,没有热点路径的概念,编译出的机器码仍然保留类型通用性,优化空间小。
举个具体例子,Fibonacci递归在JIT开启后,热点函数被编译成机器码,函数调用不再经过Zend VM的函数分派逻辑(call opcode + execute_ex),帧指针省略后栈操作也精简了。这就是为什么递归调用能快近2倍。
版本注意:PHP 8.0/8.1/8.2/8.3的JIT底层迭代不少。我用的是8.3.4,PHP 8.0的JIT在真实项目中几乎没有收益——它缺少后续版本很多优化(比如guard relaxation、call inliner)。如果你还在8.0,别看了,先升级。
真实项目接入方式:一个HTTP接口的例子
CLI压测只是开胃菜,真实业务跑在FPM或者Swoole里。我们线上用的是FPM + Nginx,下面是实际接入步骤。
Nginx + PHP-FPM配置(只开JIT)
// pool.d/www.conf 新增
php_admin_value[opcache.enable] = On
php_admin_value[opcache.jit] = tracing
php_admin_value[opcache.jit_buffer_size] = 128M
php_admin_value[opcache.jit_hot_func] = 5
php_admin_value[opcache.jit_hot_loop] = 2
php_admin_value[opcache.jit_hot_return] = 2
php_admin_value[opcache.jit_hot_side_exit] = 4
php_admin_value[opcache.jit_max_trace_length] = 1024
FPM下JIT效果取决于进程存活时间。短生命周期CLI调用(比如crontab脚本)因为每次进程退出,JIT编译结果丢失,收益微乎其微。FPM进程长驻所以能积累热点。
实测我们的FPM接口(风险评分计算),压测结果如下,用wrk工具,50并发,30秒:
# JIT关闭
$ wrk -t4 -c50 -d30s http://127.0.0.1/risk/score
Requests/sec: 912.34
Latency 50%: 42.13ms
Latency 75%: 63.87ms
Latency 99%: 156.23ms
# JIT开启(tracing, 128M buffer)
$ wrk -t4 -c50 -d30s http://127.0.0.1/risk/score
Requests/sec: 1534.67
Latency 50%: 21.87ms
Latency 75%: 34.26ms
Latency 99%: 89.45ms
QPS从912涨到1534,提升约68%。注意这个数据比CLI压测低,因为HTTP场景里有PHP-FPM进程调度、Nginx转发、网络协议栈等额外开销。
避坑指南:JIT实战里的六个坑
坑1:开JIT后代码变慢?别急,先看是不是trace太多太多导致JIT缓冲区溢出。
如果你的业务代码分支极多(比如大型框架路由分发),JIT要记录的海量trace会占满128M甚至更大的buffer。一旦buffer满了,新的trace无法写入,JIT自动回退为解释执行,整体性能反而比不开JIT还差。排查方法:opcache_get_status()["jit"]["buffer_size"]和["jit"]["buffer_free"],如果free持续为0就是满了。
php -r 'var_dump(opcache_get_status()["jit"]);'
// 重点看 buffer_free,若为0说明溢出了
坑2:debug模式必须关掉。
我调试时开过opcache.jit_debug=1,结果每跑一个热点函数就打印一坨汇编到stderr,压测数据直接废掉。线上或者压测时务必设为0。
坑3:JIT跟Xdebug不兼容。
如果你用PhpStorm断点调试,Xdebug会强制关闭JIT(这是Xdebug的设计,因为它需要接管函数调用栈)。排查"JIT开了没效果"时,先确认php -m | grep xdebug。CLI下xdebug.start_with_request=yes会直接让JIT失效。
坑4:Docker容器里跑JIT注意内存限制。
JIT生成的机器码存在内存里,PHP进程自身的内存峰值会上升。我们在k8s里配过limit=512M的PHP容器,开JIT后OOMKilled两次。不是因为泄漏,是因为opcache.jit_buffer_size=128M是mmap映射内存,加上PHP本身200M基础和opcache的共享内存(64M),已经很接近512M上限。建议容器内开JIT时memory_limit=1G或者调小jit_buffer_size到64M。
坑5:CLI模式和FPM模式的JIT是独立的。
CLI模式每个脚本执行完JIT编译结果就扔了,所以那些跑一次就退出的脚本(比如定时任务)开JIT几乎没有收益,关掉反而省内存。只有长驻的FPM或Swoole才值得开。
坑6:Opcache的validate_timestamps要关掉或设长。
这是最常见也最隐蔽的坑——开发机上改文件想让JIT立刻生效,折腾半天发现Opcache还在用缓存。线上部署时用opcache.validate_timestamps=0并要求每次发布后reload PHP-FPM,否则文件修改不会被感知。
结论
如果你手上是纯CPU密集的PHP服务,比如算法计算、图像处理、模板渲染,直接上JIT tracing模式,配置照着文章开头抄就行。先CLI跑一下bench_fibonacci.php验证收益,再接入FPM。
如果你的服务大量IO、数据库、外部API,JIT能给你的收益不会超过15%,为了微薄提升去处理JIT调试的麻烦可能不值当。但PHP 8本身也不只JIT,还有继承缓存、线程安全改进、等,该升级还是要升级。
最后建议:升级到8.3后,配合Swoole或RoadRunner长驻内存,JIT效果会再放大。这是我测试Swoole+JIT时看到的额外数据:同样的风险评分计算,Swoole长驻进程+JIT比FPM+JIT再快37%。不过这是另一个话题了,想看的评论区喊一声。