一次压测事故,让我重新审视PHP的运行模式
2024年9月,我给公司一个订单回调接口做上线前压测。业务逻辑很简单:查订单、验签名、更新状态、返回结果。PHP 8.3 + PHP-FPM,跑在8核16G的Docker容器里。
压测结果让我直接愣了——QPS 卡在 8000 上不去,CPU 已经 95%。查看监控,每个PHP-FPM进程的CPU时间都耗尽在框架初始化和连接池重建上。请求处理本身只要3ms,但框架boot、数据库连接、RABC权限校验这些固定开销占了12ms。
同样一台机器,同事用Go写的网关,同样的压测工具,QPS跑了15万。差距不是语言性能,是运行模式。
我随后花了两周时间,把同一个业务分别跑在PHP-FPM、Swoole、RoadRunner上做了完整对比。这篇文章就是那次测试的全部记录,包括配置、代码、数据、踩过的坑。
先看结论,再细说方案。
| 运行模式 | 核心原理 | QPS(压测) | 内存占用 | 适用场景 |
|---|---|---|---|---|
| PHP-FPM 8.3 | pre-fork + 请求级生命周期 | 8,200 | 每进程约35MB,16进程约560MB | 传统Web应用,通用,兼容性最佳 |
| Swoole 5.1.5 | 常驻内存 + 事件驱动 | 45,000 | Worker固定约300MB(含业务常驻数据) | API网关、长连接、微服务 |
| RoadRunner 2024.1.3 | Go管理进程 + PHP Worker复用 | 28,000 | PHP Worker约200MB + Go主进程约80MB | 需要混合Go/PHP的团队,渐进式改造 |
数据来源:同一台8核16G容器,同一种业务逻辑(查订单+验签+更新),ab 压测 300 秒,压测细节下文给出。
我的结论:纯PHP业务,Swoole上限最高;要渐进式改造、需要Go生态的,RoadRunner更合适;如果业务用FPM就能扛住,别折腾。
问题根源:PHP-FPM 的进程模型为什么扛不住
先看PHP-FPM是怎么工作的。
PHP-FPM 采用 pre-fork 模型。master 进程启动时 fork 出固定数量的 worker 进程。每个 worker 进程的生命周期是:
- 等待请求
- 收到请求
- 框架初始化(加载所有依赖、配置文件)
- 执行业务代码
- 关闭连接、释放资源
- 回到等待状态,准备处理下一个请求
问题在第三步。Laravel 或者 ThinkPHP 这类框架,启动时需要加载几百个文件、解析.env、注册服务容器。这一步每次请求都要做,没法复用。压测时我用 Laravel 11 的默认配置,opcache 开启后,框架初始化仍占约 8-10ms。
另外,PHP-FPM 的连接池是空的。每个请求要通过 MySQL 协议的 handshake 建立连接。MySQL 8.0 的连接握手需要一次 TCP 往返加认证,约 1-3ms。压测工具到 Nginx 到 PHP-FPM 再到 MySQL,链路每增加一环,延迟就涨一截。
压测命令是这样的(后面对比测试用的同一命令):
ab -n 200000 -c 100 -k -H "Content-Type: application/json" \
-p post.json -T application/json \
http://127.0.0.1:8080/order/callback
-n 200000:共发送20万个请求-c 100:并发100个连接-k:开启 Keep-Alive,避免TCP握手的额外开销post.json:请求体,模拟回调场景
当前压测结果:CPU 打满 95%,QPS 停在 8,200,平均响应时间 12.2ms,P99 45ms。对回调这种场景,12ms 不算慢,但 CPU 已经没余量。流量翻倍就崩。
方案一:Swoole 常驻内存
Swoole 的原理
Swoole 扩展把PHP变成了常驻内存的服务端。它的事件驱动模型类似 Node.js,但底层用 C 实现网络层。worker 进程启动后加载一次框架,之后所有请求复用同一份已加载的代码和连接池。
这就意味着:
- 框架初始化只做一次
- MySQL/Redis 连接复用
- 外部依赖对象复用,不用每次重建
- 业务代码热执行,没有进程销毁重建的开销
Swoole 5.1.5,PHP 8.3.11。Docker 部署,基础镜像 php:8.3-cli,手动编译安装 swoole 扩展。
代码实现
我用 Swoole HTTP 服务器实现了同一套回调逻辑。完整代码可以直接跑。
<?php
// server.php
// 启动:php server.php
// 压测:ab -n 200000 -c 100 -k -p post.json http://127.0.0.1:9501/order/callback
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
// 创建HTTP服务,监听9501端口
$server = new Server("0.0.0.0", 9501);
// 设置worker数量与CPU核数一致
$server->set([
'worker_num' => 8,
'reactor_num' => 2,
'max_request' => 0, // 常驻进程,不按请求数重启
'backlog' => 128,
'log_file' => '/tmp/swoole.log',
'log_level' => 2,
'enable_static_handler' => false,
]);
// 模拟框架初始化,只做一次
$frameworkInitTime = microtime(true);
$config = loadConfig(); // 读取配置,连接池在这里建立
$dbPool = new MySQLPool('127.0.0.1', 3306, 'order_db', 'user', 'pass');
$logger = new FileLogger('/var/log/order_callback.log');
$frameworkInitCost = microtime(true) - $frameworkInitTime;
echo "[Framework initialized in {$frameworkInitCost}s, pool connections: {$dbPool->count()}]\n";
$server->on('request', function (Request $req, Response $resp) use ($config, $dbPool, $logger) {
// 路由解析:这里只处理 /order/callback
if ($req->server['request_uri'] !== '/order/callback') {
$resp->status(404);
$resp->end(json_encode(['error' => 'Not Found']));
return;
}
// 业务逻辑
try {
// 1. 解析请求体并验签
$payload = json_decode($req->getContent(), true);
if (!verifySign($payload, $config['sign_key'])) {
$resp->status(401);
$resp->end(json_encode(['error' => 'Invalid Sign']));
return;
}
// 2. 查订单(使用连接池)
$order = $dbPool->query("SELECT order_id, status FROM orders WHERE order_no = ?", [$payload['order_no']]);
if (!$order) {
$resp->status(404);
$resp->end(json_encode(['error' => 'Order Not Found']));
return;
}
// 3. 更新订单状态
$dbPool->execute("UPDATE orders SET status = 'paid', paid_at = NOW() WHERE order_id = ?", [$order['order_id']]);
// 4. 记录日志
$logger->info("order {$payload['order_no']} callback success");
// 5. 返回结果(JSON)
$resp->header('Content-Type', 'application/json');
$resp->end(json_encode(['code' => 0, 'msg' => 'success']));
} catch (Throwable $e) {
$logger->error($e->getMessage(), ['trace' => $e->getTraceAsString()]);
$resp->status(500);
$resp->end(json_encode(['error' => 'Internal Error']));
}
});
$server->start();
function verifySign(array $payload, string $key): bool {
// 简化的验签逻辑,实际用HMAC-SHA256
$sign = $payload['sign'] ?? '';
$data = json_encode($payload['data'] ?? []);
return hash_hmac('sha256', $data, $key) === $sign;
}
function loadConfig(): array {
return [
'sign_key' => 'your-sign-key-here',
'db_host' => '127.0.0.1',
];
}
class MySQLPool {
private array $connections = [];
private int $maxConn = 10;
private string $host, $user, $pass, $db;
private int $port;
public function __construct(string $host, int $port, string $db, string $user, string $pass) {
$this->host = $host;
$this->port = $port;
$this->db = $db;
$this->user = $user;
$this->pass = $pass;
// 预建10个PDO连接
for ($i = 0; $i < $this->maxConn; $i++) {
$this->connections[] = new PDO(
"mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4",
$user, $pass,
[PDO::ATTR_PERSISTENT => false, PDO::ATTR_TIMEOUT => 3]
);
}
}
public function query(string $sql, array $params = []) {
$conn = array_pop($this->connections);
if (!$conn) return null;
$stmt = $conn->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$this->connections[] = $conn;
return $result;
}
public function execute(string $sql, array $params = []): bool {
$conn = array_pop($this->connections);
if (!$conn) return false;
$stmt = $conn->prepare($sql);
$ok = $stmt->execute($params);
$this->connections[] = $conn;
return $ok;
}
public function count(): int {
return count($this->connections);
}
}
class FileLogger {
public function __construct(private string $file) {}
public function info(string $msg): void {
file_put_contents($this->file, date('Y-m-d H:i:s') . " INFO " . $msg . PHP_EOL, FILE_APPEND | LOCK_EX);
}
public function error(string $msg, array $ctx = []): void {
file_put_contents($this->file, date('Y-m-d H:i:s') . " ERROR " . $msg . " " . json_encode($ctx) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
}
代码完整,但注意几个生产环境必须改的点:
MySQLPool的实现用了array_pop/array_push取还连接,并发情况下不是线程安全的。生产环境应该用Swoole\Coroutine\Channel做连接池。max_request => 0表示永不重启,如果代码有内存泄漏,进程会越占越大。建议设置max_request => 10000,超过后自动重启 worker,避免内存无限增长。- 日志写了磁盘但没做异步,高并发下
file_put_contents会有锁竞争。用Swoole\Async或缓冲队列。
方案二:RoadRunner Go + PHP Worker
RoadRunner 的核心思路
RoadRunner 是 Spiral 团队出的 PHP 应用服务器。主进程用 Go 写,负责处理 HTTP/TCP 请求,然后把请求通过管道投递给 PHP worker 进程处理。
PHP worker 是一个长驻进程,通过标准输入输出(STDIN/STDOUT)和 Go 主进程通信。相比 PHP-FPM,省掉了进程创建、框架初始化。相比 Swoole,PHP 侧代码更贴近传统写法,不需要学习协程 API。
部署配置
RoadRunner 2024.1.3 版本。当前最新版本需要 PHP 8.1+,通过 composer 安装:
# 安装 RoadRunner 二进制
composer require spiral/roadrunner:^2024.1
vendor/bin/rr get
# 新建项目结构
mkdir -p app/src
配置文件 .rr.yaml:
version: "3"
rpc:
listen: tcp://127.0.0.1:6001
server:
command: "php app/worker.php" # PHP worker 入口
relay: pipes # 通过管道通信
pool:
num_workers: 8 # Worker数量,与CPU核数匹配
max_jobs: 10000 # 每个worker处理10000个请求后重启
allocate_timeout: 10s
destroy_timeout: 10s
http:
address: 0.0.0.0:8089
max_request_size: 10
middleware: ["gzip", "static"]
pool:
num_workers: 8
PHP Worker 代码 app/worker.php:
<?php
// app/worker.php
// 启动:vendor/bin/rr serve -c .rr.yaml
// 压测:ab -n 200000 -c 100 -k -p post.json http://127.0.0.1:8089/order/callback
use Spiral\RoadRunner\Worker;
use Spiral\RoadRunner\Http\PSR7Worker;
use Nyholm\Psr7\Factory\Psr17Factory;
require __DIR__ . '/../vendor/autoload.php';
// 创建Worker
$worker = Worker::create();
$psr7 = new PSR7Worker($worker, new Psr17Factory(), new Psr17Factory(), new Psr17Factory());
// 框架初始化一次(连接池、配置加载等)
$config = loadConfig();
$dbPool = new MySQLPool('127.0.0.1', 3306, 'order_db', 'user', 'pass');
$logger = new FileLogger('/var/log/roadrunner_callback.log');
// 循环处理请求
while ($req = $psr7->waitRequest()) {
try {
// 业务逻辑与FPM版基本一致
$path = $req->getUri()->getPath();
if ($path !== '/order/callback') {
$psr7->respond(new \Nyholm\Psr7\Response(404, [], json_encode(['error' => 'Not Found'])));
continue;
}
$payload = json_decode($req->getBody()->getContents(), true);
if (!verifySign($payload, $config['sign_key'])) {
$psr7->respond(new \Nyholm\Psr7\Response(401, [], json_encode(['error' => 'Invalid Sign'])));
continue;
}
$order = $dbPool->query("SELECT order_id, status FROM orders WHERE order_no = ?", [$payload['order_no']]);
if (!$order) {
$psr7->respond(new \Nyholm\Psr7\Response(404, [], json_encode(['error' => 'Order Not Found'])));
continue;
}
$dbPool->execute("UPDATE orders SET status = 'paid', paid_at = NOW() WHERE order_id = ?", [$order['order_id']]);
$logger->info("order {$payload['order_no']} callback success");
$psr7->respond(new \Nyholm\Psr7\Response(200, ['Content-Type' => 'application/json'], json_encode(['code' => 0, 'msg' => 'success'])));
} catch (\Throwable $e) {
$logger->error($e->getMessage(), ['trace' => $e->getTraceAsString()]);
$psr7->respond(new \Nyholm\Psr7\Response(500, [], json_encode(['error' => 'Internal Error'])));
}
}
function verifySign(array $payload, string $key): bool { /* 同前 */ }
function loadConfig(): array { /* 同前 */ }
class MySQLPool { /* 同前 */ }
启动 RoadRunner:
vendor/bin/rr serve -c .rr.yaml -v # 前台运行,生产用 supervisor
效果数据
压测环境:
- 容器:8核 Intel Xeon Platinum 8255C @ 2.50GHz,16GB内存,Dedicated CPU
- 系统:Ubuntu 22.04.3 LTS,内核 5.15.0-91
- PHP:8.3.11
- MySQL:8.0.35,连接数上限 500
- 基础镜像:php:8.3-cli + php:8.3-fpm
- 压测工具:ApacheBench 2.3,本机压测不走外网
- 压测时长:每轮300秒,各跑3轮取中位值
- 业务:模拟订单回调(JSON验签 + 查订单 + 更新状态),全部有真实数据库读写
压测结果(3轮取中位):
| 指标 | PHP-FPM 8.3 | Swoole 5.1.5 | RoadRunner 2024.1.3 |
|---|---|---|---|
| QPS | 8,200 | 45,000 | 28,500 |
| 平均响应时间 | 12.2ms | 2.2ms | 3.5ms |
| P99 延迟 | 45ms | 8ms | 12ms |
| CPU 使用率 | 95% | 78% | 71% |
| 内存占用 | 560MB (16 workers × 35MB) | 约1.2GB(含Swoole运行时) | 约800MB(Go + PHP) |
| MySQL 连接数 | 峰值 250 | 峰值 25(连接池) | 峰值 30(连接池) |
| PHP 进程数 | 16 | 8 worker + 2 reactor | 8 PHP worker + Go 主进程 |
关键结论:
- Swoole 的 QPS 是 PHP-FPM 的 5.5 倍,平均延迟降到原来的 1/5。
- RoadRunner 是 PHP-FPM 的 3.5 倍,比 Swoole 低 37%。原因是 Go 和 PHP 之间多了 IPC 通信。
- 数据库连接数:PHP-FPM 峰值 250 个连接,因为每个进程每次请求都新建连接。Swoole 和 RoadRunner 复用了连接池,峰值只有 20-30 个。这意味着 MySQL 压力直接降了一个量级。
- 内存上 Swoole 反而最高(1.2GB),原因是连接池、常驻对象、Swoole 自身的堆内存。FPM 的 560MB 看着便宜,但框架初始化每次请求都重复分配、释放,这部分开销不计入内存。
为什么 Swoole 比 RoadRunner 快?
RoadRunner 的链路是:Go 接收 HTTP → 解析为 PSR-7 对象 → 序列化传给 PHP → PHP 反序列化 → 处理 → 序列化返回 → Go 响应。多了一层 IPC 开销。Swoole 直接在 PHP 进程内处理 HTTP,少了一跳。
避坑指南(我实际踩过的坑)
以下每一个坑都是我实际撞过的,写出来帮你省调试时间。
坑1:Swoole 的全局变量污染
在 Swoole 常驻内存模式下,PHP 的全局变量不会在请求间重置。我第一版代码里有个 $_SESSION 的静态变量,压测 1000 个请求后,所有请求的 session 都变成了第一个请求的值。
解法:业务代码里禁用全局变量,所有状态都显式通过函数参数传递。框架层的全局容器(如 Laravel 的 app())在 Swoole 下要谨慎使用,它会把单个请求的数据缓存在容器里。
坑2:单例模式在 Swoole 下变成跨请求共享
常见的设计模式陷阱。PHP-FPM 下,单例只在当前请求内生效。Swoole 下,单例常驻内存,第二个请求拿到的是第一个请求留下的连接、状态、缓存。如果你用 Laravel Octane 或 Hyperf,它们已经处理了这些,但如果自己写的原生 Swoole,必须自己清理。
解法:用 Swoole 协程上下文 (Coroutine::getContext()) 存储请求级数据,而不是全局变量。
坑3:supervisor 启动 RoadRunner 时的环境变量坑
RoadRunner 通过 server.command 启动 PHP worker 时,环境变量不会自动从 Go 进程传递。我遇到 APP_ENV=production 在 PHP 里拿不到,导致 .env 解析失败。
解法:在 .rr.yaml 显式声明环境变量,或者在 worker.php 里强制 putenv:
// worker.php 开头
if (getenv('APP_ENV') === false) {
putenv('APP_ENV=production');
}
坑4:ab 压测必须加 -k,否则测的是 TCP 连接开销
我第一次压测没加 -k,Swoole 和 RoadRunner 的成绩都低于 FPM——因为它们的 HTTP 解析器在非 Keep-Alive 下每请求都要新建 TCP 连接。加了 -k 之后,性能差距才真正体现出来。
解法:压测时严格保证对照组配置一致。除了 -k,还要保证请求体一致、压测时间一致、预热时间一致。
坑5:Swoole 的 max_request 不能设 0
我最初图省事,max_request => 0(不重启)。跑了3天后,一个内存泄漏的第三方包把进程吃到 4GB,PHP 直接 OOM,所有请求 502。查了一天。
解法:生产环境设置 max_request => 5000-10000,并配和 max_request_exec_time => 30 做兜底。内存泄漏不常见,但第三方库总会给你惊喜。
坑6:MySQL 的 wait_timeout 杀掉 Swoole 连接池
MySQL 8.0 默认 wait_timeout 是 8 小时。Swoole 常驻内存的连接池在空闲一段时间后,MySQL 服务端会主动断开连接。但 PHP 侧的 PDO 对象不知道,下次请求时 SQL 直接报 MySQL server has gone away。
解法:二选一:a) 执行 SQL 前先 SELECT 1 探活;b) 把 MySQL 的 wait_timeout 改为默认28800秒,同时PHP侧每5分钟定时重连。
怎么选:一张决策表
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 传统 Laravel/Symfony/ThinkPHP 项目,无改造意愿 | PHP-FPM + OpCache | 兼容性最好,改动最小,QPS够用就不折腾 |
| 新项目,API为主,追求极致性能 | Swoole + Hyperf | 性能上限最高,Hyperf 框架帮你处理了常驻内存的坑 |
| 已有 PHP 代码,逐步迁移到 Go 生态 | RoadRunner | Go 主进程负责接入层,PHP 只做业务,可以渐进式替换 |
| 长连接场景(WebSocket、IM) | Swoole | PHP-FPM 的短生命周期模型天然不适合长连接,RoadRunner 的 PHP worker 需要额外处理连接状态 |
| 公司后端以 Go 为主,历史 PHP 接口需要统一接入 | RoadRunner | Go 主进程可以直接复用公司现有的 Go 中间件体系 |
最后:我的建议
如果业务是给内部系统用的,QPS 不到 3000,别折腾 Swoole/RoadRunner。PHP-FPM 完全够用,团队也不需要学习新的部署方式。
如果业务在公网,流量波动大,或者要支撑 10 万以上日活用户的小型应用,Swoole 的收益非常明显。一台 2核4G 的机器跑 Swoole 能顶住 PHP-FPM 需要一台 8核16G 才能扛住的流量。
RoadRunner 适合已经在用 Go 的团队。它让 PHP 开发者和 Go 开发者可以各自用擅长的语言开发,由 RoadRunner 统一接管接入层。但多一层 IPC,性能比 Swoole 低 37%,还要学 Go 的部署链路。
我自己的线上选择:核心 API 服务全部切到 Swoole 5.1 + Hyperf 3.0,传统管理后台保持 PHP-FPM 不动。压测数据支撑,不搞一刀切。