FrankenPHP worker 模式真香之前,先过一遍这三个坑

2026-10-05 0 723

先把结论放前面。把项目从 php-fpm 切到 FrankenPHP 的 worker 模式之后,同一个接口的 QPS 从 340 涨到了 1420,P99 从 240ms 降到 68ms。但代价是花了两周时间排查一堆诡异的 bug,最后一个坑到今天还在持续观察。

这篇文章把我踩过的坑和排查过程写下来。如果你正好在考虑 FrankenPHP,或者已经上了但遇到了一些奇怪的现象,这篇大概能帮你省点时间。

FrankenPHP 到底是个什么东西

一句话说清楚:它一个用 Go 写的 PHP 应用服务器。你可以把它理解成「Caddy + PHP 解释器 + 一套跟 PHP 深度整合的接口」。

为什么最近火起来?因为它带来了一件 php-fpm 一直做不到的事——常驻内存的 worker 模式。

php-fpm 的工作方式是:每个请求进来,框架从头加载一遍,路由、容器、配置文件、各类 Provider 全部重新构建,处理完请求,进程状态清零等下一个请求。这个过程至少要消耗 20 到 50 毫秒,规模大了之后这部分开销占比很可观。

FrankenPHP 的 worker 模式把这个过程提前做了一次。worker 启动的时候把框架加载好,之后所有请求复用这份已加载的状态。省掉的就是每个请求从头 bootstrap 的那部分时间。

安装和第一次启动

官方推荐用 Docker,本地开发的话也可以直接用二进制。

# 从官方镜像启动一个最小示例
docker run -p 80:80 -p 443:443 
  -v $PWD:/app 
  -v caddy_data:/data 
  -v caddy_config:/config 
  dunglas/frankenphp

如果用二进制方式:

curl https://frankenphp.dev/install.sh | sh
sudo mv frankenphp /usr/local/bin/

没有 Caddyfile 的时候,它在当前目录下找 public/index.php,走的是传统的 php-fpm 那种「一个请求一个生命周期」的经典模式。要让 worker 生效,得写一个 Caddyfile:

{
    frankenphp
    order php_server first
}

localhost {
    root * /app/public
    encode zstd br gzip

    php_server {
        try_files {path} {path}/index.php index.php
        worker {
            file /app/public/index.php
            num 8
            env APP_ENV production
            env APP_DEBUG false
        }
    }
}

num 8 是 worker 数量。经验值是 CPU 核心数乘以 1 到 2。CPU 密集型的项目可以少一点,IO 密集型的可以多一点。如果服务器是 4 核,8 到 12 都可以试。

Laravel 项目的改造

Laravel 从 10 版本开始就原生支持 FrankenPHP 的 worker 模式,官方文档里有专门的段落。Laravel Octane 的启动命令加个参数就行:

php artisan octane:frankenphp --workers=8 --max-requests=2000

如果不装 Octane,也可以自己写一个 worker 入口。核心思路是接收一个循环,每个请求进来处理完,把状态清一下。一个最小的自定义 worker 长这样:

<?php
// public/index.php 之外,另起一个 worker.php
require __DIR__ . '/../vendor/autoload.php';

$app = require_once __DIR__ . '/../bootstrap/app.php';
$kernel = $app->make(IlluminateContractsHttpKernel::class);

$handler = static function () use ($kernel) {
    $request = IlluminateHttpRequest::capture();
    $response = $kernel->handle($request);
    $response->send();
    $kernel->terminate($request, $response);
};

// FrankenPHP 会持续调用这个 handler
while (FrankenPHPworker_handle_request($handler)) {
    // 每个请求之后清理请求级状态
    $kernel->terminate(...);
    gc_collect_cycles();
}

用 Octane 的话这些细节它都处理了,但要知道它做了什么,不然遇到问题没法排查。

第一个坑:全局状态污染

改动完成之后,本地开发环境一切正常。功能测试全过,接口返回也正确。上线灰度到 10% 流量之后,收到几个奇怪的投诉:

  • 用户 A 登录之后能看到用户 B 的数据
  • 某些请求返回结果是上一个请求的
  • 日志里打印的用户 ID 跟实际处理的用户不匹配

这几种现象放在一起,只有一个可能:请求之间的状态没清干净。

php-fpm 模式下,每个请求都是全新的 PHP 进程上下文,全局变量、静态变量、单例全部重新初始化。所以在 php-fpm 里写代码时,很多人会随手用 static 变量或者全局单例来缓存东西,根本不用考虑清理——反正下个请求是全新进程。

到了常驻内存的 worker 模式,这些「记忆」会一直存在。

