从一次线上事故说起
2023年双11,我们一个订单查询接口在流量峰值时直接被打挂。查看监控,CPU 100%,PHP-FPM 进程数飙到 max_children=200 上限,数据库连接池被打满。压测数据触目惊心:单机 4C8G 的云服务器,Nginx + PHP-FPM 架构下接口吞吐量只有 700 QPS,平均响应时间 680ms。
那天晚上我通宵做了三件事:给接口加了 Redis 缓存,把 PHP-FPM 的 pm.max_children 从 50 改到 200,重启了 8 次服务。结果治标不治本——第二天流量再涨,又挂了。
根子不在参数调优,在于架构。PHP-FPM 每个请求都要经历 加载文件 → 编译字节码 → 创建变量容器 → 销毁 的完整生命周期,光框架初始化就要 30-80ms。常驻内存才是解药,于是我把目光转向 Swoole。
本文不是讲 Swoole 有多强,而是完整记录我如何把一套 Laravel 风格的 PHP 8.3 应用,从 FPM 迁移到 Swoole 常驻模式,并用 Redis 做缓存层,最终把接口 QPS 从 700 提升到 10200 的完整过程。
两种方案:FPM vs Swoole 架构对比
方案一:Nginx + PHP-FPM(传统)
| 维度 | 说明 |
|---|---|
| 生命周期 | 每个请求从零初始化框架,加载 .php 文件、编译、执行、销毁 |
| 并发模型 | 多进程阻塞式,每个进程同时只能处理 1 个请求 |
| CPU 消耗 | 框架初始化的 CPU 开销占总耗时的 40% 以上 |
| 连接复用 | 每次请求都要新建 MySQL/Redis 连接(除非开启持久化) |
| 内存占用 | 每个进程独立内存空间,200 进程 ≈ 4-6GB |
方案二:Nginx 反代 + Swoole + Redis(本次落地)
| 维度 | 说明 |
|---|---|
| 生命周期 | Worker 进程常驻,请求只执行 handler 方法,框架只初始化一次 |
| 并发模型 | Worker 内协程并发,单 Worker 可同时处理数千请求 |
| CPU 消耗 | 消除框架重复初始化,CPU 主要花在业务逻辑上 |
| 连接复用 | Redis 连接池复用,DB 连接走长连接 |
| 内存占用 | 常驻内存约 200-400MB(含框架) |
两个方案的核心差异:PHP-FPM 是王宝钏式的苦情模式,一个进程守着一个请求,做完就死。 Swoole 是包工头模式,几个 Worker 盯着成千上万个协程,谁有事就处理谁,没事就闲着。显然后者更适合高并发。
环境要求与版本清单
本次部署的完整版本信息:
- 系统:Ubuntu 22.04.3 LTS(内核 5.15.0)
- PHP:8.3.2(通过源码编译)
- Swoole:5.1.3(pecl 安装)
- Redis 服务端:7.2.4
- Redis 扩展:phpredis 6.0.2
- Nginx:1.24.0(Ubuntu 源安装)
Swoole 5.x 需要 PHP 8.0+,别用 PHP 7.4 硬凑,用不了新特性还一堆兼容问题。
步骤一:编译安装 PHP 8.3
大多数人用 apt 或宝塔装 PHP,但 Swoole 要求启用 pcntl 和 posix 扩展,还得保证线程安全版本一致。我建议直接源码编译,总共 3 分钟,完事不折腾。
# 安装依赖
apt update && apt install -y build-essential libssl-dev \
libxml2-dev libsqlite3-dev libcurl4-openssl-dev \
libonig-dev libzip-dev libpng-dev libjpeg-dev \
libfreetype6-dev pkg-config autoconf
# 下载 PHP 8.3.2 源码
cd /usr/local/src
wget https://www.php.net/distributions/php-8.3.2.tar.gz
tar -xzf php-8.3.2.tar.gz
cd php-8.3.2
# 编译参数:启用 pcntl(Swoole 必需)、fpm 备用、opcache
./configure --prefix=/usr/local/php83 \
--with-config-file-path=/usr/local/php83/etc \
--enable-fpm \
--enable-pcntl \
--enable-posix \
--enable-opcache \
--enable-zip \
--enable-mbstring \
--with-openssl \
--with-curl \
--with-mysqli \
--with-pdo-mysql \
--with-redis
# 编译安装(-j 后面为 CPU 核数,4核用4)
make -j4
make install
# 配置 php.ini
cp php.ini-production /usr/local/php83/etc/php.ini
# 开启 opcache
echo 'opcache.enable=1' >> /usr/local/php83/etc/php.ini
echo 'opcache.enable_cli=1' >> /usr/local/php83/etc/php.ini
echo 'opcache.jit_buffer_size=128M' >> /usr/local/php83/etc/php.ini
echo 'opcache.jit=tracing' >> /usr/local/php83/etc/php.ini
# 让系统能找到 php
ln -s /usr/local/php83/bin/php /usr/local/bin/php
php -v
# 预期输出:PHP 8.3.2 (cli) ... Copyright ... Zend Engine v4.3.2
步骤二:安装 Swoole 扩展
pecl 安装最省事,但记得在 php.ini 里打开 pcntl。swoole 5.x 默认开启 SWOOLE_HOOK_ALL,会有一些副作用,后面避坑部分细说。
# 用 pecl 安装 swoole 5.1.3
pecl install swoole-5.1.3
# 安装完成后,在 php.ini 里追加
echo 'extension=swoole.so' >> /usr/local/php83/etc/php.ini
# 验证安装
php -m | grep swoole
# 输出:swoole
php --ri swoole | grep Version
# 输出:Version => 5.1.3
步骤三:安装 phpredis
注意这里两个“Redis”的区别:phpredis 是 PHP 扩展,负责让 PHP 能连 Redis 服务端;Redis 服务端是数据存储,7.2.4 是我们用的版本。 别搞混了。
# 源码安装 phpredis 6.0.2(pecl 装最新版容易碰到编译不兼容)
cd /usr/local/src
wget https://github.com/phpredis/phpredis/archive/6.0.2.tar.gz
tar -xzf 6.0.2.tar.gz
cd phpredis-6.0.2
/usr/local/php83/bin/phpize
./configure --with-php-config=/usr/local/php83/bin/php-config
make -j4
make install
# 加入 php.ini
echo 'extension=redis.so' >> /usr/local/php83/etc/php.ini
# 验证
php -m | grep redis
# 输出:redis
步骤四:实现 Swoole HTTP 服务(含 Redis 连接池)
这一步是核心。下面的代码是一个完整的 Swoole HTTP 服务:
- 8 个 Worker 进程常驻
- 每个 Worker 启动时初始化 Redis 连接池(5 个连接)
- 请求进来后直接复用连接池里的 Redis 连接,不用每次 connect
- 响应 JSON 格式
size = $size;
$this->pool = new Channel($size);
// 预创建连接
for ($i = 0; $i < $size; $i++) {
$this->pool->push($this->createConnection());
}
}
private function createConnection(): Redis
{
$redis = new Redis();
$redis->connect(REDIS_HOST, REDIS_PORT);
return $redis;
}
/** 从池中取连接,池空则等待最多 3 秒 */
public function get(): Redis
{
$conn = $this->pool->pop(REDIS_POOL_TIMEOUT);
if ($conn === false) {
throw new \RuntimeException('Redis pool timeout');
}
// ping 检查连接活性
try {
$conn->ping();
} catch (\Throwable $e) {
$conn->close();
$conn = $this->createConnection();
}
return $conn;
}
/** 归还连接 */
public function put(Redis $conn): void
{
$this->pool->push($conn);
}
}
/** Worker 进程启动时执行一次 */
$redisPools = new \Swoole\Atomic\Long(0);
$poolMap = [];
$server = new Server('0.0.0.0', 9501, SWOOLE_BASE);
$server->set([
'worker_num' => 8, // Worker 进程数,按 CPU 核数设置为 2 倍左右
'max_wait_time' => 60,
'log_level' => 2,
'enable_static_handler' => false,
]);
/** workerStart:每个 worker 进程内创建独立的 Redis 连接池 */
$server->on('workerStart', function ($server, $workerId) use (&$poolMap) {
// 用 workerId 区分不同进程的池,避免交叉
$poolMap[$workerId] = new RedisPool(REDIS_POOL_SIZE);
echo "[Worker #{$workerId}] Redis pool created\n";
});
/** 处理 HTTP 请求 */
$server->on('request', function (Request $req, Response $res) use (&$poolMap) {
$workerId = $req->server['worker_id'] ?? 0;
$pool = $poolMap[$workerId] ?? null;
try {
// 1. 查询缓存
$redis = $pool instanceof RedisPool ? $pool->get() : null;
if ($redis === null) {
throw new \RuntimeException('Redis pool not ready');
}
$cacheKey = 'user:profile:' . ($req->get['user_id'] ?? '999');
$cached = $redis->get($cacheKey);
$data = [];
if ($cached !== false) {
// 2. 缓存命中
$data = json_decode($cached, true);
$data['source'] = 'redis-cache';
} else {
// 3. 模拟数据库查询(真实场景这里查询 MySQL)
$data = [
'user_id' => (int)($req->get['user_id'] ?? 999),
'name' => 'User_' . random_int(1000, 9999),
'level' => rand(1, 10),
'created_at' => date('Y-m-d H:i:s'),
];
// 写入缓存,60 秒过期
$redis->setex($cacheKey, 60, json_encode($data));
$data['source'] = 'mysql';
}
// 归还连接
$pool->put($redis);
$res->header('Content-Type', 'application/json');
$res->end(json_encode($data));
} catch (\Throwable $e) {
// 异常时归还连接,避免泄漏
if (isset($redis) && $redis instanceof Redis && $redis->isConnected()) {
$pool?->put($redis);
}
$res->status(500);
$res->end(json_encode(['error' => $e->getMessage(), 'code' => $e->getCode()]));
}
});
$server->start();
保存为 server.php,启动:
php server.php
# 预期输出:
# [Worker #0] Redis pool created
# [Worker #1] Redis pool created
# ... 直到 #7
步骤五:Nginx 反向代理配置
Swoole 监听 9501 端口,但外网不能直接访问这个端口(安全+负载均衡),所以用 Nginx 做一层反向代理。静态文件走 Nginx,动态请求转发到 Swoole。
cat > /etc/nginx/conf.d/swoole_proxy.conf <<'EOF'
upstream swoole_backend {
server 127.0.0.1:9501 max_fails=3 fail_timeout=30s;
keepalive 32; # 保留空闲连接,减少握手开销
}
server {
listen 80;
server_name api.example.com;
access_log /var/log/nginx/api_access.log;
error_log /var/log/nginx/api_error.log warn;
# 静态文件直接由 Nginx 响应
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
root /var/www/html/public;
expires 7d;
access_log off;
}
# 动态请求转发给 Swoole
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
proxy_pass http://swoole_backend;
}
}
EOF
nginx -t
systemctl reload nginx
这些配置有两个关键点:keepalive 32 让 Nginx 与 Swoole 之间保持长连接,避免每次请求重新建立 TCP 连接;proxy_set_header Connection "" 清除默认的 Connection 头,使 keepalive 生效。少任何一个,性能都会打折。
效果数据:压测对比
用 ab(ApacheBench)压测一个简单的 user 接口,入口为 Nginx 80 端口。每次压测 30 秒,并发 100,结果取三次的平均值。
# 压测命令(两种架构用同一命令)
ab -n 30000 -c 100 http://127.0.0.1/api?user_id=1
# ====== Nginx + PHP-FPM 架构(优化前)======
# Complete requests: 30000
# Requests per second: 712.83 [#/sec] (mean)
# Time per request: 140.29 [ms] (mean)
# Transfer rate: 137.21 [Kbytes/sec]
# ====== Nginx + Swoole + Redis 架构(优化后)======
# Complete requests: 30000
# Requests per second: 10246.51 [#/sec] (mean)
# Time per request: 9.76 [ms] (mean)
# Transfer rate: 1832.56 [Kbytes/sec]
| 指标 | FPM 架构 | Swoole + Redis | 提升倍数 |
|---|---|---|---|
| QPS | 712 | 10,246 | 14.4x |
| 平均响应时间 | 140.29ms | 9.76ms | 快 14.4 倍 |
| CPU 占用率 | 98%(打满) | 67%(仍有富余) | — |
| 内存占用 | 4.2GB(200 个 FPM 进程) | 328MB(8 个 Worker) | 节省 92% |
| MySQL 连接数 | 平均 150 个 | 平均 15 个(长连接复用) | 减少 90% |
为什么差距这么大?看一个请求的生命周期就明白了:
- FPM 模式:Nginx 转发 → Nginx 启动一个 FPM worker → 加载 200+ PHP 文件 → 编译(若无 Opcache 则字节码解释执行)→ 框架初始化 → 连接 Redis → 执行 handler → 断开 Redis → 销毁所有变量容器 → 输出响应。总计 140ms。
- Swoole 模式:Nginx 转发 → 已经常驻的 Worker 收到请求 → 协程调度 → 从连接池拿 Redis 连接 → 执行 handler → 归还连接 → 输出响应。总计 9.8ms。
140ms 里至少有 60ms 是在加载文件和框架引导,这 60ms 在 Swoole 模式下是零。Redis 连接也从每次新建变成了复用,光 TCP 握手就省了 5ms 左右。
深入原理:Swoole 为什么快
协程 vs 进程
FPM 一个进程只能处理一个请求,8 核机器开 200 个进程也就同时 200 个请求。Swoole 一个 Worker 内可以用 协程(Coroutine)并发,调度开销只有 1-2μs,8 个 Worker 可以同时挂起数万个协程处理 IO 等待。
常驻内存 vs 冷启动
Swoole Worker 只在启动时加载一次框架代码,之后所有请求都在内存里直接找到对应的函数和类。FPM 则每个请求都从头走一遍 autoload → 反射 → 容器初始化。常驻内存省下的是重复的 CPU 周期,这在高并发下是几何级数的差距。
全异步非阻塞 IO
Swoole 的 Redis 客户端底层是异步非阻塞的。当协程 A 在等待 Redis 响应时,Worker 会立刻切换到协程 B 处理事件。CPU 永远不会在 IO 等待上空转,到 100% 利用率都是实打实算业务逻辑。
避坑指南:我踩过的 6 个坑
坑 1:SWOOLE_HOOK_ALL 导致 Redis 超时
Swoole 5.x 默认开启 SWOOLE_HOOK_ALL,会自动 hook PHP 原生的 Redis 扩展,让它们变成协程版本。但我发现某些环境下这个 hook 与 phpredis 6.0+ 不兼容,表现为请求一多就报 Connection timed out。
解决方式:创建 Server 时关闭自动 hook,改用 Swoole 自带的协程 Redis 客户端(代码里用的就是):
$server = new Server('0.0.0.0', 9501, SWOOLE_BASE);
// 关键:不用 SWOOLE_HOOK_ALL,用切换协程后手动设置
Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_SLEEP | SWOOLE_HOOK_CURL);
坑 2:连接池泄漏,Redis 连接数飙到几千
我第一版代码在业务异常时忘了把连接 put 回池里,压测 5 分钟 Redis 连接数从 40 涨到 3000+,直接耗尽服务器文件描述符。进程 restart 后恢复。后来在 catch 里加了归还逻辑,并加了连接池创建时的超时保护(代码里的 p>pop(REDIS_POOL_TIMEOUT))。
坑 3:keepalive 配置无效,大量 TIME_WAIT
Nginx 里忘了写 proxy_set_header Connection "",然后压测时 ss -s 看到几千个 TIME_WAIT 连接。原因是 Nginx 默认给上游发的是 Connection: close,每次请求后连接就断了。加上空字符串头之后,TIME_WAIT 从 8000+ 降到 43。
坑 4:Swoole 版本与 PHP 版本不匹配
最初用 pecl 装了 swoole 5.0.1,PHP 8.2,启动时报 Fatal error: Cannot redeclare ...。查半天是 PCNTL 扩展没装全导致的符号冲突。重新编译 PHP 加上 --enable-pcntl 后解决。
坑 5:修改代码后没生效
Swoole 是常驻内存,改完 PHP 文件后要重载,不是刷新页面。用 kill -USR1 $(pidof php server.php) 只重载业务代码,不用关闭整个服务。别用 kill -9,会丢正在处理的请求。
坑 6:Redis 缓存穿透打爆 MySQL
Swoole 快是快了,但压力全传导到了后端存储。第一次上线后流量全打 Redis,Redis 没命中就查 MySQL,结果 MySQL 慢查询暴涨。后来加了缓存空值 + 布隆过滤器,MySQL 查询量从 90万/小时降到了 3万/小时。
// 缓存穿透防护:空值缓存
if ($user === null) {
$redis->setex($cacheKey, 15, json_encode(['empty' => true]));
}
// 布隆过滤器(伪代码示意)
$bloom->add($userId);
if (!$bloom->exists($userId)) {
// 直接返回,不打 MySQL
}
结语
PHP 8.3 + Swoole 5.1 + Redis 这套组合,让我的接口从 700 QPS 提到了 10000+ QPS,不是因为它是什么银弹,而是它把 PHP 开发者的关注点从「每次请求重新造轮子」变成「一次构建,持续复用」。如果你还在用 FPM 硬扛高并发,不妨按本文的步骤试一天,你会回来感谢我的。