一次 HikariCP 连接池耗尽导致的线上雪崩排查实录------问题概览
适用场景:线上接口响应慢、数据库连接池(Hikari)排队超时、想定位到底是哪条 SQL 拖垮了服务。
环境:MySQL 8.x,自建实例,数据目录
/var/lib/mysql/data。示例里的表名、库名按你们实际环境替换。
〇、背景:为什么从慢 SQL 查起
先看一段典型的线上报错(摘自 2026-10-08 13:10:11 的日志):
MyBatisSystemException
└─ Error querying database
└─ CannotGetJdbcConnectionException: Failed to obtain JDBC Connection
└─ HikariPool-1 - Connection is not available, request timed out after 30000ms
翻译一下:一个请求等了 30 秒没拿到数据库连接,最后报错。但报错的那个接口往往只是受害者,真正的原因通常是别的 SQL 把连接长期占着。所以排查方向是:找到"谁占着连接不放"------大概率是一条慢 SQL,或者一个开了很久的事务。
一、打开慢查询日志
1.1 先看当前配置
登录 MySQL 后执行:
sql
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';
SHOW VARIABLES LIKE 'log_output';
看到类似这样的结果:
slow_query_log ON
long_query_time 1.000000
slow_query_log_file /var/lib/mysql/data/mysql-lpt3-slow.log
log_output TABLE
逐行解释:
slow_query_log = ON表示慢日志已经开了long_query_time = 1表示执行超过 1 秒的 SQL 会被记录log_output = TABLE关键 ,表示慢 SQL 写到mysql.slow_log这张表里,而不是写文件。如果是FILE,才会写到上面那个.log文件。
1.2 如果没开,怎么开
MySQL 8.0.14 以上推荐用 SET PERSIST,重启后依然有效:
sql
SET PERSIST slow_query_log = ON;
SET PERSIST long_query_time = 1;
SET PERSIST log_output = 'TABLE';
老版本(8.0.14 以下)用 SET GLOBAL,但重启后会丢 ,需要让运维在 my.cnf 的 [mysqld] 段补上:
ini
[mysqld]
slow_query_log = 1
long_query_time = 1
log_output = TABLE
改完重启 MySQL 才生效。
权限提示:
SET PERSIST/SET GLOBAL需要SUPER或SYSTEM_VARIABLES_ADMIN权限。业务账号执行会报ERROR 1227 (42000): Access denied,找 DBA 执行。
1.3 确认真的在记录
执行一条肯定慢的 SQL:
sql
SELECT SLEEP(3);
然后查一下有没有记录进来:
sql
SELECT start_time, query_time, sql_text
FROM mysql.slow_log
ORDER BY start_time DESC
LIMIT 5;
能看到刚才那条 SELECT SLEEP(3) 就说明配置成功。
注意:改完
long_query_time后,已经存在的数据库连接还在用旧值。让应用重启一下(连接池会重建连接),或者新开的连接才会用新阈值。
二、慢 SQL 存在哪:mysql.slow_log 表
mysql.slow_log 是 MySQL 自带的系统表,跟 mysql.user 是邻居,初始化时就建好了,不用你手动创建。
表结构主要看这几列:
| 列名 | 含义 | 排查时关注什么 |
|---|---|---|
start_time |
SQL 开始执行的时间 | 锁定故障时段 |
query_time |
执行耗时 | 越大越可疑 |
lock_time |
等锁耗时 | 占比大说明锁竞争 |
rows_examined |
扫描行数 | 远大于 rows_sent 就是没走索引 |
rows_sent |
返回行数 | 和上面那列对比着看 |
user_host |
哪个账号从哪台机器发起 | 区分是哪个服务 |
sql_text |
完整 SQL | 最终要看的 |
三个使用上的坑
坑 1:这表会无限增长。 MySQL 不会自动清理 mysql.slow_log,量大了查询会越来越慢,CSV 文件也占磁盘。每周导出归档一次,然后清空:
sql
-- 清空(记得先导出备份)
TRUNCATE TABLE mysql.slow_log;
坑 2:CSV 引擎不支持索引。 所以任何查询都是全表扫,行数多的时候要加时间过滤,别裸跑聚合。
坑 3:权限。 普通账号默认看不到 mysql 系统库的表,让 DBA 授权:
sql
GRANT SELECT ON mysql.slow_log TO '你的账号'@'%';
三、查慢 SQL:按场景选语句
下面几条语句是日常最常用的,按需挑。先用第一条定位方向,再用后面的深挖。
3.1 最常用的:看最近的慢 SQL
sql
SELECT start_time, query_time, lock_time,
rows_examined, rows_sent,
user_host, LEFT(sql_text, 250) AS sql_text
FROM mysql.slow_log
WHERE start_time >= '2026-10-08 00:00:00'
AND start_time < '2026-10-09 00:00:00'
ORDER BY TIME_TO_SEC(query_time) DESC
LIMIT 50;
改日期就能查任意一天。用 TIME_TO_SEC(query_time) 而不是直接 ORDER BY query_time,是为了避免 TIME 类型极端值的坑。
3.2 聚合:找最费时间的 SQL 指纹
单次最慢的 SQL 不一定是罪魁。举个例子:A 语句跑了 60 秒一次,B 语句每次 2 秒但跑了 1000 次------B 吃掉的数据库时间其实更多。所以聚合版更实用:
sql
SELECT LEFT(sql_text, 120) AS sql_fingerprint,
COUNT(*) AS exec_count,
ROUND(SUM(TIME_TO_SEC(query_time)), 1) AS total_sec,
ROUND(AVG(TIME_TO_SEC(query_time)), 2) AS avg_sec,
MAX(TIME_TO_SEC(query_time)) AS max_sec,
ROUND(AVG(rows_examined)) AS avg_rows_scanned
FROM mysql.slow_log
WHERE start_time >= '2026-10-08 00:00:00'
AND start_time < '2026-10-09 00:00:00'
GROUP BY LEFT(sql_text, 120)
ORDER BY total_sec DESC
LIMIT 50;
total_sec 排第一的就是"吃掉最多数据库时间"的 SQL,优先优化它。
3.3 故障时段的:锁定事发那几分钟
假设报错时间是 13:10:11,往前推 30 秒连接池就开始排队了,所以重点看 12:50 到 13:30 这个窗口:
sql
SELECT start_time, query_time, lock_time,
rows_examined, rows_sent,
user_host, db, LEFT(sql_text, 300) AS sql_text
FROM mysql.slow_log
WHERE start_time BETWEEN '2026-10-08 12:50:00' AND '2026-10-08 13:30:00'
ORDER BY start_time, TIME_TO_SEC(query_time) DESC
LIMIT 100;
重点看 13:05 ~ 13:10 之间开始执行的长查询,它们的执行时间覆盖了报错点,基本就是元凶。
3.4 找没走索引的查询
sql
SELECT start_time, query_time, rows_examined, rows_sent,
LEFT(sql_text, 150)
FROM mysql.slow_log
WHERE start_time >= '2026-10-08 00:00:00'
AND start_time < '2026-10-09 00:00:00'
ORDER BY rows_examined DESC
LIMIT 20;
扫了几十万行、返回了十几行------典型的索引缺失。
3.5 排查锁等待
sql
SELECT start_time, lock_time, query_time, LEFT(sql_text, 150)
FROM mysql.slow_log
WHERE start_time >= '2026-10-08 00:00:00'
AND start_time < '2026-10-09 00:00:00'
ORDER BY lock_time DESC
LIMIT 20;
lock_time 占 query_time 一大半的,说明主要在等锁,不是查询本身慢。
3.6 排除分析 SQL 自己
跑上面这些分析语句本身也会被记进慢日志(因为它们也不快),加个过滤更干净:
sql
AND sql_text NOT LIKE '%mysql.slow_log%'
AND sql_text NOT LIKE '%information_schema%'
四、拿到慢 SQL 之后怎么判断
结果出来后,按这四个特征挑嫌疑对象:
| 特征 | 例子 | 说明 |
|---|---|---|
query_time 超过 30 秒 |
query_time = 00:00:33 |
跟连接池 30 秒超时量级吻合,一条就占死一个连接 |
rows_examined 远大于 rows_sent |
扫 80 万行、返回 12 行 | 没走索引,先看执行计划 |
lock_time 占大头 |
query_time=8s, lock_time=7s |
在等锁,往下查长事务或 DDL |
| 开始时间卡在故障窗口 | start_time = 13:07:22 |
时间完全覆盖故障点,直接锁定 |
看时间线是关键技巧 :把 3.3 的结果按 start_time 排开,找一条"13:0X 开始执行、执行时长跨越 13:10"的查询,它就是压垮连接池的那根稻草。
五、从 SQL 反查到代码
SQL 里一般能找到 Mapper 的痕迹。打开对应 Mapper 的 DEBUG 日志:
yaml
logging:
level:
com.chinaunicom.medical.ihm.xxx.repository.mapper.XxxMapper: DEBUG
日志会打出:
DEBUG c.c.m.i.x.r.mapper.XxxMapper.selectByPage : ==> Preparing: SELECT ...
DEBUG c.c.m.i.x.r.mapper.XxxMapper.selectByPage : ==> Parameters: ...
DEBUG c.c.m.i.x.r.mapper.XxxMapper.selectByPage : <== Total: 12
logger 名直接就是 Mapper 全限定类名 + 方法名,一眼定位到代码位置。
拿到代码后用 EXPLAIN 看执行计划:
sql
EXPLAIN SELECT * FROM auth_log WHERE user_id = 123 ORDER BY create_time DESC;
重点看 type 列:ALL 是全表扫描(糟糕),ref/range 走了索引(还行);再看 rows 列估算扫描行数,以及 Extra 列有没有 Using filesort、Using temporary。
六、配合使用的辅助手段
慢日志是事后分析,如果还想看"现场",配合下面几个:
6.1 看当前谁占着连接
sql
SELECT id, user, host, db, command, time, state, info
FROM information_schema.processlist
WHERE command != 'Sleep'
AND time > 5
ORDER BY time DESC;
time是已执行秒数state是关键:Sending data说明在扫表,Waiting for table metadata lock说明被 DDL 或长事务卡住info是正在执行的 SQL
权限提示:这个视图默认只显示自己账号的连接。看全局需要
PROCESS权限,让 DBA 给监控账号加一下:GRANT PROCESS ON *.* TO 'monitor'@'监控机IP';
6.2 看长事务(比慢 SQL 更隐蔽)
sql
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_locked, trx_rows_modified, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
举个典型场景:trx_query 显示只是一条小 UPDATE,但 duration_sec 是 200 秒------说明这个事务打开后一直没提交,连接被白白占着。这在 processlist 里还显示 Sleep,容易漏掉,所以两个视图都要查。
6.3 开连接泄漏检测
如果怀疑是代码拿了连接不还,在 Spring 配置里加:
yaml
spring:
datasource:
hikari:
leak-detection-threshold: 15000 # 15秒没归还就打 WARN + 借出时的调用栈
connection-timeout: 20000
原则是泄漏阈值要小于连接超时时间,让报警赶在报错前面出现。触发后日志会精确到哪个类哪一行借了连接不还。
6.4 看线程卡在哪
出问题时抓一次线程栈:
bash
jstack <pid> > dump.txt
搜 http-nio-8886-exec 开头的线程,看大量线程卡在哪个位置:卡在 HikariPool.getConnection 是在排队等连接(受害者),卡在 SocketInputStream.read 是连接在手、在等数据库返回(持有者)。
七、日常维护
慢日志表定期清理,加个定时任务(每周跑一次):
sql
-- 先导出归档
SELECT * FROM mysql.slow_log
INTO OUTFILE '/var/lib/mysql/data/backup/slow_log_20261008.csv'
FIELDS TERMINATED BY ',' ENCLOSED BY '"';
-- 再清空
TRUNCATE TABLE mysql.slow_log;
阈值建议 :OLTP 场景长期保留 long_query_time = 1,开销很小(对性能影响通常不到 1%),出问题时不用临时抱佛脚。
权衡 log_output :TABLE 查询方便但会无限增长;FILE 可以配合 pt-query-digest 做专业分析但要自己轮转。想要两者兼得就用 FILE,TABLE。
八、一次完整排查的执行顺序
以这次 2026-10-08 13:10 的故障为例,串一遍:
1. 跑 3.3(故障时段查询),锁定 12:50~13:30 的慢 SQL
2. 看有没有 query_time 超过 30 秒、或跨越 13:10 的长查询
3. 如果有 → 拿 SQL 文本去日志平台搜,用 traceId 反查接口 URI
4. 如果全是短查询但 rows_examined 爆表 → 走第五节反查 Mapper 补索引
5. 如果慢日志没线索 → 查 6.2 长事务,或开 6.3 泄漏检测抓堆栈
6. 定位到代码后,补索引 / 改写查询 / 收缩事务边界
7. 复现验证,确认 Hikari 的 Pool stats 里 waiting 归零
排查完记得把临时开的 DEBUG 日志关掉,mysql.slow_log 归档清理一次。
文档基于 2026-10-08 生产故障排查过程整理。环境:MySQL 8.x + MyBatis-Plus + HikariCP + Spring Boot 3。