前事不忘,后事之师,不忘国耻!

 找回密码
 用户注册
搜索
查看: 28|回复: 0

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

[复制链接][复制链接]
发表于 昨天 05:47 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

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


一、具体的问题

一个电商订单库,MySQL 8.0.32,16 核 64G,QPS 峰值 1800。某个大促凌晨开始告警刷屏:
  1. ERROR 1040 (HY000): Too many connections
复制代码

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

运维的第一反应是调大参数:
  1. SET GLOBAL max_connections = 3000;
复制代码

撑了两个小时又满了,而且这次连带出了新问题:dmesg 里开始出现
  1. TCP: too many orphaned sockets
  2. Out of memory: Killed process 12873 (mysqld)
复制代码

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

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

二、核心原理

第一,max_connections 限制的是线程数,不限制请求量。 连接数由使用方决定,不由数据库决定。一个服务的连接数大致是:
  1. 连接数 ≈ 单实例池上限 × 应用实例数 ×(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,取值大致在
  1. 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:确认现状基线

先把两条资源线和连接画像一次性捞出来,存成表,后面所有判断都对着这张表说话。
  1. SELECT @@max_connections, @@open_files_limit, @@table_open_cache,
  2.        @@table_definition_cache, @@wait_timeout, @@interactive_timeout, @@thread_stack;
  3. SHOW GLOBAL STATUS WHERE Variable_name IN (
  4.   'Threads_connected','Threads_running','Threads_created','Max_used_connections',
  5.   'Aborted_connects','Aborted_clients','Connection_errors_max_connections',
  6.   'Open_files','Open_tables','Opened_files');
  7. SHOW GLOBAL VARIABLES LIKE 'max_connect%';
复制代码

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

步骤 1:区分是连接洪水还是执行卡住
  1. SELECT COMMAND,
  2.        COUNT(*)               AS cnt,
  3.        ROUND(100*COUNT(*)/SUM(COUNT(*)) OVER (), 1) AS pct,
  4.        MAX(TIME)              AS max_idle_sec,
  5.        USER,
  6.        SUBSTRING_INDEX(HOST, ':', 1) AS ip
  7. FROM information_schema.PROCESSLIST
  8. GROUP BY COMMAND, USER, ip
  9. ORDER BY cnt DESC
  10. LIMIT 20;
复制代码

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

如果这里 Query 占比高、TIME 大,就换方向:
  1. SELECT * FROM sys.innodb_lock_waits ORDER BY wait_age_secs DESC LIMIT 10;
  2. SELECT THREAD_ID, SQL_TEXT, TIMER_WAIT/1e12 AS sec
  3. FROM performance_schema.events_statements_current
  4. WHERE TIMER_WAIT > 1e12 ORDER BY TIMER_WAIT DESC LIMIT 10;
复制代码

步骤 2:按来源定位"谁开了这么多连接"
  1. SELECT PROCESSLIST_USER, PROCESSLIST_HOST, PROCESSLIST_COMMAND,
  2.        PROCESSLIST_TIME, PROCESSLIST_INFO
  3. FROM performance_schema.threads
  4. WHERE TYPE = 'FOREGROUND' AND PROCESSLIST_COMMAND = 'Sleep'
  5. ORDER BY PROCESSLIST_TIME DESC
  6. LIMIT 30;
复制代码

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

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

不要拍脑袋定池大小。并发需求 = QPS × 平均响应时间:
  1. 理论并发 ≈ 1800 QPS × 3ms ≈ 5.4
复制代码

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

步骤 4:服务端兜底参数(顺序不能反)
  1. -- 先收池再动服务端,否则调大参数只是推迟爆炸
  2. SET GLOBAL max_connections      = 800;
  3. SET GLOBAL wait_timeout         = 600;   -- 非交互连接 10 分钟回收
  4. SET GLOBAL interactive_timeout  = 1800;  -- 交互式单独放长,别一起砍
  5. SET GLOBAL table_open_cache     = 4000;  -- 别贪大:每条缓存 ≈ 2 个句柄
复制代码

句柄侧必须在操作系统层放开,my.cnf 里写没用:
  1. # /etc/systemd/system/mysqld.service.d/limits.conf
  2. [Service]
  3. LimitNOFILE=1048576
复制代码
  1. systemctl daemon-reload && systemctl restart mysqld
复制代码

重启后必须回查协商结果,而不是看配置文件:
  1. SELECT @@open_files_limit, @@max_connections;
  2. SHOW GLOBAL STATUS LIKE 'Open_files';
复制代码

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

步骤 5:留一条救命通道

max_connections 满了的时候,root 也连不进去,mysqladmin 一样连不进去。MySQL 8.0.14+ 给了管理端口:
  1. [mysqld]
  2. admin_address = 127.0.0.1
  3. 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、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-11-8 00:11 , Processed in 0.024346 second(s), 9 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表