|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
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
- [Service]
- 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+ 给了管理端口:
- [mysqld]
- 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 峰值 | 1180 | 236 | | Max_used_connections | 1180 | 241 | | Sleep 连接占比 | 82% | 31% | | 最长 Sleep 时长 | 42600s | 590s | | Open_files 峰值 | 63100(limit 65535) | 18400(limit 1048576) | | Connection_errors_max_connections / 天 | 42000 | 0 | | Aborted_clients / 天 | 17000 | 210 | | mysqld RSS | 41G | 12G | | OOM kill | 2 次/周 | 0 | | P99 建连耗时 | 210ms | 3ms |
业务侧 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 是"故障现场",不是"业务需求"。拿故障数据当容量基线,只会把下一次爆炸的阈值一起抬高。 |
|