起因:一个 QPS 卡在 3000 的订单接口
去年底帮朋友看他们跨境电商的价格计算接口。技术栈很普通:PHP 8.2 + Laravel 10 + MySQL 8.0.32 + Redis 7.0,一台 4 核 8G 的 ECS,前面挂 Nginx。日常 800 QPS 跑得挺好,大促冲到 3000 左右就开始超时,P99 从 40ms 一路飙到 900ms,客服那边投诉页面一直转圈。
老板第一反应是换 Go,理由是“Go 并发高”。我没接这话,因为顺手看了眼代码:一个请求打 7 次 Redis、3 次 MySQL,其中两次 MySQL 是在 for 循环里查的,经典 N+1。这种写法换成汇编也快不到哪去。不过既然人家有这个心气,我说那就正经压一次,拿真实业务逻辑压,不用 hello world。测完的数据我觉得比网上那些“PHP 已死”“Go 吊打一切”的帖子靠谱,所以整理出来。
顺便说明一下我的立场:我不是 PHP 吹也不是 Go 黑。我自己的主力语言是 PHP,但这两年也写了大概一万多行 Go,两个语言的坑我都踩过,所以尽量说人话。
测试环境和方法,不藏参数
环境:Ubuntu 22.04,内核 5.15,4C8G。MySQL 和 Redis 都塞在同一台机器上,我知道这不真实,但目的是排除网络抖动这个变量,只看语言和运行时本身的差异。
各版本配置:
| 方案 | 版本/配置 | 并发模型 |
|---|---|---|
| PHP-FPM | PHP 8.2.18,opcache 开,JIT 关,pm=static,max_children=24 | 每请求一进程 |
| PHP-FPM + JIT | 同上,opcache.jit=1255,jit_buffer_size=128M | 每请求一进程 |
| Swoole | Swoole 5.0.2,worker_num=16,task_worker_num=4,协程开 | 常驻协程 |
| Go | Go 1.21.6,net/http + sqlx,GOMAXPROCS=4,连接池 20/20 | goroutine |
对应的 opcache 配置长这样,数值都是实测能生效的:
opcache.enable=1
opcache.jit=1255
opcache.jit_buffer_size=128M
opcache.validate_timestamps=0
opcache.memory_consumption=256
压测用 wrk(版本 4.2.0),命令是 wrk -t4 -c200 -d60s --latency http://127.0.0.1:8080/api/price?id=10086。每组跑三次取中位数,每次跑之前重启进程、清空 Redis 热键缓存,模拟缓存全冷启动的极端情况。接口逻辑是:根据 SKU ID 读 Redis 缓存,miss 就查库,然后算会员折扣 + 阶梯价 + 优惠券叠加,最后返回大概 1.2KB 的 JSON。
数据:JIT 在 Web 接口上几乎是零收益
| 方案 | QPS | P50 | P99 | 常驻内存 |
|---|---|---|---|---|
| PHP-FPM(JIT 关) | 2980 | 31ms | 51ms | 24 × 42MB ≈ 1.0GB |
| PHP-FPM(JIT=1255) | 3010 | 30ms | 50ms | 24 × 45MB ≈ 1.08GB |
| Swoole 常驻 | 7400 | 14ms | 22ms | 210MB |
| Go | 8150 | 12ms | 18ms | 65MB |
JIT 开了跟没开差 1%,我自己都不太信,重跑了三遍,确实就这样。原因其实不复杂:JIT 编译是有成本的,它得先跑到某个热点阈值才触发编译,而 PHP-FPM 下一个请求的生命周期只有几十毫秒,进程处理完就回收了,热代码根本没机会被编译几次。JIT 省下来的那点 CPU,被它自己的编译开销吃掉了。
但是——这里有个但是。我把接口里的业务逻辑单独拎出来,换成纯计算:10 万次 bcadd 大数运算加 5000 次 HMAC-SHA256 签名,同一个 PHP 8.2 二进制,结果完全反过来:
| 场景(纯计算) | 耗时 |
|---|---|
| PHP 8.2,JIT 关 | 1.42s |
| PHP 8.2,JIT=1255 | 0.51s |
| Swoole 5.0.2 | 0.49s |
| Go 1.21.6 | 0.19s |
JIT 在这个场景下提速 2.8 倍,跟 PHP 官方 RFC 里说的 1.5 到 3 倍是对得上的。所以结论不是“JIT 没用”,而是“JIT 只在你一次请求里跑够多循环的时候才有用”。像导出 Excel、生成对账单、跑报表、图像处理这种活,开 JIT 是真香;像普通 CRUD 接口,开不开随你。
翻车记录一:我的 JIT 其实第一次根本没开
第一次测的时候,我改完 php.ini 重启了 php-fpm,压出来 QPS 还是 2980,我当时以为是 JIT 就是没效果,差点就这么下结论了。后来随手跑了一句检查:
var_dump(opcache_get_status()['jit']);
返回的 enabled 是 false。原因是 opcache.jit_buffer_size 默认是 0,你不显式给个非零值,JIT 就算配了 jit=1255 也不会启用。这个坑非常常见,网上大部分教你开 JIT 的文章都只写了 opcache.jit=1255,把 buffer_size 那行漏了。
顺带另一个坑:我为了性能把 opcache.validate_timestamps=0 打开之后,改代码不生效了,debug 了快半小时才反应过来要 reload php-fpm。生产环境这么配没问题,但本地开发千万别照抄。
翻车记录二:Swoole 的内存涨到 1.3G
Swoole 那组第一次跑长稳测试的时候,跑了 6 个小时内存从 180MB 涨到了 1.3G,吓得我以为是 Swoole 本身泄漏。查了半天发现是自己的锅:代码里有个静态数组,把商品 ID 到分类的映射缓存进去,本来想减少查询,结果 key 是无限的,跑一天下来几十万个 key 全堆在内存里。
改成 LRU 上限 2000 条加定时清理之后,内存稳定在 210MB 左右。这件事说明常驻内存模式最大的代价不是性能,是状态。PHP-FPM 模式下你随便写全局变量、静态变量,请求结束全清空,永远不会出事;常驻模式下这些东西会一直活着,任何一个不小心就是内存泄漏。而且常驻之后 xdebug 基本没法用了,hot reload 也得自己搭,团队里如果新人多,这些隐形成本一定要算进去。
我的观点:常驻内存省下来的到底是什么
很多人把 Swoole 的 2.5 倍提升理解成“语法变快了”,其实不对。PHP 代码本身一行没快,快的是这几件事:框架 bootstrap 被省掉了(Laravel 一次 bootstrap 大概 25 到 40ms)、每次请求不用重新建立 PDO 连接、不用重新加载几十个文件、不用重新走一遍路由注册。
也就是说,你花 2.5 倍的复杂度换来的,是“状态复用”。这个收益的大小,跟你的框架越重、连接建立越贵、依赖容器越复杂,成正比。反过来,如果你用的是原生 PHP 写的轻量接口,bootstrap 只要 2ms,那 Swoole 给你带来的提升可能就只有 20%,这时候上常驻纯属自找麻烦。
Go 比 Swoole 又快了大概 10%,但我觉得这 10% 不该是你选 Go 的理由。Go 真正的优势在于部署:编译出来一个二进制,扔服务器上就跑,没有 php-fpm、没有 composer、没有扩展版本对不上;还有并发模型,goroutine 加 channel 写扇出调用确实比回调舒服。劣势也很实在,写一个 Excel 导出我给 Go 找了三天库,最后发现社区那个 excelize 的 API 得写两百行;PHP 那边 composer require phpoffice/phpspreadsheet 一行搞定。生态这东西,平时不觉得,关键时刻真要命。
如果你也在纠结要不要换,按这个顺序来
第一件事,先把 opcache 该开的开了(生产环境 validate_timestamps=0),再打开 MySQL 的慢查询日志,long_query_time 设成 0.1,跑一天看看排名前几的 SQL。我敢说,十个说自己接口慢的项目,有七个问题出在 N+1 或者缺索引上,跟语言一行关系都没有。
第二件事,搞清楚你的时间花在哪。用 php-fpm 的 request_slowlog_timeout = 3s 加 slowlog 抓几次慢请求,看看到底是在等下游还是在自己算。如果 P99 里有超过 30% 是 bootstrap 加建连,那你适合上常驻,可以先用 FrankenPHP 的 worker 模式试,它是 Caddy 打包的,改动比 Swoole 小得多;如果超过 40% 是在等下游 IO,先去修 SQL 和缓存,别急着换语言。
第三件事,真要换语言,先拿一个边缘服务试水,别动核心。而且换之前想清楚:你的团队里有没有人能在凌晨三点起来看 Go 的 pprof?这个问题比 QPS 重要得多。
我到现在还是留在 PHP,不是因为性能,是因为生态和熟悉度能让我少加班。性能这件事,到了 3000 QPS 这个量级,加一台机器比重构一个月便宜多了——除非你的瓶颈真的不在数据库。