引言
传统 PHP 部署模型是「一次请求、一次启动」:Nginx 把请求转发给 PHP-FPM,FPM 分配一个 worker 进程,脚本从头执行、加载框架、连接数据库、渲染响应,然后销毁全部内存。这个模型简单可靠,但每一次请求都要重新 bootstrap 整个框架——一次 Laravel 启动就要数百毫秒和几十 MB 内存。现代运行时的核心思路,是把「启动一次、常驻服务」引入 PHP,让应用像 Go/Node 那样在内存里长住,只把请求当作事件来处理。
本文聚焦两条最主流的常驻路线:FrankenPHP(把 PHP 编译进 Caddy Web 服务器)与 RoadRunner(Go 写的主进程 + PHP worker 进程)。它们都不需要改写代码即可获得数倍吞吐,但也引入了 FPM 时代没有的新问题——内存泄漏、状态污染、优雅停机。本文从架构讲起,给出可运行的配置、压测方法与迁移清单。
目录
- 1. PHP-FPM 的瓶颈在哪里
- 2. FrankenPHP 架构:PHP 内嵌 Caddy
- 3. Worker 模式与请求生命周期
- 4. RoadRunner:Go 驱动的应用服务器
- 5. 内存泄漏与状态污染防治
- 6. 平滑重启与优雅停机
- 7. 容器化部署与调优
- 8. 压测对比与选型
- 延伸阅读
1. PHP-FPM 的瓶颈在哪里
1.1 每请求 bootstrap 的成本
一次典型的 Laravel 请求在 FPM 下的开销分布:
| 阶段 | 耗时(冷) | 说明 |
|---|---|---|
| 进程启动 + ini 解析 | 1~3 ms | 每个 worker 首次 |
| Composer autoload 建立 | 5~20 ms | 数千个 classmap 项 |
| 框架 bootstrap | 30~120 ms | 服务容器、Provider、中间件注册 |
| 数据库连接建立 | 5~30 ms | 无连接池时每次重连 |
| 业务逻辑 | 变化 | 真正的有效工作 |
也就是说,大量 CPU 花在了「把环境准备好」而不是「干活」上。OPcache 能省掉编译,但省不掉对象构造与容器装配。
1.2 常驻内存能省掉什么
把 bootstrap 提到进程启动时执行一次,之后每个请求只跑「业务段」:
FPM: [bootstrap][业务] [bootstrap][业务] [bootstrap][业务]
常驻: [bootstrap] → [业务] [业务] [业务] [业务] ...
代价是:全局变量、静态属性、单例在请求之间不会自动重置,必须显式清理;任何一次内存泄漏都会随时间累积。
记忆:常驻运行时用「一次性 bootstrap」换吞吐,但把「请求隔离」的责任从运行时转移给了开发者。
2. FrankenPHP 架构:PHP 内嵌 Caddy
2.1 为什么是 Caddy
FrankenPHP 是一个用 Go 写的 PHP SAPI 实现,它把 PHP 解释器作为库嵌入 Caddy。Caddy 本身是生产级 Web 服务器,天然带自动 HTTPS(Let’s Encrypt)、HTTP/2、HTTP/3(QUIC)、优雅重载。传统栈是「Nginx → FastCGI → FPM → PHP」,FrankenPHP 把这条链压成「Caddy(含 PHP)」,少了一跳 FastCGI 序列化。
传统: Client → Nginx → FastCGI → PHP-FPM → PHP
FrankenPHP: Client → Caddy (Go) → 内嵌 PHP ← 同一进程,无 socket 往返
2.2 用 Docker 起步
FROM dunglas/frankenphp:1-php8.3
RUN install-php-extensions pdo_mysql redis opcache intl zip
COPY . /app/public/
WORKDIR /app
# 生产用 worker 模式
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
2.3 Caddyfile 配置
{
frankenphp {
num_threads 8 # 每线程一个 PHP 解释器实例
worker /app/public/index.php 16 # 预启动 16 个 worker
}
auto_https on
}
example.com {
root * /app/public
encode zstd br gzip
php_server {
try_files {path} {path}/index.php
}
}
num_threads 决定并发解释器数,worker 指令后的数字是预置 worker 数量。worker 数不必等于线程数——一个线程可轮流服务多个 worker。
记忆:FrankenPHP = Caddy(Go)内嵌 PHP SAPI;
frankenphp { num_threads N; worker <入口> M }控制线程与常驻 worker 数,省去 FastCGI 一跳。
3. Worker 模式与请求生命周期
3.1 worker 脚本写法
FrankenPHP 的 worker 模式要求入口脚本自己实现「取请求 → 处理 → 重置」的循环:
<?php
// public/index.php
$handler = static function () use ($kernel) {
$request = \Illuminate\Http\Request::capture();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 500);
for ($i = 0; $i < $maxRequests; $i++) {
frankenphp_handle_request($handler); // 阻塞等待下一个请求
gc_collect_cycles(); // 主动回收循环引用
}
frankenphp_handle_request() 内部会在两次调用之间重置 PHP 的 superglobal($_GET/$_SERVER 等),但不会重置你自己写的单例、静态缓存、容器里绑定的对象。
3.2 请求之间必须重置的东西
| 项目 | 风险 | 处理方式 |
|---|---|---|
| 静态属性缓存 | 跨请求串数据 | 每请求显式清空 |
| 单例里的用户态 | 用户 A 的数据泄漏给用户 B | 用请求作用域重建 |
| 已开事务 | 悬挂连接 | 请求末尾 rollback 未提交事务 |
| 累加的日志/计数器 | 内存单调增长 | 定期重启或重置 |
Laravel 官方提供了 Illuminate\Foundation\Application::flush() 与 Octane 的 RequestTerminated 事件来辅助重置,自研框架则要自己保证。
记忆:worker 模式只重置 superglobal,不重置你的单例——「谁持有跨请求状态,谁负责清理」是常驻应用的第一条纪律。
4. RoadRunner:Go 驱动的应用服务器
4.1 架构
RoadRunner 的主进程是 Go 二进制,负责 HTTP、gRPC、队列、WebSocket 等协议处理;PHP 侧是一个常驻的 worker 进程池,通过 Goridge(自定义二进制协议,基于管道/socket)与 Go 主进程通信。请求字节从 Go 传到 PHP,PHP 返回响应字节,Go 再写回客户端。
Client → RoadRunner(Go):路由/TLS/限流/静态文件
│ Goridge(管道)
▼
PHP worker 进程池(常驻,复用框架)
因为 Go 层处理了网络与协议,PHP 侧只需专注业务,且天然支持 gRPC、队列、KV、指标等插件。
4.2 安装与 .rr.yaml
composer require spiral/roadrunner-cli spiral/roadrunner-http
./vendor/bin/rr get-binary # 下载与当前 PHP 匹配的 rr 二进制
# .rr.yaml
version: "3"
rpc:
listen: tcp://127.0.0.1:6001
server:
command: "php ./vendor/bin/rr-worker" # 或用 Laravel Octane 的入口
relay: pipes
env:
- APP_ENV: production
http:
address: 0.0.0.0:8080
middleware: ["gzip", "static"]
pool:
num_workers: 8
max_jobs: 500 # 处理 500 个请求后回收 worker
supervisor:
max_worker_memory: 128 # MB,超过则重启该 worker
static:
dir: public
forbid: [".php", ".htaccess"]
max_jobs 与 max_worker_memory 是防治内存泄漏的第一道防线:worker 处理固定请求数或被内存超限后,被 Go 主进程优雅回收并重启,无需人工干预。
4.3 与 Laravel Octane 结合
Laravel 应用通常不手写 worker,而是通过 Octane 适配:
composer require laravel/octane
php artisan octane:install --server=roadrunner
php artisan octane:start --server=roadrunner --workers=8 --max-requests=500
Octane 会在请求之间自动调用容器的重置钩子,并清理已注册的 Request 实例,减少手工重置的工作量。
记忆:RoadRunner = Go 主进程 + PHP 常驻 worker,靠 Goridge 通信;
max_jobs与max_worker_memory是内存泄漏的兜底阀。
5. 内存泄漏与状态污染防治
5.1 定位泄漏
常驻进程最怕「每请求泄漏几百 KB」。用周期性采样观察:
// 在每个请求处理完后记录
$mem = memory_get_usage(true);
error_log(sprintf('req=%d mem=%.2fMB peak=%.2fMB',
$i, $mem / 1048576, memory_get_peak_usage(true) / 1048576));
若 mem 随请求数单调上升,几乎可以断定存在引用未被释放。常见来源:
- 全局事件监听器在每次请求重复注册(
Event::listen未去重); - 静态属性
static $cache = []只增不减; - 长生命周期对象互相持有引用形成环,需要
gc_collect_cycles()才回收; - 数据库连接/游标未关闭。
5.2 用限制代替修复
即使短期无法定位所有泄漏,也可以先用「主动回收 + 定期重启」兜底:
if (memory_get_usage(true) > 200 * 1024 * 1024) {
// 让当前 worker 处理完本请求后退出,由管理器重启
exit(0);
}
配合 RoadRunner 的 max_worker_memory 或 FrankenPHP 的 MAX_REQUESTS 上限,可把单次泄漏的影响限制在一个 worker 生命周期内。
记忆:先量化(每请求记内存),再定位(事件重复注册/静态缓存/引用环),最后兜底(内存上限触发 worker 重启)。
6. 平滑重启与优雅停机
6.1 为什么要优雅停机
常驻进程重启时,若直接 kill,正在处理的请求会被截断,用户看到 502。优雅停机要求:停止接收新请求 → 等待在途请求完成 → 再退出。
6.2 FrankenPHP / Caddy
# 重载配置,Caddy 会平滑切换,不中断在途连接
frankenphp reload --config /etc/caddy/Caddyfile
# 发送 SIGUSR1 触发 worker 轮换
docker kill --signal=USR1 <container>
6.3 RoadRunner
./rr reset # 平滑重启所有 worker
./rr workers # 查看 worker 池状态
./rr serve -d # 后台运行
RoadRunner 在收到新代码部署后,rr reset 会逐个替换 worker,保证服务不中断。
6.4 部署流水线里的位置
# 部署脚本片段
- git pull
- composer install --no-dev --optimize-autoloader
- php artisan config:cache && php artisan route:cache
- ./rr reset # 或 frankenphp reload
记忆:优雅停机 = 停止收新 + 等在途 + 再退;FrankenPHP 用
reload/SIGUSR1,RoadRunner 用rr reset。
7. 容器化部署与调优
7.1 多阶段构建
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader
FROM dunglas/frankenphp:1-php8.3 AS runtime
RUN install-php-extensions pdo_mysql opcache
COPY --from=vendor /app/vendor /app/vendor
COPY . /app
RUN php artisan config:cache && php artisan route:cache
7.2 关键调优参数
| 参数 | 建议 | 说明 |
|---|---|---|
num_threads / num_workers | = CPU 核数 × 1.5 | 过高会争抢 CPU |
max_jobs / MAX_REQUESTS | 300~1000 | 越短越安全、越短越费 |
max_worker_memory | 128~256 MB | 依据应用实际占用设定 |
OPcache validate_timestamps | 生产设为 0 | 免去每次 stat 检查 |
OPcache jit | tracing | 计算密集场景收益明显 |
OPcache 预加载(opcache.preload)在常驻模式下收益尤其大,因为整个进程生命周期只需加载一次。
7.3 容器信号与 PID 1
Go 主进程应作为容器的 PID 1,直接接收 SIGTERM 并转发给 worker。避免在 shell 里包一层导致信号无法传递:
# 正确:Go 二进制直接作为入口
ENTRYPOINT ["/usr/local/bin/frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
# 错误:sh -c "frankenphp ..." 会吞掉信号
记忆:常驻容器里 Go 主进程必须是 PID 1;OPcache 关时间戳校验、开预加载,是常驻模式的两个免费提速。
8. 压测对比与选型
8.1 基准测试
用 wrk 或 k6 对比同一应用的三种部署:
wrk -t4 -c200 -d30s --latency http://localhost:8080/api/ping
# k6 脚本化
k6 run --vus 200 --duration 30s script.js
在典型 Laravel「返回 JSON」场景下,常驻运行时相对 FPM 的吞吐提升通常在 2~5 倍,P99 延迟下降更明显,因为省掉了每请求的 bootstrap 与连接重建。
8.2 选型对照
| 维度 | FrankenPHP | RoadRunner | Swoole |
|---|---|---|---|
| 语言 | Go(Caddy) | Go | C++ 扩展 |
| 协议支持 | HTTP/1.1/2/3、自动 HTTPS | HTTP、gRPC、队列、WS | HTTP、TCP、WS |
| 部署复杂度 | 低(单二进制) | 中(需 rr 二进制) | 低(PECL 扩展) |
| 生态适配 | Laravel Octane、Symfony | Laravel Octane、Spiral | Laravel Octane、Hyperf |
| 适用 | 需要 HTTPS/HTTP3 的边缘服务 | 需要 gRPC/队列一体化 | 深度定制协程应用 |
如果团队已经在用 Nginx + FPM 且不想大改运维,FrankenPHP 的迁移成本最低(一个二进制替换整套栈);如果需要内建 gRPC、队列、KV 等一整套组件,RoadRunner 更合适;如果要做极致的协程控制与自定义网络协议,Swoole 的灵活性最高。
8.3 什么场景不适合常驻
- 代码频繁热改的本地开发(每次改都要重启 worker,反而更慢);
- 单次执行、无复用的批处理脚本;
- 依赖大量全局状态且难以重置的遗留系统。
这些场景继续用 FPM 更省心。是否迁移的判断标准很简单:请求量大到「bootstrap 成本」成为瓶颈时,常驻才划算。
记忆:常驻运行时适合高 QPS、代码稳定的服务;本地开发、一次性脚本、强全局状态的遗留系统留在 FPM。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。