一次把我逼到加班的线上事故
凌晨1点40分,监控告警把我从床上炸起来。某Laravel项目的订单回调接口,耗时从平均180ms直接飙升到7.3s,CPU跑满8核,PHP-FPM的max_children已经顶到512,还在疯狂fork新进程。
我tail日志看到的是:WARNING: [pool www] seems busy (you may need to increase pm.start_servers)。
那晚我临时改了nginx的fastcgi_read_timeout,把队列消费者暂时切到supervisor重启,勉强撑过去。但这事情没完——第二天我拉了全量access log,这个接口的QPS峰值只有300左右,理论上FPM不该崩。问题出在哪?慢查询 + FPM的阻塞模型,每个请求要等下游HTTP响应才释放进程。下游服务只要抖一下,FPM进程池就全被占住。
之后我在测试环境把同样一套业务分别跑在PHP-FPM、Swoole、RoadRunner上做了压测。这篇文章就是那次对比的完整记录,结论只对这次的接口和硬件有效,但方法论你可以直接抄。
三种运行模式的核心差异
PHP-FPM 8.3:进程池 + 请求周期生命周期
FPM是PHP官方标配,外部请求通过FastCGI协议转发给FPM,FPM从进程池里取一个空闲worker处理请求。请求结束,worker复位,所有静态变量、连接、内存全部释放。
代价也在这:每个请求都要重新bootstrap框架(Laravel启动约60-80ms,Symfony差不多),重新创建PDO连接,重新加载opcache之前的编译产物。我们的Laravel项目一个请求空跑(仅路由+空控制器)就需要消耗38MB内存,290ms(含框架初始化)。
# php-fpm 8.3 关键配置
pm = dynamic
pm.max_children = 150
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
pm.max_requests = 5000
max_requests=5000 是保命配置,防止worker内存泄漏积累。但代价是频繁重启worker。每个worker重启后第一次请求要重新做opcache warmup,延迟会高一些。
Swoole 5.1:常驻内存 + 事件驱动
Swoole是一个PHP扩展,通过C实现的网络层事件循环。进程启动时加载一次PHP代码到内存,之后所有请求复用这些类和连接。
关键点:Swoole的worker进程是常驻的,你的Laravel应用在onWorkerStart时boot一次,之后每个请求只是执行路由分发和控制器,不再重复bootstrap。数据库连接(协程MySQL)也常驻,省掉每次TCP三次握手和MySQL认证。
<?php
// swoole_http_server.php 启动文件
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
$http = new Server("0.0.0.0", 9501);
$http->set([
'worker_num' => 8, // 与CPU核数一致
'max_request' => 10000, // 10万请求后重启worker,防内存泄漏
'log_level' => 2,
'log_file' => '/var/log/swoole/swoole.log',
]);
$http->on('workerStart', function ($server, $workerId) {
// 在这里初始化框架,只执行一次
require __DIR__ . '/bootstrap/app.php';
$app = require_once __DIR__ . '/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
// 将kernel存到全局,供request回调使用
$GLOBALS['kernel'] = $kernel;
});
$http->on('request', function (Request $req, Response $res) use ($kernel) {
// 将Swoole请求转换为Laravel Request
$illuminateRequest = Illuminate\Http\Request::createFromBase(
new Symfony\Component\HttpFoundation\Request(
$req->get ?: [],
$req->post ?: [],
[],
$req->cookie ?: [],
$req->files ?: [],
$req->server ?: [],
$req->rawContent()
)
);
try {
$response = $kernel->handle($illuminateRequest);
$res->status($response->getStatusCode());
foreach ($response->headers->all() as $name => $values) {
foreach ($values as $value) {
$res->header($name, $value);
}
}
$res->end($response->getContent());
} catch (\Throwable $e) {
$res->status(500);
$res->end($e->getMessage());
}
});
$http->start();
这里有个细节:真正在生产环境你不会手写这个,而是用LaravelS(用于Laravel的Swoole适配包)或Hyperf。LaravelS帮你处理了请求转换、协程上下文清理、连接池管理。上面代码只是让你理解原理。
RoadRunner 3.6:Go二进制 + PHP worker
RoadRunner的思路完全不同:用Go写HTTP服务器(同时支持gRPC、TCP、队列消费),PHP以CLI模式启动worker,通过标准输入输出与Go进程通信(protocol buffers编码)。
这意味着:网络层、并发管理交给Go,PHP只负责任务执行。每个PHP worker处理完一个请求后不退出,等待下一个任务。Worker数量由Go侧动态调节。
# .rr.yaml RoadRunner 3.6 配置文件
version: "3.0"
server:
command: "php worker.php"
rpc:
listen: tcp://127.0.0.1:6001
http:
address: 0.0.0.0:8080
max_request_size: 10
pool:
num_workers: 20
max_jobs: 20000
allocate_timeout: 60s
destroy_timeout: 60s
middleware:
- gzip
- static
- headers
static:
enable: true
dir: public
forbid: [".php", ".htaccess"]
<?php
// worker.php RoadRunner PHP worker
use Spiral\RoadRunner\Worker;
use Spiral\RoadRunner\Http\PSR7Worker;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\ServerRequest;
require __DIR__ . '/vendor/autoload.php';
$worker = Worker::create();
$psr7 = new PSR7Worker($worker, new Psr17Factory(), new Psr17Factory(), new Psr17Factory());
while ($request = $psr7->waitRequest()) {
try {
// 每次请求需要重新初始化,还是常驻?
// 答案是:worker启动时初始化一次框架,请求循环里只执行
$response = new \Nyholm\Psr7\Response(200, [], 'Hello from RoadRunner!');
$psr7->respond($response);
} catch (\Throwable $e) {
$psr7->respond(new \Nyholm\Psr7\Response(500, [], $e->getMessage()));
}
}
RoadRunner的坑在哪?它的生态比Swoole小。PHP社区对RoadRunner的支持主要靠Spiral框架,你用Laravel的话需要自己处理请求转成PSR-7再转成Laravel Request。官方有Laravel Bridge包,但版本更新经常赶不上Laravel节奏。
三者如何选?一张表给你
| 维度 | PHP-FPM 8.3 | Swoole 5.1 | RoadRunner 3.6 |
|---|---|---|---|
| 模型 | 多进程 + 阻塞I/O | 多进程 + 事件驱动/协程 | Go多线程 + PHP多进程 |
| 常驻内存 | 否 | 是 | 是(PHP worker常驻) |
| 并发能力 | 受进程数限制 | 协程,单worker可万级并发 | Go处理连接,PHP worker处理任务 |
| 内存占用 | 每个请求释放,但常驻进程多 | worker常驻,单个占用高 | 同样常驻,比Swoole略低 |
| 生态 | 所有框架原生支持 | 需要适配层(LaravelS/Hyperf) | Spiral全家桶,其他框架需适配 |
| 部署复杂度 | 最低,apt install搞定 | 需扩展编译,进程守护 | 需Go编译,进程守护 |
| 适合场景 | 低并发、传统项目 | 高并发API、WebSocket、微服务 | 高并发API、任务队列、gRPC |
实测数据:同一个业务接口,三种模式
测试环境:阿里云ecs.g7.2xlarge,8核16GB,CentOS 7.9,PHP 8.3.2(opcache开启),Laravel 11.9,MySQL 8.0.35(本机),Redis 7.2.1(本机)。
接口逻辑:接收POST JSON → 验证参数 → 查询用户表(走主键)→ 查Redis缓存 → 写入一个操作日志表 → 返回JSON。
压测工具:wrk 4.2.0,每个模式跑3次取中位数,压测时长60秒,连接数400。
# 压测脚本
wrk -t8 -c400 -d60s -s post.lua http://127.0.0.1:8080/api/order/callback
# post.lua
wrk.method = "POST"
wrk.body = '{"order_id":"ORD20240115","status":"paid","amount":99.50}'
wrk.headers["Content-Type"] = "application/json"
结果一:QPS(越高越好)
| 运行模式 | QPS | 较FPM提升 | CPU使用率 |
|---|---|---|---|
| PHP-FPM (150 workers) | 1,842 | 基准 | 71% |
| Swoole (8 workers) | 6,319 | 3.43x | 68% |
| RoadRunner (20 workers) | 4,156 | 2.26x | 65% |
FPM 150个worker跑满8核才1842 QPS,Swoole只用8个worker就打出6319 QPS。差距主要省在框架re-bootstrap、数据库连接数、以及FPM进程切换开销。
RoadRunner比Swoole低34%,原因在于其PHP worker实际处理请求的逻辑和FPM类似,只是免去了FPM的连接管理开销。Go的调度器再快,PHP worker本身仍受限于单进程阻塞I/O(除非你每个worker内部用协程,那就是Swoole的路子了)。
结果二:P95/P99延迟(毫秒,越低越好)
| 运行模式 | P50 | P95 | P99 |
|---|---|---|---|
| PHP-FPM | 38ms | 127ms | 412ms |
| Swoole | 12ms | 29ms | 67ms |
| RoadRunner | 22ms | 61ms | 158ms |
P99是重点。FPM在400并发下P99冲到412ms,已经接近用户体验红线,而Swoole只有67ms,差了6倍。
结果三:内存占用(MB)
| 运行模式 | 空闲时全部进程/线程内存 | 高负载时峰值 |
|---|---|---|
| PHP-FPM (150 workers) | 3.2GB (40MB × 80 worker) | 6.1GB |
| Swoole (8 workers) | 312MB (39MB × 8) | 520MB |
| RoadRunner (20 workers) | 1.4GB (70MB × 20) | 2.7GB |
FPM内存占用是Swoole的8.5倍,主要来自大量空闲worker驻留。RoadRunner每个PHP worker内存偏高,因为需要额外跑Go侧通信的胶水代码。
代码实现:完整可运行的接入案例
下面给一个更接近生产的Swoole接入Laravel示例,以及RoadRunner接入Laravel示例。两者都能跑,但注意:它们是演示代码,生产请用官方适配包(LaravelS / Spiral Laravel Bridge),因为还有协程上下文污染、事务回滚、文件句柄泄漏等问题需要处理。
Swoole + Laravel 11 生产级接入(简化版)
<?php
// swoole_laravel.php
// 使用LaravelS思路但自己实现,方便你理解每一层在干什么
use Swoole\Http\Server;
use Swoole\Coroutine;
use Illuminate\Http\Request as LRequest;
use Illuminate\Http\Response as LResponse;
use Illuminate\Contracts\Http\Kernel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
$http = new Server("0.0.0.0", 9501);
$http->set([
'worker_num' => 8,
'max_request' => 20000,
'enable_coroutine' => true,
'task_worker_num' => 4,
'log_level' => 2,
]);
$http->on('workerStart', function ($server, $workerId) {
$app = require __DIR__ . '/bootstrap/app.php';
$app->make(Kernel::class);
// 放到静态全局供request使用
$GLOBALS['app'] = $app;
$GLOBALS['kernel'] = $app->make(Kernel::class);
});
$http->on('request', function ($swooleRequest, $swooleResponse) {
$app = $GLOBALS['app'];
$kernel = $GLOBALS['kernel'];
// 构建Laravel Request
$laravelRequest = LRequest::createFromBase(
new Symfony\Component\HttpFoundation\Request(
$swooleRequest->get ?: [],
$swooleRequest->post ?: [],
[],
$swooleRequest->cookie ?: [],
$swooleRequest->files ?: [],
$swooleRequest->server ?: [],
$swooleRequest->rawContent()
)
);
try {
// 使用Laravel HTTP Kernel处理请求
$laravelResponse = $kernel->handle($laravelRequest);
// 转换响应
$swooleResponse->status($laravelResponse->getStatusCode());
foreach ($laravelResponse->headers->all() as $name => $values) {
foreach ($values as $value) {
$swooleResponse->header($name, $value);
}
}
$swooleResponse->end($laravelResponse->getContent());
// 请求结束后处理terminate逻辑,但不要在这里销毁app
if (function_exists('fastcgi_finish_request')) {
// 说明不是真正FPM环境
}
} catch (\Throwable $e) {
$swooleResponse->status(500);
$swooleResponse->end(json_encode(['error' => $e->getMessage()]));
} finally {
// 清理请求绑定的singleton,否则内存暴涨
// 关键:Laravel 11的app会把请求中间件、路由、session绑定到container
// 这里需要手动forget
foreach ($app->getBindings() as $abstract => $binding) {
if ($app->isShared($abstract)) {
try { $app->forgetInstance($abstract); } catch (\Throwable $e) {}
}
}
$kernel->terminate($laravelRequest, $laravelResponse);
}
});
$http->start();
上面的finally块就是避坑核心。Laravel的container是个大杂烩,每个请求跑完后,request、session、auth这些singleton还挂在container上,你不清内存就炸。这也是为什么很多人说「Swoole跑Laravel内存泄漏」,其实是没做请求隔离。
RoadRunner + Laravel 11(官方Bridge速览)
# .rr.yaml 完整配置
version: "3.0"
server:
command: "php rr_worker.php"
rpc:
listen: tcp://127.0.0.1:6001
http:
address: 0.0.0.0:8080
max_request_size: 10
pool:
num_workers: 10
max_jobs: 10000
allocate_timeout: 60s
destroy_timeout: 60s
middleware: [static, gzip, headers, sendfile]
logs:
mode: production
level: error
output: stdout
<?php
// rr_worker.php
use Spiral\RoadRunner\Worker;
use Spiral\RoadRunnerLaravel\ServiceProvider;
use Spiral\RoadRunnerLaravel\Binary\LaravelBridge;
use Illuminate\Contracts\Http\Kernel;
require __DIR__ . '/vendor/autoload.php';
// 创建Laravel应用
$app = require __DIR__ . '/bootstrap/app.php';
$worker = Worker::create();
// 官方Bridge提供的桥接器
$bridge = new LaravelBridge($app, $worker);
$bridge->start();
官方Bridge比手写稳得多,它内部处理了请求生命周期、容器隔离、响应格式转换。如果你一定要手写,麻烦把上面的Swoole示例里的内存清理照抄过来。
避坑指南:这些坑我都踩过
坑1:Swoole内存泄漏不是Swoole的锅,是你的
我最早用Swoole跑Laravel,跑了一个星期,内存从300MB涨到6GB。一开始以为Swoole的bug,后来发现是Laravel的container在请求结束后没有清理singleton。每次请求都会new一个服务提供者实例注册到container,但container还在,旧实例也不释放。
解决办法:在每个请求的finally里遍历container绑定,调用forgetInstance。另外,避免在Swoole下使用需要跨请求保存状态的全局单例——比如把请求ID放在静态属性里,一旦协程切换就串了。用Swoole\Coroutine\Context存请求上下文。
坑2:max_request别省,别以为常驻内存就万事大吉
Swoole默认max_request=0表示永不重启worker,这是给常驻任务用的。做HTTP服务必须设max_request,比如20000,一旦达到就自动重启worker。我见过有人不设,两个星期后PHP进程的虚拟内存涨到20GB。
RoadRunner也一样,max_jobs必须设置。官方推荐的10000-20000是一个比较安全的区间,既能减少重启频率,又能及时回收泄漏。
坑3:Swoole下MySQL连接必须用连接池
如果你在Swoole里继续用PDO连接MySQL,每个请求都新建PDO,那么高并发下MySQL会被打爆——每个协程各建一个连接,瞬间上千个连接怼上去。Swoole提供了Swoole\Coroutine\MySQL和连接池方案,LaravelS会用独立的连接池管理。但你自己用的话,务必实现Channel池。
因为Laravel在Swoole下用的还是标准PDO,LaravelS通过队列管理数据库连接,确保同一时刻每个worker只用一个连接在跑。你在写业务代码时,注意不要嵌套事务,Swoole下事务一旦没提交,连接一直占用。
坑4:RoadRunner的PHP worker不是线程安全的
RoadRunner的Go侧会并发地向多个PHP worker派发任务,但每个PHP worker同一时刻只处理一个任务,因为它是阻塞式的。你写业务时不用考虑并发变量冲突,但要注意:不同worker之间不共享任何数据,共享数据要放Go侧(比如通过RPC调用来存储)。
如果某个PHP worker卡住了,RoadRunner不会杀掉它,而是等它完成或超时。所以你的代码里绝对不能有sleep()或者阻塞等待——一个worker卡住,整个池的吞吐下降一半。
坑5:FPM的pm.max_requests别设0,多进程模型不是越少越好
有个项目设pm.max_requests=0,结果跑了三个月,每个FPM worker都积累了几百万个请求,PHP进程内存飙到5GB。虽然FPM不会内存泄漏到爆(最坏情况是OOM killer杀掉PHP-FPM),但P99延迟会很不稳定。
建议pm.max_requests=5000,配合opcache.validate_timestamps=0(生产环境关闭文件变更检查),既避免频繁重启,又控制内存膨胀。
坑6:别再手动编译Swoole扩展了,用官方Docker镜像或PECL
如果你还在phpize && ./configure && make && make install,浪费时间。PECL一行搞定:pecl install swoole。如果你是Docker部署,用php:8.3-cli-alpine基础镜像,然后RUN pecl install swoole。
# Dockerfile
FROM php:8.3-cli-alpine
RUN pecl install swoole \
&& docker-php-ext-enable swoole
# 生产用swoole-olevel: 记得加上 --enable-openssl 和 --enable-swoole-curl
# pecl install swoole 默认已启用多数特性
总结:我的选型建议
如果你的项目已经在用FPM且访问量不大(QPS < 1000),完全没有必要换Swoole。FPM的简单可靠是最大优势——每个请求独立,没有内存泄漏,没有协程上下文问题,debug容易。
如果QPS > 2000,或者你有WebSocket、gRPC需求,换Swoole。注意:Laravel配Swoole用LaravelS,别自己造轮子;ThinkPHP配Swoole用官方think-swoole包;如果你要真正发挥Swoole功力,直接上Hyperf——它是原生Swoole协程框架,没有适配损耗。
RoadRunner适合已经有Go基础但业务逻辑必须用PHP的团队。它的优势是Go可以原生处理静态文件、gRPC、HTTP2,PHP只做业务计算。但生态和社区比Swoole弱不少。
最后,无论选哪个,上线前压测必须做。不要只看QPS,要看P99延迟和长稳测试(跑24小时以上看内存增长曲线)。