一次凌晨的Redis连接数告警
凌晨1点42分,监控告警:Redis maxclients 达到上限,线上接口开始大量 502。登录服务器一看,INFO clients 显示连接数 10000,mem 分配了 4.2GB。当时服务是标准的 Nginx + PHP-FPM + phpredis,每个 PHP 请求里 new Redis() → connect() → get() → close(),高峰期 PHP-FPM 起了 256 个进程,每个进程又开了 10 个连接,瞬间打爆 Redis。
这不是个例。PHP-FPM 的短生命周期模型决定了每个请求都要重复「建连→查询→断开」,Redis 的连接处理是纯内存操作,本身不慢,但 TCP 握手 + 三次挥手 + 进程切换的开销全压在 Redis 上。一台 8C16G 的 Redis 节点,能支撑的 QPS 被连接建立速度卡死,而不是查询本身。
下面是我踩完坑之后落地的方案,直接解决连接数爆炸的问题。
问题拆解:连接数为什么是瓶颈
先看传统 PHP-FPM 下的连接生命周期:
# 每个PHP-FPM请求内部
$redis = new Redis(); // 1. 创建客户端
$redis->connect('127.0.0.1'); // 2. TCP建连 (RTT ~0.1ms)
$redis->get($key); // 3. 执行指令
$redis->close(); // 4. 断开连接
这个流程有 3 个致命问题:
- 连接无法复用:请求结束后连接销毁,下一个请求只能重新建连。FPM 进程多但每个进程连接数不稳定,极端情况会无限涨。
- TCP 握手的损耗被放大:Redis 单次 GET 耗时约 0.05ms,但 TCP 建连需要 1 次 RTT(局域网约 0.1-0.3ms),加上 PHP 侧 socket 资源创建销毁,单请求额外支出 0.3ms 以上。在 QPS 过万时,这 0.3ms 直接吃掉 30%-50% 的 CPU 时间片。
- FPM 进程模型导致连接数不可控:FPM 的
pm.max_children设大了连接数翻倍,设小了并发不够。没有一层中间调度能控制「每个 Redis 连接被多少复用、何时回收」。
两种方案对比
方案A:phpredis 长连接 pconnect
phpredis 提供了 pconnect,让同一个 FPM worker 内的请求复用同一个连接。改动最小,代码如下:
<?php
// A方案:phpredis pconnect
$redis = new Redis();
$redis->pconnect('127.0.0.1', 6379, 1.0); // 长连接
$redis->get($key);
// 不调用close()
效果:连接数从「每请求10个」降为「每worker 1个」。但问题没根治——FPM 有 256 个 worker,就是 256 个连接;加上 Redis 自身 timeout 参数容易误杀空闲连接,客户端拿到的连接已失效,产生大量 Connection lost 异常。生产环境我见过最离谱的场景:pconnect 配合 FPM 的上百个 worker,Redis 连接数依然冲到 3000+,且大量 TIME_WAIT。
| 维度 | pconnect 长连接 | Swoole 连接池 |
|---|---|---|
| 连接复用 | 可复用,但受 FPM 进程数限制 | 可复用,进程内共享 |
| 连接数上限 | = FPM worker 数 | = worker 数 × 池大小 |
| 失效连接的容错 | 需自己 try-catch 重连 | 自动剔除,池内重建 |
| 性能模型 | 每个请求仍有进程切换 | 协程调度,无进程切换 |
| 服务器连接数 | 1万+(当并发高时) | 稳定在 300-500 |
方案B:Swoole 常驻内存 + Redis 协程连接池
Swoole 将 PHP 从「每请求启动销毁」变成「常驻内存」。配合 协程 和 连接池:
- 进程数固定(通常 = CPU 核数),连接数 = worker数 × 池大小,完全可控。
- 每次请求从池中取连接,用完归还,不产生 TCP 建连。
- 协程切换的代价是微秒级,比进程切换(微秒到十微秒级)低 10 倍以上。
这套架构,我用 PHP 8.3 跑了线上 + 压测验证,QPS 提升 30+ 倍,连接数下降 95%。下面是完整落地过程。
完整代码实现
环境与版本
# 版本
PHP 8.3.10
Swoole 5.1.3
Redis 7.2.4
phpredis 6.0.2
Nginx 1.26.1
# 操作系统
Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-91-generic x86_64)
# CPU/内存
8核 8GB
# 编译安装 Swoole 5.1.3(我是用pecl装的,编译参数如下)
pecl install swoole
# 确保开启以下扩展 flags:
# --enable-openssl --enable-http2 --enable-swoole-curl --enable-swoole-json
安装后确认:
php -m | grep swoole # swoole
php --ri swoole | grep Version # 5.1.3
第一步:Swoole HTTP Server 骨架
创建 bin/server.php:
<?php
declare(strict_types=1);
// bin/server.php - Swoole HTTP 服务入口
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
require __DIR__ . '/../vendor/autoload.php';
// 1. 创建HTTP服务
$server = new Server('0.0.0.0', 9501, SWOOLE_PROCESS);
// 2. 关键配置
$server->set([
'worker_num' => swoole_cpu_num(), // 8个worker,CPU密集和IO密集混合场景该值=CPU核数
'enable_coroutine' => true, // 开启协程(Swoole5默认开)
'max_coroutine' => 100000, // 单个worker最大协程数
'open_tcp_nodelay' => true, // 禁用Nagle算法,降低小包延迟
'log_file' => '/var/log/swoole_server.log',
'log_level' => 2, // INFO
'daemonize' => false, // 开发环境前台运行,生产用systemd管理
]);
// 3. (可选)定时器:每隔30秒清理池中空闲连接
$server->on('WorkerStart', function (Server $server, int $workerId) {
// 每个worker进程创建自己的连接池实例
$pool = \App\Pool\RedisPool::getInstance();
$pool->init();
// 定时清理空闲连接
swoole_timer_tick(30000, function () use ($pool) {
$pool->cleanIdleConnections();
});
});
// 4. 请求路由
$server->on('Request', function (Request $req, Response $resp) {
$uri = rtrim($req->server['request_uri'] ?? '/', '/');
if ($uri === '/health') {
$resp->header('Content-Type', 'application/json');
$resp->end(json_encode(['status' => 'ok', 'time' => time()]));
return;
}
if (str_starts_with($uri, '/user/')) {
$userId = (int) substr($uri, strlen('/user/'));
// 从连接池获取Redis连接
$pool = \App\Pool\RedisPool::getInstance();
$redis = $pool->get();
try {
$key = "user:info:{$userId}";
$data = $redis->get($key);
$resp->header('Content-Type', 'application/json');
$resp->end(json_encode([
'code' => 0,
'data' => $data ? json_decode($data, true) : null,
]));
} finally {
// 重要:无论是否异常,连接都要归还连接池!
$pool->put($redis);
}
return;
}
$resp->status(404);
$resp->end(json_encode(['code' => 404, 'msg' => 'Not Found']));
});
$server->start();
第二步:Redis 协程连接池
核心代码,40 行内解决。用 Swoole\Coroutine\Channel 作为连接容器,压测验证无死锁、无泄漏。
<?php
declare(strict_types=1);
// app/Pool/RedisPool.php - 基于Channel的Redis连接池
namespace App\Pool;
use Swoole\Coroutine\Channel;
use Redis;
class RedisPool
{
private static ?RedisPool $instance = null;
private Channel $pool;
private array $config;
private int $size;
private int $current = 0;
private function __construct(array $config)
{
$this->config = $config;
$this->size = (int) ($config['pool_size'] ?? 10);
$this->pool = new Channel($this->size);
}
public static function getInstance(): RedisPool
{
if (self::$instance === null) {
self::$instance = new self([
'host' => getenv('REDIS_HOST') ?: '127.0.0.1',
'port' => (int) (getenv('REDIS_PORT') ?: 6379),
'password' => getenv('REDIS_AUTH') ?: '',
'db' => (int) (getenv('REDIS_DB') ?: 0),
'timeout' => 1.0,
'pool_size' => 10,
'max_wait' => 2.0, // 获取连接的超时时间(秒)
]);
}
return self::$instance;
}
public function init(): void
{
// 初始化池,创建最少数量的连接
for ($i = 0; $i < $this->size; $i++) {
$this->pool->push($this->createConnection());
$this->current++;
}
}
private function createConnection(): Redis
{
$redis = new Redis();
$conn = $redis->connect($this->config['host'], $this->config['port'], $this->config['timeout']);
if (!$conn) {
throw new \RuntimeException('Redis 连接失败: ' . $redis->getLastError());
}
if (!empty($this->config['password'])) {
$redis->auth($this->config['password']);
}
if ($this->config['db'] > 0) {
$redis->select($this->config['db']);
}
return $redis;
}
public function get(): Redis
{
// 池中有空闲连接直接取
$redis = $this->pool->pop($this->config['max_wait']);
if ($redis === false) {
// 池耗尽且未超时,按比例新建连接(但不超过池大小的1.5倍)
if ($this->current < (int) ($this->size * 1.5)) {
$this->current++;
return $this->createConnection();
}
throw new \RuntimeException('Redis 连接池耗尽,等待超时');
}
return $redis;
}
public function put(Redis $redis): void
{
// 归还连接。若池已满,则关闭该连接,避免泄漏
if (!$this->pool->push($redis, 0.1)) {
$redis->close();
$this->current--;
}
}
public function cleanIdleConnections(): void
{
// 定时清理:连接数超过初始池大小后,多余部分关闭
$surplus = $this->current - $this->size;
for ($i = 0; $i < $surplus; $i++) {
$conn = $this->pool->pop(0.001);
if ($conn instanceof Redis) {
$conn->close();
$this->current--;
}
}
}
}
第三步:业务 Controller 接入
实际业务中不要在回调函数里手动管理连接池,建议封装一个基类或门面(Facade)。下面是个简单的封装:
<?php
declare(strict_types=1);
// app/Support/RedisFacade.php - Redis门面封装
namespace App\Support;
use App\Pool\RedisPool;
use Redis;
class RedisFacade
{
/** 连接池方式调用 */
public static function get(string $key): string|false
{
$pool = RedisPool::getInstance();
$redis = $pool->get();
try {
return $redis->get($key);
} finally {
$pool->put($redis);
}
}
public static function set(string $key, mixed $value, int $ttl = 300): bool
{
$pool = RedisPool::getInstance();
$redis = $pool->get();
try {
return $redis->setex($key, $ttl, $value);
} finally {
$pool->put($redis);
}
}
}
Controller 中使用:
<?php
// 业务代码里只需要一行
$userInfo = \App\Support\RedisFacade::get('user:info:123');
第四步:压测脚本
用 wrk 压测接口,脚本如下(安装 wrk 后直接跑):
# 安装 wrk(macOS: brew install wrk; Ubuntu: apt install wrk)
wrk -t4 -c100 -d30s --latency http://127.0.0.1:9501/user/123
对比压测传统 Nginx+PHP-FPM 环境,用同一个接口:
wrk -t4 -c100 -d30s --latency https://your-fpm-domain.com/user/123
第五步:systemd 守护进程
# /etc/systemd/system/swoole-http.service
[Unit]
Description=Swoole HTTP Server
After=network.target redis-server.service
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php /var/www/myapp/bin/server.php
ExecReload=/bin/kill -USR1 $MAINPID
Restart=always
RestartSec=3
# 防止 OOM
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
效果数据
压测环境:同一台 8C8G 机器,Redis 在本地。接口逻辑:解析路径 → 从 Redis 读取 JSON → 返回。分别压 3 个版本:
| 方案 | QPS | p99 延迟 | Redis 连接数 | CPU 占用 |
|---|---|---|---|---|
| Nginx + PHP-FPM 8.3(phpredis 单连接) | 623 | 45.12ms | 1540 | 72% |
| Swoole 常驻内存(每次请求 new Redis) | 4120 | 6.48ms | 1200 | 58% |
| Swoole + Redis 协程连接池 | 21456 | 1.12ms | 320 | 61% |
结论:
- 最终方案 QPS 是 FPM 的 34.4 倍,p99 延迟降低 97.5%。
- Redis 连接数从 1540 降到 320,且处于稳定水位,不再暴涨。
- CPU 占用反而更低(省去了进程切换和 TCP 握手的开销)。
线上灰度后的监控同比:
- Redis
connected_clients从 10000+ 降至 300-500。 - 接口平均响应时间从 38ms 降至 6ms。
- Nginx 5xx 率从 3.8% 降至 0.02%。
避坑指南
这套方案我前前后后调了一周,主要卡在以下几个坑上。
坑1:静态属性存连接跨协程导致数据错乱
第一版我用静态变量保存一个 Redis 连接,想着「只要连接不关,我所有协程共用不就行了吗」。结果在高并发下偶发返回别人请求的数据。原因:Swoole 协程在 IO 等待时会自动切换,假设协程 A 用连接执行 get('user:1'),还没拿到结果,协程 B 抢到同一个连接执行 get('user:2'),最后 A 拿到的可能是 B 的数据。解决方式是必须使用连接池,每个协程独享连接,用完归还,不能在协程间共享同一个连接。
坑2:phpredis 版本和 Swoole hook 不兼容
Swoole 通过 Runtime::enableCoroutine hook 了 phpredis 的阻塞调用,但 phpredis 5.x 和 6.x 在 hook 上有差异。我用 phpredis 5.3 时出现「连接池取出连接后偶发超时」,换到 6.0.2 后稳定。建议直接用 phpredis 6.0+,并开启 Swoole 的 hook_flags:
<?php
// 注意:必须在创建 Server 之前设置
Swoole\Runtime::enableCoroutine(true, SWOOLE_HOOK_ALL | SWOOLE_HOOK_CURL);
如果不开启 hook,连接池里的 Redis 连接仍然走阻塞 IO,一旦连接池连接被多个协程排队复用,后面的请求会被前面的 IO 阻塞,QPS 不升反降。
坑3:连接池大小和 Redis maxclients 联动配置
连接池大小不是越大越好。假设 8 worker × 池 10 = 80 连接,Redis 的 maxclients 必须留足余量(建议 x3 以上),否则 Redis 连接数满直接拒绝。但池太大会导致空闲连接过多,Redis 的 timeout 会断开空闲连接,客户端不知道,下次取出使用时报错。我的经验值:
{
"redis": {
"host": "127.0.0.1",
"port": 6379,
"pool_size": 10,
"max_wait": 2.0
},
"swoole": {
"worker_num": 8,
"max_coroutine": 100000
},
"redis_server": {
"maxclients": 10240,
"timeout": 0
}
}
Redis 的 timeout 默认 300 秒,建议设为 0(不主动断开客户端),由连接池自行回收。我在生产就吃过这个亏:Redis 断开了空闲连接,但 Swoole 不知道,取出后用已失效的连接请求 Redis,报 Connection lost。所以要么把 Redis timeout 设 0,要么连接池取出连接时做 ping,但 ping 有约 0.05ms 开销,高并发下会浪费 CPU。我最终把 Redis timeout 设 0 + 连接池定时清理无用连接,两边都干净。
坑4:连接池溢出处理
当并发超过池大小时,pop 会等待。如果等待超时仍无空闲连接,需要决定是新建连接还是直接抛异常。直接抛异常会导致大批量请求失败,疯狂重试又压垮 Redis。正确做法是:允许新建连接,但限制最大不超过池的 1.5 倍,同时定时清理超出部分。上面的代码里已实现。
坑5:热重启和连接池状态
发布代码后 reload 时,如果连接池没被正确清理,旧 worker 的连接会被新 worker 继承吗?不会。Swoole reload 会重启 worker 进程,连接池是每 worker 私有的,所以进程退出后连接自然释放。但要注意:如果你的代码里用了 单例 持有连接池实例,需要在 WorkerStop 事件里显式关闭所有连接,否则会有 TIME_WAIT 积压。
$server->on('WorkerStop', function (Server $server, int $workerId) {
\App\Pool\RedisPool::getInstance()->close();
});
对应的 close() 方法:
public function close(): void
{
while (!$this->pool->isEmpty()) {
$conn = $this->pool->pop(0.001);
if ($conn instanceof Redis) {
$conn->close();
}
}
$this->current = 0;
self::$instance = null;
}
什么时候不要用这套方案
如果你的场景是低并发 API(QPS < 500)或一次性脚本,直接上 Swoole 是过度设计。FPM + phpredis 足够简单可靠。另外如果代码里大量用到超时不可控的阻塞操作(如 file_get_contents 远程请求),需要把所有阻塞调用替换为 Swoole 协程客户端(Swoole\Http\Client、Swoole\Coroutine\MySQL),否则协程调度会被阻塞卡死,这是另一个大的改造点。
连接池方案的前提是:你的 Redis 操作是纯 IO,且耗时稳定。如果 Redis 上有慢查询(如 KEYS *、大 key 的 SMEMBERS),再好的连接池也会被慢查询拖垮,先把慢查询处理掉再谈性能。
总结
这套 PHP8 + Swoole + Redis 连接池方案,核心就三件事:
- 用 Swoole 常驻内存替代 FPM 的进程生命周期,去掉重复的 TCP 建连。
- 用 Channel 实现的连接池控制连接复用,让每个连接被高效复用而不是独占。
- 严格控制连接池大小和 Redis maxclients,让客户端连接数处于可控范围。
最终数据:QPS 从 623 提升到 21456,p99 延迟从 45ms 降到 1.1ms,Redis 连接数从 1540 降到 320。代码量不大,但每个细节都有讲究。