我遇到的第一个具体问题是这个:项目中有一个 TenantContext 类,用来在多租户环境下保存当前租户 ID。代码大致是这样:

class TenantContext
{
    private static ?int $tenantId = null;

    public static function set(int $id): void
    {
        self::$tenantId = $id;
    }

    public static function get(): ?int
    {
        return self::$tenantId;
    }
}

中间件在最前端调用 TenantContext::set(),业务代码里到处引用 TenantContext::get()。在 php-fpm 里跑得好好的——每个请求都是新进程,self::$tenantId 初始就是 null,中间件一定会把它设对。

worker 模式下就出事了。如果某个请求没有走中间件(比如健康检查、静态资源、某些内部路由),self::$tenantId 就保留着上一个请求设置的值。更糟的是,如果中间件里 set 之前抛异常了,也可能留下一半的脏状态。

修复方式有好几种。

方案一:把静态改成请求级。用 Laravel 的容器绑定 scoped:

// AppServiceProvider 里
$this->app->scoped(TenantContext::class);

class TenantContext
{
    private ?int $tenantId = null;

    public function set(int $id): void { $this->tenantId = $id; }
    public function get(): ?int { return $this->tenantId; }
}

Laravel 的 scoped 绑定会在每次请求结束时自动重新解析,天然防了这个问题。改造要动的地方不少,但一劳永逸。

方案二:每个请求显式清理。在中间件或者事件监听器里 Reset:

Event::listen(Terminating::class, function () {
    TenantContext::reset();
});

这种方式适合改造量太大、短期内没法重构的项目。但容易漏,加一个新的静态属性忘了加 reset 就又是坑。

方案三:开 Octane 的严格模式。如果用的是 Octane,配置文件里有一个 warm 和 flush 的配置项,可以把特定的服务标记为「每次请求重新构造」。这个也值得用。

我当时的处理是:找到项目里所有 static $ 定义的属性,一个个过一遍,判断哪些是「随请求变化」的,全部改成 scoped 或者增加 reset。搜出来十几处,事后想想,其实这些代码在 php-fpm 下也是隐患,只是被进程隔离掩盖了。

第二个坑:数据库连接老化

第一个坑解决之后,功能稳定性没再出问题。但接下来出现了另一个现象:服务跑一段时间之后(大概两三个小时),出现零星的 MySQL server has gone away 或者 Packets out of order 错误。

频率不高,每小时可能就几条,但每次报出来都会让一个用户请求失败。

这个问题的根源是:worker 进程是常驻的,它持有的数据库连接也一直不释放。

php-fpm 里,每个请求结束之后连接会被回收(或者交给连接池复用),连接的空闲时间很短。而 worker 模式下,一个 worker 处理完请求后不退出,它持有的 PDO 连接会一直挂着。如果这个 worker 后续一段时间没有流量(比如夜里),连接就一直空闲。MySQL 服务端或者中间的网络设备(比如防火墙、云厂商的 NAT)会把长时间空闲的连接断掉。下次这个 worker 拿到请求,用这条已经被服务端关闭的连接去查询,就报错。

这个问题在 php-fpm 时代不常见,所以网上一搜,很多人没遇到过,得自己判断。

解法有三种,我最后用的是第二种。

方案一:配置 MySQL 的 wait_timeout 和 PDO 的断线重连。把 MySQL 的 wait_timeout 调大,让服务端不那么快断。这个是治标,中间如果有 NAT 设备还是会断。

方案二:在 Laravel 的数据库配置里加「心跳检测」。Laravel 11 支持 PDO 的 Options 里加 PDO::ATTR_PERSISTENT => false,但这个是默认行为。真正有用的是给每个请求前做一次 ping:

Event::listen(RequestReceived::class, function () {
    $connection = DB::connection();
    try {
        $connection->getPdo();
    } catch (Throwable $e) {
        $connection->reconnect();
    }
});

这样每次请求前主动探一下连接是否有效,无效就重连。有一点性能开销,但很小。

方案三:给 worker 设最大处理请求数。Octane 有个 --max-requests 参数,worker 处理完指定数量的请求之后自动重启。设成 2000 到 5000 比较合适,能显著减少连接老化的概率,也能顺带缓解内存泄漏。代价是每次重启有几十毫秒的冷启动。

我是方案二加方案三一起用的,稳定运行了三个月没再出现过这个问题。

第三个坑:内存泄漏

第三个坑到现在还在持续观察。

上线两周之后,运维那边发现每个 worker 进程的常驻内存会缓慢上涨。从 60MB 涨到 200MB 大概需要一个星期,涨到 300MB 以上就会触发 OOM 告警。

