PHP-FPM vs Swoole vs RoadRunner:三种运行模式实测
发布日期: 2026/08/14 阅读总量: 0

一次压测事故,让我重新审视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.3pre-fork + 请求级生命周期8,200每进程约35MB,16进程约560MB传统Web应用,通用,兼容性最佳
Swoole 5.1.5常驻内存 + 事件驱动45,000Worker固定约300MB(含业务常驻数据)API网关、长连接、微服务
RoadRunner 2024.1.3Go管理进程 + PHP Worker复用28,000PHP 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 进程的生命周期是:

  1. 等待请求
  2. 收到请求
  3. 框架初始化(加载所有依赖、配置文件)
  4. 执行业务代码
  5. 关闭连接、释放资源
  6. 回到等待状态,准备处理下一个请求

问题在第三步。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 进程启动后加载一次框架,之后所有请求复用同一份已加载的代码和连接池。

这就意味着:

  1. 框架初始化只做一次
  2. MySQL/Redis 连接复用
  3. 外部依赖对象复用,不用每次重建
  4. 业务代码热执行,没有进程销毁重建的开销

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.3Swoole 5.1.5RoadRunner 2024.1.3
QPS8,20045,00028,500
平均响应时间12.2ms2.2ms3.5ms
P99 延迟45ms8ms12ms
CPU 使用率95%78%71%
内存占用560MB (16 workers × 35MB)约1.2GB(含Swoole运行时)约800MB(Go + PHP)
MySQL 连接数峰值 250峰值 25(连接池)峰值 30(连接池)
PHP 进程数168 worker + 2 reactor8 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 生态RoadRunnerGo 主进程负责接入层,PHP 只做业务,可以渐进式替换
长连接场景(WebSocket、IM)SwoolePHP-FPM 的短生命周期模型天然不适合长连接,RoadRunner 的 PHP worker 需要额外处理连接状态
公司后端以 Go 为主,历史 PHP 接口需要统一接入RoadRunnerGo 主进程可以直接复用公司现有的 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 不动。压测数据支撑,不搞一刀切。