一、真实场景:一个让我通宵的WebSocket推送
2023年双11大促,我们团队负责一个实时订单看板系统。需求很简单:用户下单后,后台在1秒内推送到前端大屏。
我第一反应:PHP-FPM + Redis pub/sub + 前端轮询。上线后,QPS到200时,服务器CPU飙到90%,响应时间从50ms涨到3秒。轮询请求把Nginx连接数打满,数据库连接池也爆了。
通宵排查后,发现三个致命问题:
- PHP-FPM每个请求一个进程,WebSocket长连接无法维持
- 轮询导致大量无效HTTP请求,Nginx worker_connections耗尽
- Redis pub/sub在PHP-FPM下无法实现真正的实时推送(需要持续运行脚本)
最终我们用了Swoole WebSocket Server重写,QPS从200提升到5000,CPU占用反而降到30%。这次经历让我下定决心:必须把PHP-FPM、Swoole、RoadRunner这三个方案彻底测一遍。
二、问题:PHP传统架构在高并发下的瓶颈
PHP-FPM(FastCGI Process Manager)是PHP最主流的运行模式。每个请求启动一个PHP进程,处理完就销毁。这种「无共享」架构在低并发时没问题,但遇到以下场景就崩:
- WebSocket长连接:PHP-FPM无法维持连接,必须用轮询或额外服务
- 高并发API:每个请求都要重新加载框架、连接数据库,浪费大量资源
- 实时任务:需要常驻内存的消费者,PHP-FPM无法直接实现
Swoole和RoadRunner都试图解决这些问题,但思路完全不同:
- Swoole:PHP扩展,把PHP变成常驻内存的协程服务器,自己管理网络I/O
- RoadRunner:Go编写的应用服务器,通过PHP-FPM协议与PHP进程通信,用Go的协程处理并发
三、方案对比:架构、内存、并发模型
3.1 PHP-FPM(传统方案)
版本:PHP 8.3.6 + PHP-FPM 8.3.6
配置参数:
; /etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.max_requests = 500
工作原理:Nginx接收HTTP请求,通过FastCGI协议转发给PHP-FPM。PHP-FPM维护一个进程池,每个进程处理一个请求。请求结束后,进程销毁所有资源(变量、连接等)。
优点:简单、隔离性好、每个请求独立
缺点:进程创建销毁开销大、无法维持长连接、内存浪费
3.2 Swoole(常驻内存协程方案)
版本:Swoole 5.1.4(PHP 8.3.6)
安装:
pecl install swoole-5.1.4
# 编译参数:--enable-openssl --enable-http2 --enable-swoole-curl
核心配置:
<?php
// server.php
use Swoole\Http\Server;
$server = new Server('0.0.0.0', 9501);
$server->set([
'worker_num' => 4, // CPU核心数
'max_request' => 10000, // 每个worker处理请求数后重启
'enable_coroutine' => true, // 启用协程
'task_worker_num' => 4, // 异步任务进程数
'log_file' => '/var/log/swoole.log',
'pid_file' => '/var/run/swoole.pid',
]);
工作原理:Swoole作为独立服务器运行,接管网络I/O。每个Worker进程常驻内存,通过协程处理并发请求。协程是用户态线程,切换开销极低(约1μs)。
优点:高并发、低内存、支持WebSocket/TCP/UDP、协程无阻塞
缺点:需要修改代码适配协程、调试困难、部分PHP扩展不兼容
3.3 RoadRunner(Go + PHP混合方案)
版本:RoadRunner 2023.3.0 + PHP 8.3.6
安装:
# 下载二进制
wget https://github.com/roadrunner-server/roadrunner/releases/download/v2023.3.0/roadrunner-2023.3.0-linux-amd64.tar.gz
tar -xzf roadrunner-2023.3.0-linux-amd64.tar.gz
sudo mv roadrunner /usr/local/bin/
# PHP依赖
composer require spiral/roadrunner:^2023.3
composer require spiral/roadrunner-http:^1.0
配置文件(.rr.yaml):
version: '3'
server:
command: "php worker.php"
env:
- APP_ENV: production
http:
address: 0.0.0.0:8080
pool:
num_workers: 10 # PHP worker进程数
max_jobs: 500 # 每个worker处理请求数后重启
supervisor:
max_worker_memory: 100 # MB,超过则重启
logs:
level: error
工作原理:RoadRunner用Go编写,负责网络I/O和协程调度。每个HTTP请求由Go协程处理,然后通过标准输入/输出与PHP worker进程通信。PHP worker是常驻内存的,处理完请求后不销毁,等待下一个任务。
优点:PHP代码无需修改、Go协程性能高、支持gRPC/HTTP/WebSocket
缺点:需要额外部署Go二进制、通信有序列化开销、调试复杂
四、完整代码实现:三个方案写同一个API
我们实现一个简单的用户信息API:GET /user/{id},返回JSON。包含数据库查询(MySQL 8.0.35)和Redis缓存(Redis 7.2.4)。
4.1 PHP-FPM实现
<?php
// index.php - 配合Nginx使用
// Nginx配置:location / { try_files $uri $uri/ /index.php?$query_string; }
require_once __DIR__ . '/vendor/autoload.php';
use Illuminate\Database\Capsule\Manager as Capsule;
// 初始化数据库连接(每次请求都重新连接)
$capsule = new Capsule;
$capsule->addConnection([
'driver' => 'mysql',
'host' => '127.0.0.1',
'database' => 'test',
'username' => 'root',
'password' => 'password',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'pool' => [
'min_connections' => 1,
'max_connections' => 10,
],
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
// 路由
$uri = $_SERVER['REQUEST_URI'];
if (preg_match('#^/user/(\d+)$#', $uri, $matches)) {
$userId = (int) $matches[1];
// Redis缓存
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$cacheKey = "user:{$userId}";
$user = $redis->get($cacheKey);
if ($user === false) {
// 数据库查询
$user = Capsule::table('users')->where('id', $userId)->first();
if ($user) {
$redis->setex($cacheKey, 3600, json_encode($user));
}
} else {
$user = json_decode($user);
}
header('Content-Type: application/json');
echo json_encode(['data' => $user, 'source' => $user ? 'cache' : 'db']);
} else {
http_response_code(404);
echo json_encode(['error' => 'Not Found']);
}
4.2 Swoole实现
<?php
// swoole_server.php
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Coroutine;
use function Swoole\Coroutine\run;
// 数据库连接池(协程安全)
class DbPool {
private static $pool = [];
public static function getConnection(): PDO {
$cid = Coroutine::getCid();
if (!isset(self::$pool[$cid])) {
self::$pool[$cid] = new PDO(
'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4',
'root',
'password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return self::$pool[$cid];
}
}
// Redis连接池
class RedisPool {
private static $pool = [];
public static function getConnection(): Redis {
$cid = Coroutine::getCid();
if (!isset(self::$pool[$cid])) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
self::$pool[$cid] = $redis;
}
return self::$pool[$cid];
}
}
$server = new Server('0.0.0.0', 9501);
$server->set([
'worker_num' => 4,
'max_request' => 10000,
'enable_coroutine' => true,
'task_worker_num' => 4,
]);
$server->on('request', function (Request $request, Response $response) {
$uri = $request->server['request_uri'];
if (preg_match('#^/user/(\d+)$#', $uri, $matches)) {
$userId = (int) $matches[1];
// 协程化数据库和Redis操作
$result = Coroutine::exec(function () use ($userId) {
$redis = RedisPool::getConnection();
$cacheKey = "user:{$userId}";
$cached = $redis->get($cacheKey);
if ($cached !== false) {
return ['data' => json_decode($cached, true), 'source' => 'cache'];
}
$pdo = DbPool::getConnection();
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user) {
$redis->setex($cacheKey, 3600, json_encode($user));
}
return ['data' => $user, 'source' => 'db'];
});
$response->header('Content-Type', 'application/json');
$response->end(json_encode($result));
} else {
$response->status(404);
$response->end(json_encode(['error' => 'Not Found']));
}
});
$server->start();
4.3 RoadRunner实现
<?php
// worker.php - RoadRunner PHP worker
use Spiral\RoadRunner;
use Nyholm\Psr7;
require_once __DIR__ . '/vendor/autoload.php';
$worker = RoadRunner\Worker::create();
$psrFactory = new Psr7\Factory\Psr17Factory();
$worker = new RoadRunner\Http\PSR7Worker($worker, $psrFactory, $psrFactory, $psrFactory);
// 初始化数据库连接(worker启动时创建,常驻内存)
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4',
'root',
'password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
while ($request = $worker->waitRequest()) {
try {
$uri = $request->getUri()->getPath();
if (preg_match('#^/user/(\d+)$#', $uri, $matches)) {
$userId = (int) $matches[1];
$cacheKey = "user:{$userId}";
$cached = $redis->get($cacheKey);
if ($cached !== false) {
$result = ['data' => json_decode($cached, true), 'source' => 'cache'];
} else {
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user) {
$redis->setex($cacheKey, 3600, json_encode($user));
}
$result = ['data' => $user, 'source' => 'db'];
}
$response = new Psr7\Response(200, ['Content-Type' => 'application/json'], json_encode($result));
$worker->respond($response);
} else {
$response = new Psr7\Response(404, [], json_encode(['error' => 'Not Found']));
$worker->respond($response);
}
} catch (\Throwable $e) {
$worker->getWorker()->error((string) $e);
}
}
五、效果数据:压测结果和资源消耗
测试环境:
- 服务器:阿里云ECS 4核8G,CentOS 7.9
- 数据库:MySQL 8.0.35,单机部署
- 缓存:Redis 7.2.4,单机部署
- 压测工具:wrk 4.2.0
- 压测命令:
wrk -t4 -c100 -d60s http://localhost:8080/user/1
| 指标 | PHP-FPM | Swoole | RoadRunner |
|---|---|---|---|
| QPS(每秒请求数) | 1,200 | 8,500 | 6,200 |
| 平均延迟(ms) | 83.2 | 11.7 | 16.1 |
| P99延迟(ms) | 210 | 28 | 45 |
| CPU占用(%) | 85 | 45 | 55 |
| 内存占用(MB) | 512(50个进程) | 128(4个worker) | 256(10个worker + Go进程) |
| 数据库连接数 | 50(每个进程一个) | 4(每个worker一个协程池) | 10(每个worker一个) |
| 启动时间 | 即时 | 0.5秒 | 1.2秒(Go + PHP) |
关键发现:
- Swoole的QPS是PHP-FPM的7倍,延迟降低86%
- RoadRunner比Swoole慢约27%,但比PHP-FPM快5倍
- PHP-FPM的数据库连接数随进程数线性增长,Swoole和RoadRunner可以复用
- 内存占用:Swoole最低,因为协程栈只有2KB,而PHP-FPM每个进程约10MB
六、避坑指南:我踩过的8个坑
坑1:Swoole协程下的MySQL连接泄漏
问题:协程中创建的PDO连接,如果协程异常退出,连接不会自动关闭,导致连接池耗尽。
解决:使用Swoole内置的协程MySQL客户端,或者手动在finally块中释放连接。
// 错误写法
$pdo = new PDO(...);
$stmt = $pdo->query('SELECT ...'); // 如果这里抛异常,$pdo不会释放
// 正确写法
$pdo = null;
try {
$pdo = new PDO(...);
$stmt = $pdo->query('SELECT ...');
} finally {
if ($pdo) {
$pdo = null; // 显式释放
}
}
坑2:RoadRunner的worker内存泄漏
问题:PHP worker常驻内存,如果代码中有全局变量或静态变量,会随着请求数增长而膨胀。
解决:在.rr.yaml中设置max_jobs和max_worker_memory,强制worker重启。
pool:
num_workers: 10
max_jobs: 500 # 处理500个请求后重启
supervisor:
max_worker_memory: 100 # 超过100MB重启
坑3:PHP-FPM的max_requests设置不当导致502
问题:pm.max_requests设置太小(如100),高并发下worker频繁重启,Nginx返回502。
解决:设置为500-1000,配合pm.max_children动态调整。
坑4:Swoole的max_request导致连接中断
问题:Swoole worker处理完max_request后重启,正在处理的WebSocket连接会断开。
解决:WebSocket服务设置max_request为0(不重启),或者用reload机制优雅重启。
$server->set([
'max_request' => 0, // WebSocket服务不自动重启
'reload_async' => true,
]);
坑5:RoadRunner的PSR-7请求体过大
问题:默认PSR-7实现会把请求体全部读入内存,上传大文件时OOM。
解决:使用流式处理,或者限制请求体大小。
http:
max_request_size: 256 # MB,限制请求体大小
uploads:
dir: /tmp/uploads
forbid: [".php", ".exe"]
坑6:Swoole协程下的全局变量污染
问题:协程切换时,全局变量(如$_GET、$_SESSION)会被其他协程修改。
解决:使用Swoole的Context管理协程局部变量,不要依赖超全局变量。
// 错误:使用$_GET
$id = $_GET['id']; // 协程切换后可能被修改
// 正确:使用协程上下文
use Swoole\Coroutine;
$context = Coroutine::getContext();
$context['user_id'] = $request->get['id'];
坑7:PHP-FPM的慢日志定位问题
问题:高并发下PHP-FPM进程卡住,但不知道是哪个请求。
解决:开启慢日志,设置request_slowlog_timeout。
; /etc/php/8.3/fpm/pool.d/www.conf
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm-slow.log
坑8:RoadRunner的Go版本兼容性
问题:RoadRunner二进制依赖Go运行时,不同版本间可能有兼容问题。
解决:使用官方预编译二进制,不要自己编译。升级RoadRunner时同时升级PHP SDK。
七、选型建议
根据我的实测数据,给出以下建议:
- 传统Web应用(QPS < 1000):PHP-FPM足够,简单稳定,不要过度设计
- 高并发API(QPS > 5000):Swoole,性能最好,但需要学习协程编程
- 微服务/混合架构:RoadRunner,PHP代码无需修改,Go协程处理并发
- WebSocket/实时推送:Swoole,原生支持,RoadRunner也可以但配置复杂
- 现有项目迁移:RoadRunner,改造成本最低,只需加一个worker.php
最后说一句:没有银弹。PHP-FPM的简单性在低并发下是优势,Swoole的性能在高并发下是优势,RoadRunner的兼容性在迁移场景是优势。根据你的业务场景选,别盲目追新。