如何避免协程环境下 MySQL 连接池耗尽?

解读

国内高并发业务(电商大促、直播秒杀、短视频 feed)普遍采用 Swoole、Swow、OpenSwoole 等协程引擎,把传统 PHP-FPM 的“进程级”并发升级为“协程级”并发,单机可轻松维持数万连接。但 MySQL Server 的 max_connections 通常只有 1000~3000,且每个连接在 InnoDB 中占用约 2~4 MB 线程栈+缓存,池子一旦打满,新请求报 “MySQL server has gone away” 或 “Too many connections”,直接触发 5xx。面试时,面试官想确认候选人是否真正踩过坑:既懂“池”的本质是“令牌限流”,又能从框架、配置、业务、监控四端给出可落地的国内方案,而不是背八股文。

知识点

  1. 协程 vs 进程:协程切换仅改寄存器,内存占用 8 KB 左右;一个进程可开 10 w 协程,但数据库连接仍是 1:1 的 TCP 连接,池大小 ≠ 协程数。
  2. 连接池四要素:初始连接、最大连接、最大空闲时间、等待队列超时。
  3. 国内主流组件:Swoole 自带 \Swoole\Database\PDOPool、Hyperf/IMI 封装 PoolTrait、Laravel Octane 基于 Swoole Table 的 ConnectionCache。
  4. 池耗尽根因:慢 SQL + 无熔断、事务未提交、连接泄露、突发流量无梯度限流。
  5. 治理手段:池大小公式、连接生命周期绑定、熔断降级、监控告警、灰度预热。

答案

一、池大小设计

  1. 先算“安全上限”:MySQL 最大可用连接 = max_connections – 预留 20 %(监控、后台、备份)。假设 2000,则业务池总和 ≤ 1600。
  2. 再按服务节点分摊:单机池 maxActive = 1600 / 容器副本数。例如 16 个 Pod,则单 Pod 100 连接。
  3. 最后按 CPU 核数微调:IO 密集型经验值 核数 * 8~12,防止 CPU 先打满。

二、框架层配置(以 Hyperf 为例,国内生产验证最多)

return [
    'default' => [
        'driver'  => Env::get('DB_DRIVER', 'mysql'),
        'pool'    => [
            'min_connections' => 5,          // 初始连接
            'max_connections' => 100,        // 与上面公式对齐
            'connect_timeout' => 10.0,
            'wait_timeout'    => 3.0,        // 拿不到连接 3 s 立即抛异常,防止雪崩
            'heartbeat'       => -1,         // 关闭心跳,减少空闲检测开销
            'max_idle_time'   => 60,
        ],
        'commands' => [
            'gen:model' => [
                'path' => 'app/Model',
            ],
        ],
    ],
];

关键:wait_timeout 一定要小于接口超时(如 1 s),否则用户已经熔断,池还在空等。

三、业务层最佳实践

  1. 连接作用域最小化:使用 defer($conn->close())try/finally 立即归还;禁止把连接当类属性长持。
  2. 事务模板化:封装 Db::transaction(callable $callback, int $attempts = 1),内部自动 commit/rollback 并释放连接。
  3. 慢 SQL 熔断:在 QueryExecuted 事件监听,>500 ms 直接记录并告警;连续 10 次触发熔断器,拒绝新请求 30 s,保护池。
  4. 灰度预热:大促前通过压测脚本把池填充到 min_connections,避免冷启动瞬间打满。

四、监控与告警(贴合国内云)

  1. 指标:池活跃数、等待队列长度、获取连接耗时 P99、MySQL threads_running。
  2. 工具:阿里云 SLS + Prometheus,Grafana 面板红线 threads_running > 80 % 发钉钉、飞书。
  3. 应急:一键 kill 非活跃 sleep 连接 pt-kill --match-command Sleep --idle-time 120 --kill

五、兜底策略

  1. 双库:读写分离,读走 PolarDB 只读节点,池独立,减少主库压力。
  2. 缓存前置:WIKI 推荐“先缓存后数据库”,协程内如果缓存命中直接 return,0 连接消耗。
  3. 弹性扩容:K8s HPA 根据池队列长度指标自动水平扩容,30 s 内拉起新 Pod 分摊连接。

拓展思考

  1. 如果业务必须开长事务(如订单结算),如何既保证一致性又不霸占连接?
    答:把长事务拆成“本地消息表 + 补偿任务”,结算主流程只持连接 50 ms,后续异步任务用独立小池慢慢处理。

  2. 当池 wait_timeout 调小后,接口超时率下降,但少量慢查询仍会导致连接未就绪,如何进一步优化?
    答:引入“协程级 SQL 超时” SET MAX_EXECUTION_TIME=1000; 与池 wait_timeout 形成“双保险”,慢 SQL 在 MySQL 端被 kill,连接立即回收。

  3. 国内金融场景要求“连接可追踪”,如何在池里拿到真实业务 traceId?
    答:自定义连接装饰器,在 reset() 阶段执行 SET @trace_id='ac13'; 并在 general_log 开启审计,实现“业务-连接-SQL” 三段式追踪,满足央行 281 号文审计要求。