dbaai 发表于 2026-11-7 05:47:58

MySQL 连接数暴涨与文件句柄耗尽实战:从 Too many connections 到连接池与句柄双线收敛

MySQL 连接数暴涨与文件句柄耗尽实战:从 Too many connections 到连接池与句柄双线收敛

一、具体的问题

一个电商订单库,MySQL 8.0.32,16 核 64G,QPS 峰值 1800。某个大促凌晨开始告警刷屏:


ERROR 1040 (HY000): Too many connections


更要命的是监控也断了——监控用的是普通只读账号,max_connections 一满,它同样连不进去,告警面板上这个实例直接变灰。值班的人只好去翻 Zabbix 的历史曲线,看到 Threads_connected 在 20 分钟里从 300 一路爬到 1180,而 max_connections 是 1200。

运维的第一反应是调大参数:


SET GLOBAL max_connections = 3000;


撑了两个小时又满了,而且这次连带出了新问题:dmesg 里开始出现


TCP: too many orphaned sockets
Out of memory: Killed process 12873 (mysqld)


mysqld 被 OOM killer 干掉一次,主从切换。到这里才算把问题拆成两层:连接数只是表面,真正被吃穿的是文件句柄和内存。调大 max_connections 既没有减少连接,还把两条资源线一起推过了临界点。

事后复盘,1180 个连接里 82% 是 Sleep 状态,Threads_running 全天没超过 90。也就是说,数据库一点都不忙,只是有一千个"人"站在门口不进去。

二、核心原理

第一,max_connections 限制的是线程数,不限制请求量。 连接数由使用方决定,不由数据库决定。一个服务的连接数大致是:


连接数 ≈ 单实例池上限 × 应用实例数 ×(1 + 空转重叠率) + 旁路连接


本例 HikariCP maximumPoolSize=50,读写分离拆了两套池(读 50 + 写 50),应用 8 个 Pod,光应用侧理论上限就是 800;再叠加 CDC 同步 20、备份 8、监控 8、报表只读 12、DBA 手工会话若干,破 1000 是必然的。参数没设错,是账没算过。

第二,Sleep 连接不是免费的。 每个连接都要占:一个线程栈(thread_stack 默认 1M 虚拟内存)、一块网络缓冲(net_buffer_length 起 16K,实际读大字段时会涨到 max_allowed_packet 量级)、至少一个 socket 句柄,加上连接期间打开的临时文件、.ibd 句柄。1000 个 Sleep 连接大约等于 1G 线程栈 + 数十 M 到 1G 的 net buffer + 1000 个 fd。RSS 涨上去之后,OOM 就有了条件。

第三,open_files_limit 是协商出来的,不是写多少就是多少。 mysqld 启动时会尝试 setrlimit,取值大致在


max(10 + max_connections + table_open_cache × 2, 若干备选值)


之间,并且不能超过操作系统的 hard limit。如果 systemd 的 LimitNOFILE 是 65536,那 open_files_limit 天花板就是 65536,你在 my.cnf 里写 100000 也没用。句柄的大头是:每连接 1 个 socket、每个打开的 .ibd 1 个、table_open_cache 每缓存一张表约 2 个、临时文件、redo/binlog。本例治理前 Open_files 峰值 63100,离 65535 只剩 4%——这才是真正快要撞线的那根线。

第四,Threads_running 决定你该调参数还是该查 SQL。 连接满的时候先看这个值:


[*]Threads_running 很低(本例 90)、Sleep 占比高 → 连接洪水,去治理池和超时;
[*]Threads_running 也顶到几百、Query 状态多 → 执行卡住,去查锁等待和慢 SQL,调 max_connections 只会让排队更长。


第五,Aborted_connects 和 Aborted_clients 是两个方向的信号,别混。 前者是握手失败(密码错、网络超时、max_connect_errors 把 host 封了),后者是客户端非正常断开(被 wait_timeout 掐掉、网络闪断)。本例 Aborted_clients 一天 1.7 万,正是"连接被服务端掐断"的痕迹。

三、实例参考(动手步骤)

步骤 0:确认现状基线

先把两条资源线和连接画像一次性捞出来,存成表,后面所有判断都对着这张表说话。


SELECT @@max_connections, @@open_files_limit, @@table_open_cache,
       @@table_definition_cache, @@wait_timeout, @@interactive_timeout, @@thread_stack;

SHOW GLOBAL STATUS WHERE Variable_name IN (
'Threads_connected','Threads_running','Threads_created','Max_used_connections',
'Aborted_connects','Aborted_clients','Connection_errors_max_connections',
'Open_files','Open_tables','Opened_files');

