凌晨3点,接口突然全部超时
凌晨3点17分,告警群炸了。核心下单接口超时率100%,错误日志刷屏:
[2025-01-12 03:17:23] ERROR: PDOException: SQLSTATE[HY000] [2002] Connection timed out in /var/www/app/DB.php:42
[2025-01-12 03:17:25] ERROR: PDOException: SQLSTATE[HY000] [2002] Connection timed out in /var/www/app/DB.php:42
[2025-01-12 03:17:27] ERROR: PDOException: SQLSTATE[HY000] [2002] Connection timed out in /var/www/app/DB.php:42
登录MySQL一看:
SHOW STATUS LIKE 'Threads_connected'; -- 151
SHOW VARIABLES LIKE 'max_connections'; -- 151
连接数打到了上限,直接拒绝新连接。当时线上是PHP 8.2 + PHP-FPM,每个FPM进程在请求结束时才释放PDO连接。流量一涨,FPM进程全在等数据库响应,数据库连接被占满,新请求拿不到连接又继续创建FPM进程,雪崩。
这篇文章把当时的排查过程、三种方案的实测对比、最终落地的代码,以及踩过的坑全写出来。
问题根因:PHP-FPM没有连接池
PHP-FPM短生命周期模型下,连接MySQL的完整开销:
| 阶段 | 耗时(本地网络) |
|---|---|
| TCP三次握手 | 0.3 - 0.8ms |
| MySQL认证 + 协议握手 | 1 - 2ms |
| SET NAMES / 字符集设置 | 0.2 - 0.5ms |
单次连接释放后,MySQL端TIME_WAIT状态持续60秒(默认)。也就是说,一个FPM进程每分钟处理300个请求,就会产生300个TIME_WAIT,这些连接在MySQL侧占着内存和文件描述符。
真实数据:我们线上max_connections=151,正常流量下Threads_connected稳定在50-60。活动大促流量翻3倍,Threads_connected直接冲到151,然后所有新请求排队等连接超时,应用层RT从120ms飙到3200ms。
核心结论:在PHP-FPM模型下,每个FPM进程在请求结束前持有数据库连接,进程数 x 并发数 = MySQL连接数。FPM进程数默认dynamic模式,最大可达128+,叠加上数据库自身连接,很容易打满max_connections。
三种方案实测对比
方案A:调大max_connections和wait_timeout
最直接的办法。把max_connections从151调到500,wait_timeout从28800调到60,让空闲连接快速释放。
效果:
- Threads_connected峰值:151 → 320
- 接口超时率:100% → 35%
- MySQL CPU: 42% → 68%
问题:MySQL每增加100个连接,内存多占大约2-3MB,CPU上下文切换开销明显变大。慢查询从每天200条涨到800条。治标不治本,流量再涨一轮还是死。
方案B:ProxySQL连接池
ProxySQL 2.5.4,部署在应用和MySQL之间。应用连接ProxySQL,ProxySQL维持到MySQL的长连接池,连接复用。
关键配置:
mysql_servers:
- host: 10.0.0.3
port: 3306
hostgroup: 10
mysql_users:
- username: app
password: 123456
default_hostgroup: 10
mysql_replication_hostgroups:
- writer_hostgroup: 10
reader_hostgroup: 20
mysql_query_rules:
- rule_id: 1
match_digest: "^SELECT.*FOR UPDATE"
destination_hostgroup: 10
- rule_id: 2
match_digest: "^SELECT"
destination_hostgroup: 20
# 连接池核心参数
mysql> UPDATE global_variables SET variable_value='20' WHERE variable_name='mysql-max_connections_per_host';
# 每后端20个连接
mysql> UPDATE global_variables SET variable_value='100' WHERE variable_name='mysql-max_pool_size';
# 连接池大小100
mysql> LOAD MYSQL VARIABLES TO RUNTIME;
mysql> SAVE MYSQL VARIABLES TO DISK;
效果:
- MySQL侧连接数:320 → 40(20个写连接 + 20个读连接)
- 接口超时率:35% → 0%
- MySQL CPU: 68% → 45%
但引入ProxySQL带来两个新问题:
- 链路多一跳,网络延迟从0.3ms变成0.8ms,可以接受
- ProxySQL自身变单点,需要额外部署Keepalived做HA
方案C:PHP常驻进程 + 连接池
用Swoole 5.1.4的协程连接池,替代FPM模型。连接池在进程内维护,协程共享连接,不再每个请求创建新连接。
关键代码:
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\PostgreSQL;
class ConnectionPool
{
private Channel $pool;
private int $size;
public function __construct(int $size)
{
$this->size = $size;
$this->pool = new Channel($size);
for ($i = 0; $i < $size; $i++) {
$this->pool->push($this->createConnection());
}
}
private function createConnection(): PDO
{
$pdo = new PDO(
'mysql:host=10.0.0.3;port=3306;dbname=app',
'app',
'123456',
[
PDO::ATTR_TIMEOUT => 5,
PDO::ATTR_PERSISTENT => false,
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4'
]
);
return $pdo;
}
public function get(float $timeout = 2): PDO
{
$conn = $this->pool->pop($timeout);
if ($conn === false) {
throw new RuntimeException('连接池耗尽,等待超时');
}
return $conn;
}
public function put(PDO $pdo): void
{
$this->pool->push($pdo);
}
}
效果:
- MySQL侧连接数:40 → 30(池化后的稳定连接数)
- 接口RT:平均120ms → 85ms
- QPS:800 → 2200(压测数据)
方案结论
| 指标 | 方案A(调参数) | 方案B(ProxySQL) | 方案C(Swoole池) |
|---|---|---|---|
| MySQL连接数 | 320 | 40 | 30 |
| 超时率 | 35% | 0 | 0 |
| 平均RT | 350ms | 130ms | 85ms |
| 改动成本 | 改两个配置 | 部署ProxySQL集群 | 重写业务代码为协程 |
| 运维复杂度 | 低 | 高 | 中 |
最终线上选了方案C:业务本身就是新项目,代码量小,Swoole协程改造可控,且收益最大。
完整落地代码
连接池核心实现
<?php
declare(strict_types=1);
namespace App\Pool;
use Swoole\Coroutine\Channel;
use PDO;
class MysqlPool
{
private Channel $pool;
private int $maxConnections;
private string $dsn;
private string $username;
private string $password;
private array $options;
public function __construct(
int $maxConnections = 30,
string $dsn = 'mysql:host=10.0.0.3;port=3306;dbname=app',
string $username = 'app',
string $password = '123456'
) {
$this->maxConnections = $maxConnections;
$this->dsn = $dsn;
$this->username = $username;
$this->password = $password;
$this->options = [
PDO::ATTR_TIMEOUT => 5,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4',
];
$this->pool = new Channel($maxConnections);
$this->preheat();
}
private function preheat(): void
{
for ($i = 0; $i < $this->maxConnections; $i++) {
$this->pool->push($this->createConnection(), 0.5);
}
echo sprintf("[连接池] 预热完成, 建立 %d 个连接\n", $this->maxConnections);
}
private function createConnection(): PDO
{
return new PDO($this->dsn, $this->username, $this->password, $this->options);
}
/**
* 获取连接, 最长等待2秒
*/
public function get(): PDO
{
$conn = $this->pool->pop(2);
if ($conn === false) {
throw new \RuntimeException('数据库连接池耗尽,等待超时 2s');
}
if (!$this->isHealthy($conn)) {
$conn = $this->createConnection();
}
return $conn;
}
/**
* 归还连接
*/
public function put(PDO $pdo): void
{
if ($pdo === null) {
return;
}
$this->pool->push($pdo);
}
private function isHealthy(PDO $pdo): bool
{
try {
$pdo->query('SELECT 1')->fetch();
return true;
} catch (\Throwable $e) {
return false;
}
}
public function stats(): array
{
return [
'pool_size' => $this->pool->length(),
'pool_capacity' => $this->maxConnections,
];
}
}
业务调用方式
<?php
declare(strict_types=1);
use App\Pool\MysqlPool;
// 依赖注入容器中注册单例
$pool = new MysqlPool(
maxConnections: 30,
dsn: 'mysql:host=10.0.0.3;port=3306;dbname=app',
username: 'app',
password: '123456'
);
// Swoole协程环境: 自动归还连接
go(function () use ($pool) {
$conn = $pool->get();
try {
$stmt = $conn->prepare("SELECT * FROM orders WHERE user_id = ? LIMIT 10");
$stmt->execute([$userId]);
$orders = $stmt->fetchAll();
// 业务处理...
} finally {
$pool->put($conn);
}
});
压测脚本
#!/bin/bash
# 压测脚本, 对比FPM与Swoole连接池模式
# 依赖: wrk 4.2.0, 本机执行
BASE_URL_FPM="http://127.0.0.1:8080/api/orders"
BASE_URL_SW="http://127.0.0.1:9501/api/orders"
echo "========== FPM模式压测 =========="
wrk -t 8 -c 200 -d 30s --latency $BASE_URL_FPM
echo "========== Swoole连接池压测 =========="
wrk -t 8 -c 200 -d 30s --latency $BASE_URL_SW
# 压测期间采集MySQL连接数
echo "========== MySQL连接数监控 =========="
for i in $(seq 1 5); do
mysql -h 10.0.0.3 -u monitor -e "SHOW STATUS LIKE 'Threads_connected';"
sleep 5
done
Docker Compose部署
version: "3.8"
services:
app:
image: swoole:5.1.4-php8.2
ports:
- "9501:9501"
volumes:
- ./src:/var/www
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: app
DB_USER: app
DB_PASSWORD: "123456"
POOL_SIZE: "30"
depends_on:
- mysql
restart: always
mysql:
image: mysql:8.0.35
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: app
MYSQL_USER: app
MYSQL_PASSWORD: "123456"
command:
- --max_connections=151
- --wait_timeout=60
volumes:
- ./mysql_data:/var/lib/mysql
监控告警脚本
#!/bin/bash
# 连接耗尽前30秒预警
# crontab: * * * * * /opt/scripts/monitor_conn.sh
THRESHOLD=120
MYSQL_HOST="10.0.0.3"
ALERT_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=xxxx"
CONN_COUNT=$(mysql -h $MYSQL_HOST -u monitor -N -e "SHOW STATUS LIKE 'Threads_connected'" | awk '{print $2}')
MAX_CONN=$(mysql -h $MYSQL_HOST -u monitor -N -e "SHOW VARIABLES LIKE 'max_connections'" | awk '{print $2}')
USAGE=$((CONN_COUNT * 100 / MAX_CONN))
if [ $USAGE -gt $THRESHOLD ]; then
curl -s -H "Content-Type: application/json" \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[告警] MySQL连接数达到 ${CONN_COUNT}/${MAX_CONN}, 使用率 ${USAGE}%\"}}" \
$ALERT_WEBHOOK
fi
效果数据
压测环境:8核16G虚拟机,MySQL 8.0.35(10.0.0.3),应用服务器同网段。wrk压测30秒,8线程200并发。接口逻辑:1次用户表查询 + 1次订单表查询 + 1次Redis读缓存。
| 指标 | FPM连接池(方案C前) | Swoole连接池(方案C后) |
|---|---|---|
| QPS | 812 | 2215 |
| 平均RT | 246ms | 90ms |
| P99 RT | 980ms | 143ms |
| MySQL Threads_connected | 151(打满) | 30(稳定) |
| MySQL CPU使用率 | 68% | 45% |
| PHP-FPM进程数 | 128 | 32(Worker) |
上线后线上观察一周:
- Threads_connected稳定在28-32,不再出现尖刺
- 接口平均RT:118ms → 85ms
- 没有出现一次超时告警
内存占用对比:FPM模式128个进程 x 每个进程约25MB = 3.2GB;Swoole模式32个Worker x 每个Worker约35MB = 1.12GB。内存省了65%。
避坑指南
下面这些坑,每一个我们都实际踩过,花了不少时间才定位。
坑1:wait_timeout改太小导致连接被MySQL主动断开
把wait_timeout从28800改成60后,业务侧出现了大量SQLSTATE[HY000]: General error: 2006 MySQL server has gone away。原因:长连接在业务代码执行前就被MySQL回收了。
解决:连接池里的心跳检测必须加,每次获取连接时执行SELECT 1验证。同时连接池内部维护连接的最后使用时间,超过50秒主动断开重连。
坑2:连接池预热导致MySQL启动崩溃
连接池启动时一次性创建30个连接,MySQL还没完成初始化,连接全部失败,连接池抛异常,服务启动失败。
解决:预热时加重试机制,MySQL未就绪时循环尝试,最多等10秒。
private function preheat(): void
{
$retries = 0;
while ($retries < 50) {
try {
$this->pool->push($this->createConnection(), 0.5);
$retries = 0;
} catch (\Throwable $e) {
$retries++;
usleep(200000); // 200ms
}
}
}
坑3:连接池耗尽压测时没设置获取超时
连接池默认pop等待时间是-1(无限等待)。压测中连接池打满后,所有请求阻塞在pop上,表现和之前连接耗尽一样,完全看不出来池化了。
解决:pop()必须设置超时时间,推荐2秒。超时后立即抛出异常,让上游感知而不是无限挂起。
坑4:忘了处理MySQL连接空闲超时(interactive_timeout)
连接池里的连接一直空闲不用,超过interactive_timeout(默认28800秒)也会被MySQL踢掉。长连接池必须处理这个。我们踩过:第二天早上第一批请求全部报2006错误,因为夜间没有流量,连接被MySQL清了,但连接池不知道。
解决:心跳检测每次都要执行,不只是快速SELECT 1,还要把连接标记为"最后活跃时间",定期清理超过10分钟没使用的空闲连接。
坑5:连接池大小拍脑袋定,没有压测验证
最开始pool size设了100,但压测发现MySQL CPU比方案A还高。因为100个连接同时发查询,MySQL内部线程调度开销很大。
解决:逐个池大小压测。实测30是当前业务的最优值。
| 连接池大小 | QPS | MySQL CPU | P99 RT |
|---|---|---|---|
| 10 | 1832 | 35% | 166ms |
| 20 | 2105 | 41% | 155ms |
| 30 | 2215 | 45% | 143ms |
| 50 | 2150 | 52% | 158ms |
| 100 | 1980 | 61% | 172ms |
连接池不是越大越好,30是拐点,20到30间收益最大,超过30反而帮助不大,CPU开销还涨。选30,留30%余量给突发流量。
坑6:连接池漏释放连接
业务代码里忘记调用put($conn),连接池里的连接越来越少,半小时后池子空掉,应用开始报警。
解决:统一封装业务操作基类,query()方法内部try...finally保证连接一定归还。
public function query(string $sql, array $params = []): array
{
$conn = $this->pool->get();
try {
$stmt = $conn->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
} finally {
$this->pool->put($conn);
}
}
总结
数据库连接池耗尽,本质是连接管理模型和请求模型不匹配。FPM每请求一连接的模式下,调大max_connections只是把问题推后,ProxySQL和Swoole协程池是真正解决。
选型建议:
- 项目小、不想改业务代码:ProxySQL,部署一套集群解决
- 新项目、技术栈可控:直接用Swoole协程 + 连接池,收益最大
- 老项目、维护为主:连接池 + 调优参数 + 监控预警,减少改动面
最后说一句:连接池不止是连上MySQL再复用那么简单。心跳检测、超时控制、连接耗尽告警、池大小压测,每一环都能坑人。照文中的方案做,至少能少走一半弯路。