云数据库 Sleep 连接堆积的根因排查:从 Nginx/Apache 进程生命周期到 wait_timeout 的协同调优

先还原一个真实告警:某天凌晨,轻云互联的云数据库监控弹出 max_connections 告警,登录控制台看 Threads_connected 已经 1800/2000,而 Threads_running 只有 3。跑一条 processlist,几千条记录全是 command=Sleep。这是云数据库最典型的“假满”场景——不是 SQL 慢,不是 IO 打满,而是连接槽被空转的连接占满了。

别急着 kill,先回答三个问题

大多数人第一反应是写个脚本 kill 掉 Sleep 超过 N 秒的连接,但这解决不了任何问题。新请求一来,进程池里的 worker 马上又会建立新连接,Sleep 数量几分钟内“复原”。动手之前,你必须能回答:这些连接从哪台机器、哪个进程来?它是短连接没释放,还是某个长驻进程“养”出来的?

1. 从数据库端统计来源

mysql -h${DB_HOST} -A \
  -e "SELECT SUBSTRING_INDEX(host,':',1) AS src, user, COUNT(*) AS cnt 
      FROM information_schema.processlist 
      WHERE command='Sleep' 
      GROUP BY src,user ORDER BY cnt DESC LIMIT 30;"

2. 从 Web 机反查进程归属

ss -tnp | grep ':3306' | \
  awk '{print $6}' | grep -oP 'pid=\K[0-9]+' | \
  sort | uniq -c | sort -rn

如果输出里大量 php-fpm / apache2 进程,每个进程下面都挂一条 3306 连接,且该进程大部分时间处于空闲状态,那么问题基本锁定:进程生命周期太长,连接被“养住”了。

根因:Nginx/Apache 的进程模型天然会助长 Sleep 连接

  • Apache prefork + mod_php:Apache 子进程一旦 fork,就会一直活着。它未必会在请求结束立刻释放 MySQL 连接(尤其当 PHP 打开了 persistent connection)。子进程活得越久,连接越老。
  • Nginx + PHP-FPM:每个 worker 是独立进程。如果业务层用了 PDO::ATTR_PERSISTENTmysqli_pconnect(),每个 worker 都会在第一次请求后保存一条属于自己的连接。PHP-FPM 的 persistent connection 不跨 worker 共享,复用率极低,代价却是进程池里有 100 个 worker,数据库里就至少多出 100 条 Sleep —— 哪怕这些 worker 此刻根本没在处理请求。
  • 短连接也会形成瞬时假象:请求结束时,如果 PHP 底层没触发 close,或 MySQL 的 FIN 在网络上延迟,连接会短暂停在 Sleep。并发高时一瞬间也能堆几百条,但它会自行回落,不是治理重点。

先把持久连接关掉,再谈进程回收策略

很多“优化”其实是拿持久连接换三次握手,然后在 cloud 数据库上换来了连接数告警。先全局禁用持久连接:

; /etc/php.ini
mysql.allow_persistent = Off
mysqli.allow_persistent = Off

如果是框架层显式开启的 PDO 持久连接,直接在代码里去掉:

// 错误示范:每个 FPM worker 一条连接永不释放
$db = new PDO($dsn, $user, $pass, [
    PDO::ATTR_PERSISTENT => true,
]);

// 正确:用完即关
$db = new PDO($dsn, $user, $pass);

PHP-FPM:定期销毁 worker,让连接跟着进程一起死

; /etc/php-fpm.d/www.conf
pm = dynamic
pm.max_children = 80
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30

; 核心参数:处理完 500 个请求后当前 worker 退出
; 所有数据库连接随进程销毁,防止隐藏的全局连接越积越多
pm.max_requests = 500

pm.max_requests 才是真正把连接生命周期交给进程生命周期的关键。即使代码里有不可控的全局连接或老模块炸出了持久连接,最大也只存活 500 个请求的时间。建议结合业务压测,在 200~1000 之间选值。

Apache:MaxConnectionsPerChild 是同一个道理

Timeout 60
KeepAlive On
MaxKeepAliveRequests 1000

# Apache 2.4.x
<IfModule mpm_prefork_module>
    StartServers           20
    MinSpareServers        10
    MaxSpareServers        30
    MaxRequestWorkers      80
    MaxConnectionsPerChild 500
