大概半年前,有个朋友找我做一个电商API,要求单机抗住2万QPS。他直接说:“用Go吧,PHP不行的。”但我手上有一套完整的PHP库存系统,重写成本太高,而且我相信PHP在合适的架构下也能干这活。结果呢?压测数据出来后,他闭嘴了。但这个过程,真不是光装个Swoole就完事的,坑多得我差点想放弃。
先别急着拿PHP-FPM来对比。PHP-FPM是经典的请求-响应模型,每个请求独占一个PHP进程,请求完了进程就释放。我测试过,一个PHP进程大概占30-50MB内存(PHP8.1下),如果要用100个进程扛住500并发,光进程内存就是3-5GB。而且进程切换有开销,CPU一高就频繁上下文切换。我之前的项目在FPM下压测,2核4G的服务器,500并发延迟已经到800ms以上,错误率5%。这确实不漂亮。
然后我看Swoole。Swoole是一个常驻内存的框架,核心是worker进程和协程。Worker进程可以复用一个PHP上下文,不用每次请求重新加载,内存占用大幅下降。我现在的服务器是8核16G,用的是Swoole5.0,worker_num设为8(等于CPU核数),再加上协程,每个worker可以处理上万个并发连接。压测结果:在开启协程和连接池的情况下,2万QPS(HTTP请求)轻松达成,P95延迟只有80ms,内存峰值也就2.2GB。这个数字,我听Go的朋友说他那边也就这样。
下面是我具体的配置步骤,也许能给你参考。
第一步:安装Swoole扩展。我用的是PHP8.1,通过pecl安装,命令是pecl install swoole,版本5.0.1。注意要开启--enable-openssl和--enable-http2,因为我的API要支持HTTP2和TLS。
第二步:配置worker_num。这个数不是越大越好,我试过16,结果CPU切换太多,性能反而掉了15%。后来我用公式worker_num = 可用核数 * 2,但只开到了8,因为还要留两个核给系统和其他进程。内存方面,每个worker给256MB内存限制,我的项目是电商业务,所以使用了大量缓存。
第三步:协程和连接池。Swoole的Coroutine是让IO异步化,但一定要配合连接池,否则每次请求创建连接,照样会拖垮MySQL。我用了swoole/connection-pool这个库,Pool设置size为CPU核数*3,也就是24,最大连接数50。然后MySQL的max_connections调到了100,因为我服务器上还有其他服务。
第四步:压测。我用wrk压测,命令是wrk -t8 -c1000 -d60s --latency http://localhost/api/list。一开始发现p99非常高,后来发现是PHP的mt_rand有锁竞争,我换成了random_int。另外,日志写文件也是瓶颈,我把日志改成了异步写入,用Swoole的Coroutine Channel。
最坑的是协程下的状态污染。我有个全局变量存储用户ID,在请求间共享,结果导致A用户看到了B用户的数据。排查了很久,最后用Swoole的Context来存请求级别的数据,问题解决。另外,Swoole的HTTP2推送,如果处理不好会带来内存泄漏,我加了定时器定期清理推送队列。
说实话,PHP确实有很多反人类的地方,比如类型弱、老代码没法跑在Swoole上。但如果你只是想写一个API网关,或者一个中台服务,用PHP+Swoole是完全可以的。而且PHP的生态太丰富了,我要接支付宝、微信支付,全是PHP的SDK,这是我坚持用PHP的另一个原因。
不过别误会,我并不觉得Swoole能超过Go或者Java。Go的goroutine比Swoole协程更底层的可控,而且编译型语言在CPU密集任务上就是有优势。但PHP是解释型,开发效率高,尤其在业务逻辑频繁变动的环境下,PHP仍然是无可替代的。我的结论是:别盲目追新,也别一棒子打死旧技术。在你的场景下,用你熟悉的工具,把架构做对,才是正道。