MySQL连接池耗尽故障排查:从监控告警到索引优化实战
从一次“神不知鬼不觉”的小抖动说起。某个周三下午,业务群里突然有人反馈“后台导出报表特别慢,转圈半天没反应”,紧接着客服那边说“用户下单提交一直转菊花”。第一反应当然是先看一眼监控,结果Prometheus上MySQL连接数曲线直接拉满,Grafana面板一片飘红,连接池使用率接近100%。说实话,看到这个画面心里反而踏实了一些——至少故障是“看得见”的,而不是那种连日志都没有、只能靠猜的悬案。这篇文章我就用这次故障作为完整案例,把“监控发现异常 → 日志定位线索 → 排查根因 → 恢复验证”整条链路串起来,讲讲我在实际排障中是怎么操作的,监控该怎么配、日志该怎么挖、排查命令该怎么用,希望能给正在做运维或后端开发的同路人一些能直接用上的思路。
1. 监控先行:先有“眼睛”,才有排障的底气
1.1 从Prometheus到Grafana,监控体系是怎么搭出来的
这次故障能在一开始就被发现,靠的不是运气,而是之前花时间搭好的一套监控体系。我们用Prometheus做指标采集,Grafana做可视化展示,Alertmanager负责告警推送。架构上其实非常简单,数据链路大概是:各类exporter(mysqld_exporter、node_exporter、redis_exporter等)暴露/metrics接口,Prometheus定时拉取,存进时序数据库,Grafana从Prometheus查询数据画面板,再配上告警规则。
具体到MySQL的监控,我建议至少要盯这几个核心指标:连接数(threads_connected)、活跃连接数(threads_running)、慢查询数量(slow_queries)、QPS/TPS、缓存命中率、临时表创建量、以及InnoDB的行锁等待次数。我这次就是靠连接数曲线发现问题——正常情况下连接数稳定在30到50之间,故障发生时直接冲到了400多,而且threads_running长期大于20,说明大量SQL在真正地执行而不是空闲等待,这已经是比较危险的信号。
Grafana面板我习惯按业务维度分层:第一层是总览视图(所有核心服务的黄金指标),第二层是某个服务的详细指标,第三层是数据库、缓存这类中间件的深度指标。理论上告警只需要在第一层配,让你“知道出事了”,剩下的交给登录服务器去看。这里有个特别重要的教训: 告警不是越多越好,规则太敏感会让人麻木,最终变成“狼来了” 。我们后来把所有告警收敛成三类:可用性告警(进程挂没挂)、性能告警(响应时间、连接数超过阈值)、容量告警(磁盘使用率、连接池水位)。
1.2 监控指标怎么选才不“裸奔”
很多团队搭监控只盯着CPU和内存,说实话这远远不够。以MySQL为例,如果你只看了CPU使用率,故障发生时你可能会看到CPU到了80%以上,但你不知道是哪个SQL导致的、是连接数暴涨导致的还是全表扫描导致的。我的经验是: 数据库监控要纵向打通 ,从操作系统层(CPU、内存、磁盘IO、网络)、数据库层(连接数、慢查询、锁等待、buffer pool命中率)一直看到业务层(核心接口的P99延迟、错误率)。
这里分享一个我自己的选择标准:每个组件至少配4个“黄金指标”,即延迟(Latency)、流量(Traffic)、错误(Errors)、饱和度(Saturation)。比如MySQL的黄金指标可以这样对应:
| 指标维度 | 具体监控项 | 告警阈值参考 |
|---|---|---|
| 延迟 | 慢查询数量、SQL平均执行时间 | 慢查询数 > 5/分钟 或 平均执行时间 > 1s |
| 流量 | QPS、TPS、连接数 | QPS突增50%以上 |
| 错误 | 主从延迟、复制错误、死锁数 | 主从延迟 > 10s 或 复制线程停止 |
| 饱和度 | threads_running、磁盘使用率、连接池使用率 | threads_running > 20 持续5分钟,磁盘 > 85% |
另外强烈建议把告警信息推到企业微信或者钉钉机器人上,这样不用一直盯着屏幕也能第一时间收到通知。Alertmanager的配置不复杂,核心就是配一个webhook地址,告警规则用PromQL表达式去描述,比如MySQL连接数过高就可以写成:
groups:
- name: mysql_alerts
rules:
- alert: MySQL连接数过高
expr: mysql_global_status_threads_connected > 300
for: 5m
labels:
severity: warning
annotations:
summary: "MySQL连接数超过300,当前值 {{ $value }}"
2. 日志挖坑:从慢查询日志到应用日志的全链路追踪
2.1 从慢查询日志找出“罪魁祸首”
监控只能告诉我们“系统出问题了”,但具体是哪个请求、哪条SQL导致的问题,必须靠日志来定位。这次故障排查中,我打开MySQL慢查询日志(日志目录在/var/log/mysql/mysql-slow.log),用
mysqldumpslow
工具做了个排序分析,很快就发现一条之前从没注意过的SQL:
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10 OFFSET 20000;
这个查询本身逻辑很简单,就是“查某个用户的历史订单分页数据”,但问题在于
ORDER BY created_at DESC
走了文件排序(filesort),而且因为
user_id
虽然有索引,但排序字段不在索引里,MySQL只能先把所有符合条件的行找出来,再在内存或磁盘上排序,最后才取10条返回。数据量小的时候感觉不到,等订单表积累到几百万行之后,这个操作瞬间变成噩梦。
为什么慢查询日志如此重要? 因为它是数据库侧的“第一现场”。没有它,你只看应用日志只能看到“超时”“连接池耗尽”这类表象结果,根本不知道底层在干什么。这里提醒一下:MySQL默认是关闭慢查询日志的,一定要记得在配置文件里打开,并设置合理的阈值:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
我个人习惯把long_query_time设置成1秒,也就是说任何执行超过1秒的SQL都会记录下来,配合log_queries_not_using_indexes把没走索引的查询也记一版,对日常优化非常有用。
2.2 应用日志与系统日志:拼接完整的故障现场
慢查询日志定位到了具体SQL,但还没有回答一个关键问题:为什么这条SQL会在那个时候集中出现?这时候就要去看应用日志了。我们后端是Java Spring Boot项目,日志统一由Logback输出到/var/log/app/,通过Loki采集。在Grafana的Explore页面里,我按时间范围搜索“orders”相关的日志,结果发现大量
org.springframework.dao.DataAccessException
异常,核心堆栈是:
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms
HikariCP连接池的连接获取超时,原因是数据库端的连接被那些慢SQL占着不释放,新的请求拿不到连接,只能排队等30秒然后超时——用户侧感知到的“转菊花”就是这么来的。这是一个典型的“滚雪球”效应:慢SQL导致连接被占满,连接占满导致新请求排队超时,超时又让应用线程堆积,最终整个服务的可用性崩塌。
Linux系统层面也别忘了看,登录到数据库服务器上第一步就是看负载和内存:
uptime
free -h
top -c
iostat -x 1
那台数据库服务器当时load average到了28(4核机器),iowait占比也明显偏高。这些系统日志和指标进一步印证了数据库确实在“满负荷”工作,而不是网络问题或者应用本身卡死。 我的排查顺序很固定:先看监控和系统层确认是哪个组件异常,再进该组件查慢日志/错误日志,最后回到业务日志找触发条件 ,这样不会在无关方向上浪费时间。
2.3 日志不完备时,如何靠现有线索“拼图”
说实话,很多团队的日志并没有做得非常规范,这次的日志也并不是一开始就全的。早期我排查问题经常遇到日志里啥都没有的尴尬情况——应用日志只打印了Info级别,错误堆栈被吞了,慢查询日志也没开。所以 排障的过程对我来说往往也是“补日志”的过程 。
如果这次没有慢查询日志,我大概率会怎么做?第一,在数据库上用
SHOW FULL PROCESSLIST
看当前正在执行的SQL,这个命令能实时看到每个连接的执行状态,是排查数据库问题最常用的手段之一。第二,通过
performance_schema
或者
sys
库里的
statement_analysis
视图找出历史执行次数多、总耗时长的SQL。第三,对于MySQL 8.0,还可以直接查
information_schema.innodb_trx
和
sys.innodb_lock_waits
来看事务和锁等待情况。
补一条建议,如果你用的是MobaXterm这类SSH工具,记得把会话的日志保存功能打开(MobaXterm自带“Save session output to a file”的功能),这样你在终端敲过的每一个排查命令、看到的每一行输出都有存档,复盘时翻一翻比回忆靠谱得多。这个习惯在我多次实战中救了命,特别是那种“临门一脚找到根因但忘了记录当时看到什么”的情况。
3. 根因定位:一次完整的SQL优化与系统恢复实录
3.1 用EXPLAIN看执行计划,锁定索引问题
在拿到慢SQL之后,我第一时间用
EXPLAIN
看了这条查询的执行计划,结果如下:
EXPLAIN SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10 OFFSET 20000;
执行计划里有几个关键信息让我确定了根因:
| 列名 | 值 | 说明 |
|---|---|---|
| type | ref | 用到了user_id索引的等值查找,没有全表扫描 |
| rows | 352000 | 预估扫描35万行,这是最大的问题 |
| Extra | Using where; Using filesort | 排序没有走索引,需要文件排序 |
问题已经很清楚了:虽然user_id索引帮我们过滤出了某个用户的35万条订单记录,但MySQL还需要对这35万行按created_at重新排序,然后才取第20001到20010条。大家感受一下, MySQL为了一次展示10条数据,干了一整套“把35万行捞出来、全部排好序、再丢掉不需要的”的体力活 ,而且这个动作在一个很短的时间窗口内被高频触发,数据库不崩溃才怪。
为什么骑手一开始没走这个索引?因为
ORDER BY created_at DESC
不在索引里,MySQL的优化器评估下来觉得用user_id索引再排序,成本不一定比全表扫描低,所以选了perform文件排序。解决思路就清晰了:让排序也能走索引。我们可以联合索引
(user_id, created_at)
,这样MySQL既可以用user_id做等值过滤,又可以直接按created_at的顺序读取索引,不需要额外排序。
3.2 从根源修复:建联合索引 + 改写分页SQL
索引问题找到后,接下来的操作就比较“常规操作”了:
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);
这里说明一下索引字段顺序, user_id放前面是因为查询条件是等值过滤,created_at放后面负责排序 ,这样索引B+树天然就是按“用户+时间”排好序的,只需要顺序扫描索引节点,跳过前20000条,取接下来10条就好,不需要filesort。
为了保险起见,我还把分页SQL改写了。深分页OFFSET(就是上面那种OFFSET 20000)是另一种意义上的性能杀器,即使加了索引,OFFSET越大,MySQL扫描的索引节点也越多。改用“游标分页”是更好的方案:
-- 原来的方式(OFFSET越大越慢)
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10 OFFSET 20000;
-- 改写成游标方式(记住上次查询的最后一条时间戳)
SELECT * FROM orders
WHERE user_id = 12345
AND created_at < '2026-02-20 12:34:56'
ORDER BY created_at DESC
LIMIT 10;
对于“加载更多”这种场景,游标分页体验更好,数据也不容易重复。当然如果产品形态必须支持任意页码跳转,那可以考虑用搜索引擎(Elasticsearch)或者离线数据仓库来兜底,这是另一个话题了。对于这次故障,添加联合索引已经是见效最快的操作。
3.3 恢复与验证:从“恢复服务”到“确认根因”的完整闭环
索引创建完成后,我并没有立刻宣布“故障解决”,而是按照下面的步骤做了完整的验证:
- 先看监控指标:Prometheus上连接数曲线是否回落,threads_running是否恢复到20以下。
- 再查慢查询日志:等在慢日志里再也看不到那条SQL后,才算真正过关。
- 最后做功能验证:用接口实际调用一次分页查询,确认响应时间从原来的5秒降到50毫秒以内,同时观察应用日志中是否还有获取连接超时的异常。
这里说一个细节:建索引本身是DDL操作,如果表特别大,直接执行
ALTER TABLE
可能会锁住写入请求,加剧故障。实际操作中我先在从库上验证索引创建耗时,评估主库执行的风险,然后选择在业务低峰期一次性执行。当时为了快速止血,还设置了一个连接池超时时间的临时调整,让应用侧能更快地把失败请求fail fast,而不是傻等30秒,这种“先恢复服务、再优化根因”的思路在故障处理里很重要。
最后用
SHOW FULL PROCESSLIST
确认一下没有残留的慢查询进程占着连接,整个恢复流程就完整了。
故障处理的完整体验应该是:监控说“我出问题了” → 日志说“我是谁” → 人工分析说“我为什么出问题” → 修复后监控和日志联合确认“我彻底解决了”
,缺了任何一环都不算闭环。
4. 常见问题与排查技巧实录
4.1 这次故障之外的3个“同类坑”
类似的故障我踩过不止一次,这里把常见的坑和排查思路整理成一个速查表,方便大家直接参考:
| 故障现象 | 典型原因 | 第一时间排查命令/方法 |
|---|---|---|
| 数据库连接池耗尽 | 慢SQL、连接泄漏、突发流量 |
SHOW FULL PROCESSLIST
、查看HikariPool超时日志
|
| 接口响应突然变慢 | 慢查询、外部服务依赖抖动、GC问题 |
慢查询日志、
jstack
抓线程栈、Nginx访问日志看响应时间
|
| 死锁/锁等待 | 事务乱序、缺少索引导致行锁升级 |
SHOW ENGINE INNODB STATUS
、
sys.innodb_lock_waits
|
| 系统load飙高但CPU不高 | 磁盘IO瓶颈、swap频繁 |
iostat -x 1
、
vmstat 1
|
| Redis慢查询/阻塞 | 大key、热key、慢命令 |
SLOWLOG GET
、
redis-cli --bigkeys
|
比如有一次线上整条链路都很慢,但MySQL侧完全没问题,后来排查发现是某个外部接口响应越来越慢,调用方没有设置超时时间,导致线程大量阻塞。这种“外部依赖抖动”在日志上表现得很明显——应用日志里大量超时异常都来自同一个第三方客户端。用
jstack
抓一下线程栈,看看线程到底卡在哪个调用上,基本就能实锤。
还有一次更隐蔽,数据库磁盘没满但IO等待很高,排查下来是binlog刷盘策略问题,
sync_binlog
从0改成了1之后没有评估写入压力的变化。这个案例想说明的是,
排障时不要只盯着应用层,数据库自己的各种日志(binlog、redo log、error log)都是线索来源
,不过binlog主要用于数据复制和恢复,只有存在复制延迟或数据不一致时通常才需要重点关注。
4.2 日志排查实战:从小白到熟练的3个阶段
第一阶段是“会看”。这个阶段只要掌握几个核心命令就行:查看实时日志
tail -f
、按关键字搜索
grep
、按时间段过滤用
sed
结合时间戳,再加一个查看系统日志的
journalctl -u 服务名
。够用了,大部分问题靠这几个命令就能定位个七八成。
第二阶段是“会捞”。当线上服务有多个实例时,不可能一台一台登录去找日志,这时候就需要日志采集系统。我们用Loki + Promtail做了一套轻量日志收集,所有服务日志统一进入Loki,Grafana上直接搜Label和关键字。相比ELK那套重方案,Loki胜在轻量、部署快、成本低,特别适合中小团队。如果你对Docker容器比较熟,Loki官方文档里的docker-compose一键部署脚本可以让你十分钟内跑起来。
第三阶段是“会挖”。到了这个阶段,你会开始关注日志本身的质量——有没有打印关键参数、有没有合理的traceId串联整条调用链、异常堆栈是否完整。我强烈建议在日志里为每个请求生成一个traceId,全链路透传下去,这样排查问题时只需根据一个ID,就能把网关、微服务、数据库连接操作完整地串起来,效率提升不是一点半点。
4.3 我个人的几个排障习惯
最后分享几个长期实践中沉淀下来的习惯,不一定多高级,但在关键时刻很管用:
- 做任何变更前先看监控基线 。如果不知道系统正常时的指标长什么样,你无法判断异常是变更引起的还是偶发抖动。我习惯在Grafana里保存每个核心组件的“健康运行截图”,没事就翻一翻,时间长了自然有了体感。
- 日志里永远要有时间戳和上下文 。只有错误堆栈没有时间点的日志,基本等于废日志。排查时必须能在日志里还原出“在哪个时间点、是哪个请求、做了什么操作”这三要素。
- 处理故障时先保存现场再动手 。脚本也好、数据库状态也好、线程dump也好,先把证据留下来,然后再去做修复。很多人一上来就重启服务,结果根因被覆盖,过几天故障卷土重来,变本加厉。
- 为常用命令做一个小备忘单 。比如我自己就保存了一个“排障命令速查”文档,从系统层(top、free、iostat、vmstat)到中间件层(MySQL、Redis、Nginx),每种组件的重点排查命令、日志路径、常见问题应对全都列出来。故障来的时候时间就是生命,少敲几个命令、少走几次弯路都是实实在在的效率提升。
这次从监控告警到慢查询定位,再到索引修复和日志复盘,整个过程下来,最深的体会是: 监控和日志从来不是两个独立的东西,它们是一个闭环的两端 。没有监控,你不知道什么时候该去查日志;没有日志,你看到了异常也只能干瞪眼。希望这篇实战记录能帮你把这条链路建立起来,下次再遇到类似的问题,不至于手忙脚乱。
更多推荐
所有评论(0)