</IfModule>

MaxConnectionsPerChild 设为 0 表示子进程可以永生,这是很多老服务器 Sleep 连接一直降不下来的直接原因。改成 500 后执行 apachectl -k graceful,子进程会被周期性地替换,连接数会明显下降。

注意:客户端 KeepAlive 过长会让 Apache worker 被空连接占住,进而占用它持有的 MySQL 连接,所以 KeepAliveTimeout 建议 5s 左右,别给到 15s 以上。

用一个公式约束进程池上限

云数据库的连接数配额是硬限制,与其等告警,不如反推 Web 进程池规模:

web_worker_slots = (DB max_connections * 0.7) / 每个worker占用DB连接数

假设云数据库配额 2000,监控、备份、后台任务预留 30%,每个 worker 只占 1 条连接,那么所有 Web 进程池总和不要超过 1400。Nginx + PHP-FPM 场景下约等于所有 pm.max_children 之和;Apache prefork 下约等于 MaxRequestWorkers

wait_timeout 不是越小越好

MySQL 默认 wait_timeout=28800(8 小时),Sleep 连接确实可以赖很久。把它调到 600 秒有效吗?分两种情况:

  • 纯短连接架构(PHP-FPM 未开 pconnect):调短无副作用,因为每个请求都新建连接,wait_timeout 只是为异常断连兜底。
  • 有长连接/连接池:调太短会让连接在两次业务调用间隙被 MySQL 主动掐掉。下一个请求会收到 MySQL server has gone away,错误日志瞬间爆炸。如果业务必须保留长连接,wait_timeout 必须大于业务最长空闲时间,并留足余量。

我的推荐顺序是:先在 Web 层关掉没必要的持久连接,再让 worker 定期重建,最后才去动 wait_timeout。一组经过验证的起始值:

[mysqld]
wait_timeout = 900
interactive_timeout = 900
max_connections = 2000   ; 按实例配额写

同时检查 PHP 侧的连接超时,避免 PHP 进程长时间卡在读写上:

php_value[mysql.connect_timeout] = 5
php_value[default_socket_timeout] = 60

应急兜底:max_user_connections 限制单账号

云数据库实例上往往跑着不止一个业务账号,一个应用把连接池打满会让所有业务陪葬。用 MySQL 的 max_user_connections 把每个账号限制在可控范围内:

-- 限制某个账号最多 600 条连接
SET GLOBAL max_user_connections = 1500;
ALTER USER 'app_user'@'%' WITH MAX_USER_CONNECTIONS 600;

应急杀连接:尽量克制

只杀 Sleep 超过 900 秒、非复制账号、非运维账号的连接,作为一种止血手段:

mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" -N -e "
SELECT CONCAT('KILL ', id, ';')
FROM information_schema.processlist
WHERE command = 'Sleep'
  AND time > 900
  AND user NOT IN ('repl','admin');" | \
mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS"

crontab 每 10 分钟执行一次:

*/10 * * * * /usr/local/bin/kill_sleep_conn.sh >> /var/log/kill_sleep.log 2>&1

再次强调:kill Sleep 只是止血,连接可能持有 open transaction 或临时表句柄,执行有风险。必须回头把进程生命周期和 wait_timeout 对齐,否则上线两小时后告警准时回来。

Nginx 侧两个容易被忽略的细节

PHP-FPM 与 Nginx 走 Unix Socket 时,连接队列溢出会让 FPM 瞬时 fork 更多 worker,数据库连接数跟着飙高:

; php-fpm pool 配置
listen.backlog = 1024

Nginx 给 PHP 的超时不要设太长。一个卡死的 PHP 进程会占着 MySQL 连接,时间越长 Sleep 连接越难降:

location ~ \.php$ {
    fastcgi_connect_timeout 5s;
    fastcgi_read_timeout    60s;
    fastcgi_send_timeout    60s;
}

最后再总结一句:先看 processlist 的 host 分布,再查 ss 的 pid 归属,然后去修正 Web 进程存活周期,这才是云数据库连接数治理的正确顺序。如果你的实例跑在轻云互联这类云厂商上,配额大但仍不够用,请先把这份检查清单执行完再考虑升配——很多时候只是进程活着的时间过长,而不是数据库真不够用了。