PHP-FPM/Swoole/RoadRunner如何选型
发布日期: 2026/08/19 阅读总量: 1

一次把我逼到加班的线上事故

凌晨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小时以上看内存增长曲线)。