SHOW GLOBAL VARIABLES LIKE 'max_connect%';


记 5 分钟采样窗口的 Threads_connected 峰值,和 Max_used_connections(服务启动以来峰值)对照,看是不是"一直很高"还是"只有峰值高"。本例 Max_used_connections=1180,几乎等于峰值,说明不是瞬时尖刺。

步骤 1:区分是连接洪水还是执行卡住


SELECT COMMAND,
       COUNT(*)               AS cnt,
       ROUND(100*COUNT(*)/SUM(COUNT(*)) OVER (), 1) AS pct,
       MAX(TIME)            AS max_idle_sec,
       USER,
       SUBSTRING_INDEX(HOST, ':', 1) AS ip
FROM information_schema.PROCESSLIST
GROUP BY COMMAND, USER, ip
ORDER BY cnt DESC
LIMIT 20;


本例结果:Sleep 968 条占 82%,max_idle_sec 达到 42600(11.8 小时),集中在 8 个 Pod IP 上,每 IP 约 120 条。Query 只有 60 条。结论直接落到连接洪水,不用再查锁。

如果这里 Query 占比高、TIME 大,就换方向:


SELECT * FROM sys.innodb_lock_waits ORDER BY wait_age_secs DESC LIMIT 10;
SELECT THREAD_ID, SQL_TEXT, TIMER_WAIT/1e12 AS sec
FROM performance_schema.events_statements_current
WHERE TIMER_WAIT > 1e12 ORDER BY TIMER_WAIT DESC LIMIT 10;


步骤 2:按来源定位"谁开了这么多连接"


SELECT PROCESSLIST_USER, PROCESSLIST_HOST, PROCESSLIST_COMMAND,
       PROCESSLIST_TIME, PROCESSLIST_INFO
FROM performance_schema.threads
WHERE TYPE = 'FOREGROUND' AND PROCESSLIST_COMMAND = 'Sleep'
ORDER BY PROCESSLIST_TIME DESC
LIMIT 30;


逐 IP 对到应用实例,再回应用侧看池配置。本例抓到两个事实:一是 maximumPoolSize 被环境变量覆盖成了 60(不是代码里的 50);二是读写分离两个池的 minimumIdle 都设成 30,意味着即使空载,每个 Pod 也常驻 60 条连接,8 个 Pod 就是 480 条永不断开。

步骤 3:用 Little's Law 算出"该给多少"

不要拍脑袋定池大小。并发需求 = QPS × 平均响应时间:


理论并发 ≈ 1800 QPS × 3ms ≈ 5.4


即使留 4 倍安全系数、再把慢查询拉高到 30ms 算一遍,也就 54 个并发执行。所以池给 20/实例完全够用,60 是纯粹浪费。按 8×20 + 旁路 40 + 30% 余量 ≈ 250 定服务端上限,max_connections 设 800(留 3 倍突发余量),而不是 3000。

步骤 4:服务端兜底参数(顺序不能反)


-- 先收池再动服务端,否则调大参数只是推迟爆炸
SET GLOBAL max_connections      = 800;
SET GLOBAL wait_timeout         = 600;   -- 非交互连接 10 分钟回收
SET GLOBAL interactive_timeout= 1800;-- 交互式单独放长,别一起砍
SET GLOBAL table_open_cache   = 4000;-- 别贪大:每条缓存 ≈ 2 个句柄


句柄侧必须在操作系统层放开,my.cnf 里写没用:


# /etc/systemd/system/mysqld.service.d/limits.conf

LimitNOFILE=1048576



systemctl daemon-reload && systemctl restart mysqld


重启后必须回查协商结果,而不是看配置文件:


SELECT @@open_files_limit, @@max_connections;
SHOW GLOBAL STATUS LIKE 'Open_files';


Open_files 峰值应长期稳定在 open_files_limit 的 70% 以下。

步骤 5:留一条救命通道

max_connections 满了的时候,root 也连不进去,mysqladmin 一样连不进去。MySQL 8.0.14+ 给了管理端口:



admin_address = 127.0.0.1
admin_port    = 33062


这个端口不受 max_connections 限制,只接受 SERVICE_CONNECTION_ADMIN 权限的账号,专供救火。没有 8.0.14 的话,退而求其次:给监控单独一个 EXTRA 端口(Percona 的 extra_port),或者保证 mysqladmin 走 unix socket 且预留一个管理员会话常驻。

治理前后对比