这个问题在 php-fpm 时代是完全看不见的——每次请求都换进程,就算有点内存没释放也无所谓。常驻内存之后,任何一点泄漏都会被累积放大。

常见的泄漏来源有这些:

  • 事件监听器重复注册。比如某个 ServiceProvider 的 boot 方法里每次都 Event::listen,worker 模式下 boot 只跑一次,但如果你在请求周期里又调了一次,就会叠加。
  • 静态缓存无限增长。项目里有一些手写的缓存结构,用 static $cache = [] 保存,处理过程中不断往里塞,永远不清。
  • 闭包引用。某些监听器或者事件队列里的事件对象持有大对象引用,处理完没释放。
  • 日志上下文。Monolog 的 buffer handler 如果把所有日志都留着不刷盘,会堆积。

我用的排查工具是 php-meminfo 或者更简单的 memory_get_usage(true)。在 worker 循环里周期性打点:

$lastReport = 0;
$handler = function () use (&$lastReport) {
    // ... 处理请求

    $now = time();
    if ($now - $lastReport > 60) {
        Log::info('worker-stat', [
            'pid' => getmypid(),
            'memory' => memory_get_usage(true),
            'peak' => memory_get_peak_usage(true),
        ]);
        $lastReport = $now;
    }
};

把这些打点接到监控上,观察哪一类请求之后内存增长最快,再去对应的代码里找泄漏。

我最后找到的两处:一个是在某个日志处理器里,每次请求都把 Request 对象塞进了一个静态的数组里(历史遗留的「调试」代码没删);另一个是自研的一个缓存组件,为了「方便调试」把每次缓存命中的 key 都存进了内存。

两处都改掉之后,内存曲线基本平了。到现在每周增长幅度小于 5MB,属于可以接受的范围。

关于性能的账,得算清楚

除了坑,也得说说这次的收益。同一个接口,同样的服务器配置,用 wrk 各压一分钟:

指标 php-fpm + opcache FrankenPHP worker 模式
QPS 340 1420
平均响应 29ms 7ms
P99 响应 240ms 68ms
CPU 占用(峰值) 78% 42%
单机内存 520MB 640MB

QPS 提升四倍,这个数字里有一半来自省掉的 bootstrap 开销,另一半来自 FrankenPHP 的 Go 运行时对 HTTP 层的优化。CPU 占比下降说明单位请求的成本低了,这是最实在的收益。

内存占用增加了,因为 worker 进程常驻,每个进程都持有一份完整的框架状态。但相比四倍的吞吐,这点内存换的是划算的。

上生产之前要做的检查

如果你打算从 php-fpm 迁到 FrankenPHP worker 模式,下面这份清单建议过一遍:

  1. 搜一遍项目里的 static $。每一处都问自己一遍:这个值会跨请求变化吗?会变的就改成请求作用域。
  2. 搜一遍 global 关键字。老项目、Laravel 6 之前的老代码里可能有,处理逻辑跟 static 一样。
  3. 搜一遍 ServiceProvider 里的 Event::listen。确认它们都在 boot 阶段注册,而不是在控制器或者中间件里随时注册。
  4. 把所有日志、监控、追踪上报的 channel 检查一遍。有些上报库会在内存里做缓冲,得确认缓冲有上限。
  5. 检查数据库和 Redis 的连接配置。加断线重连、加 heartbeat、或者干脆上 max-requests 定期重启。
  6. 把 --max-requests 设成 2000 到 5000。这是最后的保险,别设太大。
  7. 加一个内存监控。worker 进程的内存曲线要能看到,否则出问题只能等 OOM。
  8. 先在非核心服务上灰度。订单、支付这类不能错一个字节的服务,第一次先别上。

最后

FrankPHP 的 worker 模式我是真觉得值得用,但它不是一个「开关一打开就变快」的东西。它把 php-fpm 里靠进程隔离替你挡掉的一堆隐患暴露出来了。这些隐患在 php-fpm 时代也存在,只是被隔离掩盖着,你感知不到。换到 worker 模式,等于把它们摆到了台面上,逼你去面对。

我觉得这是好事。代码写得健不健康,以前靠压力测试碰运气,现在上 worker 模式两三天就给你暴露出来。虽然过程痛苦,但改完之后整个项目会更稳。

希望这篇能帮你在决定之前,把几个核心的风险点想清楚。真上了之后遇到更多具体问题,欢迎交流。

FrankenPHP worker 模式真香之前,先过一遍这三个坑
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php FrankenPHP worker 模式真香之前,先过一遍这三个坑 https://www.taomawang.com/server/php/2892.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务