一次线上事故:PHP-FPM扛不住了
2023年双11晚上,我负责的API网关流量飙升3倍,PHP-FPM进程池瞬间被占满,请求队列堆积到5000+,响应从20ms飙升到8秒。我登录服务器看到 php-fpm: pool www 进程数已达200(max_children=200),CPU仅40%,但大量进程在等待MySQL和Redis。这就是PHP-FPM的典型痛点:每个请求启动一个进程,上下文切换和内存开销巨大,I/O阻塞时CPU闲置。
事后复盘,我意识到PHP-FPM的同步阻塞模型已经不适合高I/O场景。于是我开始调研常驻内存方案:Swoole和RoadRunner。本文用真实压测数据告诉你:什么场景该选哪个。
三种架构的核心区别
| 特性 | PHP-FPM | Swoole | RoadRunner |
|---|---|---|---|
| 进程模型 | 多进程(同步阻塞) | 多线程+异步非阻塞 | 多进程+Goroutine协作 |
| 内存驻留 | 否(请求结束释放) | 是(Worker进程常驻) | 是(PHP Worker常驻) |
| I/O模型 | 阻塞 | 事件驱动/协程 | Goroutine(非阻塞) |
| 静态文件 | 交给Nginx | 内置HTTP服务器 | 内置静态文件处理 |
| 学习成本 | 低 | 中高(协程心智模型) | 中(容器模式) |
| 生态兼容 | 全 | 需要协程化改造 | 兼容所有PHP代码(自动协程化) |
| 版本 | PHP 8.3.10 | Swoole 5.1.2 | RoadRunner 2023.3.0 |
场景一:传统CRUD API + 多次数据库查询
这是最常见的场景:接收参数 → 查MySQL → 查Redis → 查MySQL → 返回JSON。PHP-FPM里每个请求都会重复连接数据库、解析模板、加载框架。
方案A:PHP-FPM + Laravel
// routes/api.php
Route::get('/user/{id}', function ($id) {
$user = DB::table('users')->find($id);
$orders = DB::table('orders')->where('user_id', $id)->get();
$cache = Cache::get("user_{$id}_meta");
return response()->json([
'user' => $user,
'orders' => $orders,
'cache' => $cache
]);
});
方案B:Swoole + Laravel(通过Swoole HTTP服务器)
// swoole_server.php
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
$server = new Server("0.0.0.0", 9501, SWOOLE_PROCESS, SWOOLE_SOCK_TCP);
$server->set([
'worker_num' => 4,
'enable_coroutine' => true,
'max_request' => 10000,
]);
require __DIR__ . '/vendor/autoload.php';
$app = require_once __DIR__ . '/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$server->on('request', function (Request $req, Response $res) use ($kernel) {
// 将Swoole请求转换为Laravel兼容的Request
$laravelRequest = \Illuminate\Http\Request::create(
$req->server['request_uri'],
$req->server['request_method'],
$req->get ?? [],
$req->cookie ?? [],
$req->files ?? [],
$req->server,
$req->rawContent()
);
$response = $kernel->handle($laravelRequest);
$res->end($response->getContent());
$kernel->terminate($laravelRequest, $response);
});
$server->start();
方案C:RoadRunner + Laravel(自动协程化)
# .rr.yaml
version: "3"
server:
command: "php worker.php"
env:
- APP_RUNNING_IN_RR: true
http:
address: 0.0.0.0:8080
max_request: 200
static:
dir: public
forbid: [".php"]
// worker.php
use Spiral\RoadRunner;
use Nyholm\Psr7\Factory\Psr17Factory;
use Spiral\RoadRunner\Worker;
use Spiral\RoadRunner\Http\PSR7Worker;
// 加载Laravel
require __DIR__ . '/vendor/autoload.php';
$app = require __DIR__ . '/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$worker = Worker::create();
$psr7 = new PSR7Worker($worker, new Psr17Factory, new Psr17Factory, new Psr17Factory);
while ($request = $psr7->waitRequest()) {
try {
$response = $kernel->handle($request);
$psr7->respond($response);
$kernel->terminate($request, $response);
} catch (\Throwable $e) {
$psr7->respond(new \Nyholm\Psr7\Response(500));
}
}
效果数据:压测结果
使用wrk压测 GET /user/1,场景:Laravel 11 + MySQL 8.0.35 + Redis 7.2,数据库3次查询+1次Redis。机器:4核8G。
| 方案 | QPS | 平均延迟(ms) | P99(ms) | 内存/进程(MB) |
|---|---|---|---|---|
| PHP-FPM (4 workers, max_children=50) | 380 | 26 | 58 | 25/进程 |
| Swoole (4 workers, 协程开启) | 1580 | 6.5 | 18 | 120/worker |
| RoadRunner (4 workers, max_request=200) | 1420 | 7.2 | 20 | 90/worker |
结论:Swoole和RoadRunner在I/O密集型场景下QPS是PHP-FPM的4倍左右,延迟降低70%。Swoole略高是因为协程调度更轻量,RoadRunner的Goroutine层有轻微开销。但RoadRunner的兼容性更好——Laravel代码无需任何修改。
场景二:纯计算密集型(如图片压缩、Excel导出)
这种场景下CPU是瓶颈,I/O很少。我压测了生成1000行Excel(使用PhpSpreadsheet)。
| 方案 | QPS (请求/秒) | CPU使用率 | 说明 |
|---|---|---|---|
| PHP-FPM | 12 | 98% | 每个进程独自计算 |
| Swoole (协程不节省CPU) | 11 | 95% | 协程对CPU无优化 |
| RoadRunner | 11.5 | 96% | 同样CPU受限 |
计算密集场景三种方案表现几乎一致,因为瓶颈在CPU而不是I/O。RoadRunner和Swoole的优势消失。
场景三:WebSocket/长连接
这是Swoole的绝对领域。RoadRunner不支持WebSocket(截至2023.3版本)。PHP-FPM无法实现。
// Swoole WebSocket server
$server = new Swoole\WebSocket\Server("0.0.0.0", 9502);
$server->on('open', function ($server, $req) {
echo "connection open: {$req->fd}\n";
});
$server->on('message', function ($server, $frame) {
echo "received: {$frame->data}\n";
$server->push($frame->fd, "hello");
});
$server->on('close', function ($server, $fd) {
echo "connection close: {$fd}\n";
});
$server->start();
连接数测试:5000并发WebSocket连接,Swoole稳定运行,内存占用约200MB(每个连接约40KB)。RoadRunner无法处理。
选型决策树
- 需要WebSocket/长连接 → 选Swoole
- 使用Laravel/Symfony等成熟框架,不想改造 → 选RoadRunner(零侵入)
- 高I/O并发(数据库/Redis/外部API)→ Swoole或RoadRunner均可
- 计算密集型 → 三种都一样,PHP-FPM更简单
- 团队PHP经验丰富但没接触过协程 → RoadRunner学习曲线更低
- 追求极致性能且愿意写协程代码 → Swoole
避坑指南(实战踩过的坑)
坑1:Swoole + Laravel 内存泄漏
第一次上线Swoole+Laravel,跑了8小时内存从200MB涨到2GB。原因是Laravel的ServiceProvider在每次请求中注册了闭包,闭包引用了外部变量导致无法释放。解决:在worker_start回调中只注册一次,或者使用max_request设置最大请求数后重启Worker。
$server->set([
'max_request' => 10000, // 处理1w个请求后重启worker
]);
坑2:RoadRunner 文件锁/ session 问题
PHP-FPM每次请求独立进程,文件锁、session_start() 默认用文件锁没问题。但在RoadRunner中,多个协程共享同一个Worker进程,session_start() 会报"Failed to initialize session module"。解决:使用Redis或数据库存储session,或者禁用session。
// .env
SESSION_DRIVER=redis
坑3:Swoole 的协程传染
在Swoole中,调用sleep()会阻塞整个Worker!必须使用Co::sleep()。老代码里有usleep()、file_get_contents等会阻塞。RoadRunner通过Goroutine自动将PHP的阻塞调用转为非阻塞,无需改造(但实际测试中file_get_contents仍然阻塞PHP Worker,需要配合RoadRunner的HTTP中间件或手动使用curl等异步库)。
坑4:RoadRunner 的 max_request 设置过小
默认 max_request=200,意味着每个Worker处理200个请求后重启。如果每个请求加载了框架(如Laravel),200次后重启反而比PHP-FPM还慢。建议设置为5000-10000,配合OPcache。
坑5:PHP-FPM 与 Swoole 混合部署的单测问题
本地开发用php artisan serve (PHP-FPM),线上用Swoole。代码中$_SERVER字段不同,导致某些中间件出错。务必封装统一的请求获取方式,例如request()->server()。
总结
没有银弹。PHP-FPM依然是低并发场景的最简单选择。Swoole适合需要WebSocket或愿意拥抱协程的团队。RoadRunner适合想要无痛升级性能的Laravel/Symfony项目——我最终把那个API网关迁移到了RoadRunner,零代码改动,QPS从380提升到1420,内存稳定在400MB(4个worker)。
最后给出我的压测脚本,方便你复现:
# 安装wrk
wrk -t4 -c100 -d30s --latency http://localhost:8080/user/1
# 安装Swoole
pecl install swoole-5.1.2
# RoadRunner二进制下载
curl -sS https://get.rr.dev/cli/install | bash