指标治理前治理后
Threads_connected 峰值1180236
Max_used_connections1180241
Sleep 连接占比82%31%
最长 Sleep 时长42600s590s
Open_files 峰值63100(limit 65535)18400(limit 1048576)
Connection_errors_max_connections / 天420000
Aborted_clients / 天17000210
mysqld RSS41G12G
OOM kill2 次/周0
P99 建连耗时210ms3ms


业务侧 QPS 和 P99 没有任何恶化——说明之前那一千条连接确实全是白占。

四、实操检查清单


[*][ ] 用 Max_used_connections / max_connections 算水位,长期 >70% 就要开始查来源,别等报 1040。
[*][ ] 连接满时第一件事看 Threads_running,不是看 max_connections。
[*][ ] 按 IP 分组统计 PROCESSLIST,把每条连接对到具体应用实例和具体池。
[*][ ] 应用侧核对 maximumPoolSize 是否被环境变量、配置中心、启动参数覆盖过(本例就被覆盖了)。
[*][ ] minimumIdle 不要设成和 maximumPoolSize 一样大,否则池永不收缩。
[*][ ] 用 QPS × 平均响应时间估算真实并发需求,不要把"峰值历史连接数"当容量依据。
[*][ ] 读写分离的双池要合并计算总连接,不要只盯着一个池。
[*][ ] 旁路连接(CDC、备份、监控、报表、DBA 会话)单独留配额,约 10% 即可。
[*][ ] open_files_limit 必须回查实际生效值,systemd 改了要 daemon-reload 并重启。
[*][ ] Open_files 峰值控制在上限 70% 以下,table_open_cache 调大前先算句柄账。
[*][ ] wait_timeout 和 interactive_timeout 分开设,别一起砍到 60s。
[*][ ] 配 admin_port(8.0.14+)或等价救火通道,并验证在连接打满时能登进去。
[*][ ] 给 Connection_errors_max_connections 和 Aborted_clients 建监控告警,它们是早于 1040 的预警信号。


几个容易踩的坑

一、调大 max_connections 之后起不来。 max_connections 每增加一个,就要多预留一份线程栈(thread_stack)和缓冲。设到 5000、thread_stack=1M,光虚拟内存就是 5G,在 vm.overcommit_memory=2 的机器上会直接 fork 失败——数据库不是被压垮的,是被参数撑死的。

二、改了 systemd 的 LimitNOFILE 却没生效。 少一步 daemon-reload,或者只 reload 没 restart,@@open_files_limit 还是老值。判断方法很简单:拿 cat /proc/$(pidof mysqld)/limits 看真实上限,别信配置文件。

三、open_files_limit 显示得莫名其妙地小。 常见原因是 mysqld 由 mysqld_safe 拉起、或者有 AppArmor/SELinux 的额外限制,也可能是 LimitNOFILE 用了 infinity 而 mysqld 只认具体数值。

四、table_open_cache 越调越大,句柄越紧。 每条缓存条目大约消耗 2 个句柄,从 4000 调到 20000 等于多要 3 万多个 fd。表多的实例上,这个参数是句柄的头号消耗者,不是 max_connections。

五、只改 wait_timeout。 命令行和部分客户端的连接读的是 interactive_timeout。两个都改,但不要把交互式的也砍到 60 秒,DBA 排障中途被踢会很难受。

六、Aborted_clients 突然暴涨,其实是 wait_timeout 设得太狠。 从 28800 直接砍到 60,应用侧会开始报 Communications link failure。要按"最长空闲业务间隔"来定,一次降一档,观察一天再降。

七、max_connect_errors 把客户端 IP 封了。 连接洪水期间大量握手失败会累积计数,达到 max_connect_errors(默认 100)就把整个 host 拉黑,之后密码正确也连不上,报 Host is blocked。救火时要记得 FLUSH HOSTS。

八、把监控账号拿来救火。 本例的监控账号是普通权限,连接满时连不进去,等于同时在监控盲区里排障——这条比任何参数都值得先修。

九、连接池的 connectionTimeout 设得太长。 池耗尽时请求会一直排队等连接,把线程也拖死,故障从数据库蔓延到应用。这个值应该远小于接口超时,让失败快速暴露。

十、只看历史峰值定容量。 那个 1180 是"故障现场",不是"业务需求"。拿故障数据当容量基线,只会把下一次爆炸的阈值一起抬高。
页: [1]
查看完整版本: MySQL 连接数暴涨与文件句柄耗尽实战:从 Too many connections 到连接池与句柄双线收敛