数据库连接池耗尽:一次凌晨3点的P1事故
发布日期: 2026/08/08 阅读总量: 0

凌晨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连接数3204030
超时率35%00
平均RT350ms130ms85ms
改动成本改两个配置部署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后)
QPS8122215
平均RT246ms90ms
P99 RT980ms143ms
MySQL Threads_connected151(打满)30(稳定)
MySQL CPU使用率68%45%
PHP-FPM进程数12832(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是当前业务的最优值。

连接池大小QPSMySQL CPUP99 RT
10183235%166ms
20210541%155ms
30221545%143ms
50215052%158ms
100198061%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再复用那么简单。心跳检测、超时控制、连接耗尽告警、池大小压测,每一环都能坑人。照文中的方案做,至少能少走一半弯路。