☰
连接池爆满排查实战:从HikariCP报错到慢SQL与大事务根因
2026/10/1 22:36:20 网站建设 项目流程

只要是跑Java Web应用、Spring Boot微服务,或者任何连MySQL的业务系统,连接池爆满这个问题基本都躲不过。你大概率见过这样的报错:HikariCP的connection is not available, request timed out after 30000ms,或者是Druid的wait millis 30000, active 20, maxActive 20。第一次碰到的时候,很多人第一反应是“连接数不够,调大maxPoolSize”,结果调完发现治标不治本,高峰期照样爆,甚至把数据库直接拖垮。

这篇文章就把整个排查过程和解决思路完整复盘一遍。文章会讲清楚连接池爆满时先看什么、每个报错参数到底在说什么、如何一步步定位到真正的根因,以及从应用端和数据库端分别怎么下手解决。无论你是刚接触连接池的新手,还是被线上告警折腾过的老手,这波经验应该都能直接抄作业。

1. 连接池爆满的表现与参数误读:先搞清楚报错在说什么

1.1 两种主流连接池的报错长什么样

国内Java生态里,连接池无非就是HikariCP、Druid、c3p0、dbcp这几家,现在主流基本是HikariCP和Druid。两者的报错风格不太一样,但表达的意思是一致的:池子里没有空闲连接可用了,请求在排队等连接,等到超时就直接抛异常。

HikariCP的典型报错:

java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms (total=20, active=20, idle=0, waiting=15)

注意看最后括号里的几个数字:total=20是池子当前总共管理的连接数,active=20表示20个连接全部被业务线程借走,idle=0说明空闲连接为0,waiting=15表示还有15个线程在排队等连接。

Druid的报错风格就直白些:

com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 30000, active 20, maxActive 20, creating 0

这里的active 20、maxActive 20和HikariCP的active=20, total=20是一个意思——池子满了。

这里有一个非常重要的点:报错说的是“连接池满了”,但连接池满了只是结果,不是原因。就像银行柜台排队排到了门口,你盯着“柜台数不够”去加柜台,却没想过是不是前面有个客户在窗口办了一个小时不动。连接池的active连接被占满,意味着有20个数据库连接正被业务代码占用着,这20个连接在干什么,才是关键。

1.2 连接池几个关键参数的真正含义

先说几个绕不开的参数,很多人配置连接池只填了一个maximum-pool-size,其他全靠默认值,这其实埋了不少雷。

HikariCP的核心参数:

参数名默认值含义
maximumPoolSize10池中允许的最大连接数
minimumIdle10池中维护的最小空闲连接数
connectionTimeout30000ms客户端从池中获取连接的等待超时时间
idleTimeout600000ms空闲连接超过该时长会被回收
maxLifetime1800000ms连接最大存活时间,超过后会被强制退役
validationTimeout5000ms获取连接时校验可用性的超时时间

Druid的核心参数对应关系:

参数名默认值含义
maxActive8池中最大连接数
minIdle0最小空闲连接数
maxWait-1(无限等待)获取连接的最大等待毫秒数
removeAbandonedfalse是否开启泄漏连接自动回收
removeAbandonedTimeoutMillis300000连接被占用超过该毫秒数视为泄漏
testWhileIdletrue空闲时是否校验连接有效性

在排查爆满问题时,我一般先看两个参数:连接获取超时时间和最大连接数。报错里出现的30000ms,就是客户端等待获取连接最多等了30秒,等不到就放弃。这30秒内,请求线程全被堵在getConnection()这一步,后续的SQL根本发不出去。

1.3 一个容易踩的认知误区:最大连接数不是越大越好

很多运维同学遇到连接池爆满,第一反应是“maxActive=20太小了,改成100、200”。这个思路只对了一小半。

连接池的理论并发上限,并不是你写了100就真的有100。一个MySQL实例能处理的并发连接数有限,连接数越多,每个连接分到的CPU、内存、锁资源就越少。尤其当业务SQL本身偏慢时,你把池子从20调到100,反而会让数据库同时处理100个慢查询,整体吞吐量断崖式下跌。

我曾处理过一个线上事故:Druid连接池maxActive=50,高峰期报GetConnectionTimeoutException。DBA直接把maxActive调到200,结果数据库的Threads_running从几十飙到两百多,CPU直接打到100%,最终数据库hang死,业务全部超时。后来我们把maxActive降回60,同时优化了一批慢SQL,问题彻底解决。连接池爆满时的第一要务不是加连接,而是搞清楚已有的连接为什么不够用。

提示:如果你在报错里看到waiting或者排队线程数持续增长,先别急着改参数,登录数据库看一眼当前有哪些连接、在跑什么SQL、跑了多久,这一步通常能把问题缩小到具体模块。

2. 排查取证:从复现到定位根因的完整链路

2.1 第一步:把池子调小,稳定复现问题

我排查这类问题不是直接上生产环境的大池子去分析,而是先找一个测试环境,把连接池参数调到很小,比如maximumPoolSize=5、minimumIdle=2,然后压一波流量,确保问题能稳定复现。能复现,才能观测到完整的证据链。

这里有一个小技巧:调小连接池不是为了让问题更严重,而是为了缩短每个排查循环的周期。生产环境池子大、流量高,你可能等很久才能抓到一次active=total的瞬间;池子调小后,几乎每次压测都会触发爆满,配合线程堆栈和监控,很快就能看清连接被谁占着。

复现时我会同步做三件事:

  • 打开连接池统计日志(HikariCP的poolStats、Druid的StatFilter),记录active、idle、waiting的变化曲线;
  • 开启show processlist定时采集,每5秒抓一次数据库当前的连接快照;
  • 准备一份业务线程的jstack输出,在爆满发生的时刻抓取应用线程状态。

2.2 第二步:show processlist,看清线程在干什么

show processlist是排查连接池问题时最直接的工具,不用装任何插件,MySQL自带。它会把当前所有连接到实例的线程列出来,每行包含Id、User、Host、db、Command、Time、State、Info这几列。

重点关注三个地方:

  • Command列:正常情况下,绝大多数连接应该是Sleep(空闲),如果Query、Execute很多,说明连接都在跑SQL。
  • Time列:Query状态的线程如果Time值很大,比如几百秒,那基本就是慢SQL实锤。
  • State列:如果大量线程卡在Waiting for table metadata lock、Waiting for lock、Updating这类状态,说明问题不是SQL慢,而是锁等待。

举个例子,有一次排查中发现连接池被打满,执行show processlist后,20个连接里有14个是Query状态,全部指向同一张用户表,Info列里能看到同一个慢查询模板,执行时间从几秒到几十秒不等。这就解释通了:一个慢查询把连接池的active打满,后续所有请求都在等连接。

show processlist有个局限:它只显示当前正在执行的语句,如果连接是Sleep状态,你看到的就是空白。这时候还得配合information_schema.innodb_trx和performance_schema来看事务。

2.3 第三步:区分“慢SQL堆积”与“锁等待”

连接被占满,本质上只有两种可能:SQL执行太慢,连接一直不释放;事务迟迟不提交,连接被事务粘住了。

判断方法很简单。查看information_schema.innodb_trx:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query, trx_rows_modified FROM information_schema.innodb_trx;

如果查出来的事务trx_state是RUNNING,并且trx_started时间已经过去很久(比如10分钟、半小时),那业务代码里多半有“开启事务后没及时提交”的问题。

再看performance_schema.events_statements_current:

SELECT THREAD_ID, SQL_TEXT, TIMER_WAIT/1000000000 AS cost_second FROM performance_schema.events_statements_current WHERE SQL_TEXT IS NOT NULL ORDER BY TIMER_WAIT DESC LIMIT 20;

TIMER_WAIT的单位是皮秒,除以10^9就是秒。通过这张表可以看到每个连接当前正在执行的SQL和耗时。

这两张表结合起来,基本就能把连接池爆满的锅落到具体SQL或具体事务上了。慢SQL堆积和锁等待的解决思路完全不同,前者要靠加索引、改写SQL,后者要把事务拆小、控制锁范围,所以在排查阶段一定要分清楚。

3. 根因分类:四种最常见的爆满场景

3.1 慢SQL打满连接:查询先慢,连接池后爆

慢SQL是最常见的爆满原因,逻辑很简单:某个查询特别慢,单个连接被占住好几秒,池子里的连接数量有限,当慢查询的速度超过了连接释放的速度,池子就会被慢慢填满。

有一次我们线上出现周期性连接池告警,排查后发现是后台定时任务在每天凌晨跑一个多表JOIN统计,其中一张大表的关联字段没有索引,单次查询耗时3到5分钟。这个任务一跑,20个连接被它自己吃掉十来个,其他业务请求全部拿不到连接。

慢SQL堆积的特征比较明显:

  • show processlist里能看到多个相同模板的Query语句;
  • Time列的数值普遍较大;
  • 压测时active连接数稳步上升,直到打满。

解决思路是SQL优化,不是加连接。加索引、改写关联条件、拆分大查询,单次查询耗时降下来,连接释放速度跟上,池子自然就空了。

经验:排查慢SQL时,可以顺手看下slow_query_log。MySQL的慢查询日志会记录执行时间超过long_query_time(默认10秒)的语句,定位耗时SQL比盯processlist更持久、更全面,因为processlist只能看到“当下恰好正在执行”的SQL。

3.2 连接泄漏:借了没还,池子在慢慢饿死

连接泄漏比慢SQL更隐蔽。慢SQL至少能通过processlist看见,泄漏则完全看不见任何异常SQL——所有连接都处于Sleep状态,但active就是下不来。

最经典的场景是业务代码里写了这样的逻辑:

public void doBusiness() { Connection conn = getConnection(); PreparedStatement ps = null; try { // 执行SQL } catch (Exception e) { // 只处理了异常,没有释放连接 } finally { // finally里又只关了ps,没有关闭conn } }

或者更隐蔽的:从连接池拿连接时用了try-with-resources,但内部又包了一层事务切面,事务方法抛了异常后,切面没有正确回滚并归还连接。

连接泄漏的排查难点在于:连接池里的active连接占比很高,但这些连接看起来全都“空闲”。Druid有removeAbandoned机制,HikariCP可以开leakDetectionThreshold,实在不行就抓jstack看线程栈——泄漏的连接必然被某个线程对象持有,顺着栈能看到是哪个方法打开了连接没关闭。

3.3 大事务长时间持有连接

大事务和慢SQL不一样,慢SQL的耗时主要在SQL执行本身,大事务的耗时可能SQL很快,但因为在一个事务里执行了大量操作,或者事务和外部RPC调用交织在一起,导致事务迟迟不提交。

举个典型例子:

@Transactional public void createOrder(Order order, List<OrderItem> items) { orderMapper.insert(order); // 调用库存服务扣减库存,走HTTP,耗时长 inventoryClient.deduct(order.getSkuId(), order.getCount()); items.forEach(item -> itemMapper.insert(item)); }

这个事务里嵌了一个HTTP调用,假设库存服务平均响应200ms,高峰期可能1秒以上。事务没提交之前,数据库连接一直被占用。如果这种接口的QPS稍微上来,连接池很快被打满。

大事务的排查特征是:show processlist看到连接处于Sleep状态,但information_schema.innodb_trx里能查到对应的事务,trx_started时间很旧,事务里可能已经修改了若干行(trx_rows_modified非零)。

大事务的解决方案不是数据库层面能解决的,必须改代码:把外部调用移出事务,或者用PROPAGATION_NOT_SUPPORTED让外部调用不参与当前事务,再或者把大事务拆成多个小事务。这个在本文第4部分会展开。

3.4 数据库端连接数上限与池参数不匹配

还有一种情况,问题不在应用端,而是MySQL的max_connections配置太小。

比如一个Spring Boot服务,配置maximumPoolSize=30,同时部署了4个实例,那么这4个实例对同一个MySQL实例的合计连接数是30×4=120。如果数据库的max_connections默认是151,理论上还够,但别忘了:数据库自己还有系统连接、监控连接,第三方工具如Navicat也会占用连接。当服务数再增多,几套应用一起连同一个库,很容易把max_connections顶破。

连接池这一侧,参数传的是“我要多少”;MySQL这一侧,参数决定的是“能给多少”。两边一旦不匹配,应用侧的连接池会报HikariPool - Connection is not available,数据库侧则会在错误日志里留下Too many connections的报错。

提示:确定数据库实际允许的最大连接数,可以直接查:

SHOW VARIABLES LIKE 'max_connections'; SHOW STATUS LIKE 'Threads_connected'; SELECT @@max_connections;

Threads_connected和max_connections的差值,就是你的连接余量,建议余量保持在20%以上。

4. 解决方案:技术手段与代码规范双管齐下

4.1 池参数调优:不要只调maxPoolSize

先说结论:连接池参数调整要遵循“够用、留余量、防止雪崩”的原则,而不是无脑调大。

我个人在Spring Boot + HikariCP场景下的推荐配置如下:

spring: datasource: hikari: # 核心配置,根据压测结果确定,一般10~50之间 maximum-pool-size: 20 minimum-idle: 5 # 获取连接超时,建议10秒以内,过长的等待会拖死整个服务 connection-timeout: 10000 # 连接最大存活时间,建议小于数据库wait_timeout max-lifetime: 540000 # 空闲超时 idle-timeout: 600000 # 连接泄漏检测,超过10秒没归还就输出日志 leak-detection-threshold: 10000

这组配置有几个关键逻辑需要解释:

  • maximum-pool-size:这个数字不是拍脑袋定的,建议压测得出来。简单估算公式是:最大池大小 = (单连接QPS × 峰值并发请求量) / 单连接可处理的最大请求数,但实际中更靠谱的做法是拿生产环境高峰期流量做压测,从10开始往上调,找到“连接数增加但吞吐不再增长”的那个拐点。
  • connection-timeout: 10000:这个值要保守一点。如果业务SQL平均耗时是100ms,正常情况拿连接肯定在毫秒级完成。一旦等了10秒还没拿到,要么是池子真的满了,要么是数据库hang了,这时候快速失败比继续等待更安全——至少可以把错误信息抛出来,让监控能盯到。
  • max-lifetime: 540000:MySQL的wait_timeout默认是8小时,但连接池里的连接如果存活太久,中间网络断一下、数据库重启一下,连接就成了“坏连接”。max-lifetime让连接定期退役,再新建立连接,降低这种风险。
  • leak-detection-threshold: 10000:这个参数非常重要,是探测连接泄漏的眼睛。一旦业务代码里某个线程借了连接超过10秒没归还,HikariCP会在日志里打印警告,包含栈信息,直接告诉你“哪段代码泄漏了连接”。建议所有新项目都提前配上。

Druid场景下的等价配置:

spring: datasource: druid: max-active: 20 min-idle: 5 max-wait: 10000 # 泄漏检测与回收开关 remove-abandoned: true remove-abandoned-timeout-millis: 10000 log-abandoned: true

Druid的removeAbandoned比HikariCP更激进:它不仅打印日志,还会强制将泄漏超时的连接从业务线程中回收掉。这个特性在生产环境要谨慎开——如果业务代码里存在真正耗时超过removeAbandonedTimeoutMillis的合法事务,开了它会把正在执行的事务连接强制回收,导致事务中断。建议先在测试环境开一段时间,确认没有误杀再上生产。

4.2 连接泄漏检测:HikariCP的leakDetectionThreshold与Druid的removeAbandoned

这里单独把连接泄漏检测拎出来说,因为我踩过的坑里,连接泄漏是最难揪又最容易在代码审查中被漏掉的问题。

HikariCP的leakDetectionThreshold工作方式很有意思:它并不是一个独立的检测线程,而是在每次借出连接时记录时间戳,当连接被归还时检查“借出时长是否超过阈值”。如果超过,就输出一条包含调用栈的警告日志。所以实际效果是——泄漏发生后,下一次归还连接时日志才会打印。

它的意义不是“阻止泄漏”,而是“把泄漏暴露在阳光下”。我建议线上环境统一开启这个参数,因为哪怕只是日志级别,泄漏时的调用栈信息也能帮开发省下几天的排查时间。

Druid的removeAbandoned则更进一步。开启后,Druid会启动一个守护线程周期扫描,发现占用超过removeAbandonedTimeoutMillis的连接,直接强制Connection.abort(),从池子里移除,并记录日志。被强杀的业务线程会收到Connection is closed异常,SQL执行会失败——这是刻意为之,目的就是快速暴露问题。

我个人的建议是:在测试环境把leakDetectionThreshold或removeAbandoned全部开启,并设为较小值(5~10秒)来跑全链路测试;上线初期可以保持开启,等确认代码中没有泄漏后,再把HikariCP的阈值调大或关闭,Druid则把removeAbandoned关掉、保留logAbandoned。

4.3 客户端调优与服务端配合

应用端的调优做完了,服务端的配合也不能少。这里说的服务端不是指MySQL的max_connections,而是一整套配套措施。

第一,为应用单独建数据库账号,限制最大连接数。比如给核心业务服务创建一个专用账号,GRANT ALL ON app_db.* TO 'app_user'@'%',并设置MAX_USER_CONNECTIONS 50。这样即使某台服务器上的连接池配置失控,也不会拖垮同一个MySQL上的其他业务。

CREATE USER 'app_user'@'%' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON app_db.* TO 'app_user'@'%'; ALTER USER 'app_user'@'%' WITH MAX_USER_CONNECTIONS 50;

第二,合理配置MySQL的wait_timeout和interactive_timeout。如果数据库默认8小时的wait_timeout没动,连接池里的空闲连接会长期占用数据库资源。有些团队会把wait_timeout调成60秒甚至30秒,强制空闲连接被MySQL主动断开——但要注意,单纯的断开并不是归还,应用端连接池需要自己处理“失效连接”,所以HikariCP的maxLifetime应该比数据库的wait_timeout小,比如数据库设60秒,池里设max-lifetime: 54000毫秒。

第三,监控先行。连接池的监控指标一定要前置部署。HikariCP可以通过com.zaxxer.hikari.HikariDataSource的getHikariPoolMXBean()拿到activeConnections、idleConnections、waitingThreads,Spring Boot Actuator的/actuator/metrics/hikaricp_connections_active接口可以直接暴露这些指标。Druid有更友好的监控页面/druid/index.html和一套完整的StatFilter统计。

这套监控的意义在于:连接池爆满不是一瞬间发生的,而是有一个逐渐累积的过程。如果只靠事后排查,每次都要半夜爬起来登录服务器抓现场;指标监控部署好之后,你可以在active连接数超过maxActive的80%时提前收到告警,在爆满之前介入。

5. 事后复盘:监控指标与上线验收

5.1 我建议要盯的指标

连接池相关告警,如果只设置一个,那就是active连接数的比例水位。HikariCP的监控指标形如:

hikaricp_connections_active // 当前活跃连接数 hikaricp_connections_idle // 当前空闲连接数 hikaricp_connections_pending // 正在等待连接的数量 hikaricp_connections_max // 池最大连接数

告警规则我会这样设计:

  • 当前活跃连接数 / 最大连接数 >= 80%,持续5分钟,发Warning级别告警;
  • 当前活跃连接数 / 最大连接数 >= 95%,持续1分钟,发Critical级别告警;
  • 等待线程数 > 0,持续3分钟,直接PG灵。

pending这个指标很关键——活跃连接数高可能只是并发大,但如果同时出现排队等待,那一定是池子不够用了。注意区分“池活跃”和“池排队”两个概念。

另外,数据库侧的指标也要联动看:

  • Threads_connected:已建立的连接数,对比max_connections;
  • Threads_running:正在执行的线程数,一般远小于Threads_connected,如果两者接近,说明空闲连接很少、负载很高;
  • Slow_queries:慢查询计数,持续增长说明有慢SQL在拖后腿;
  • Innodb_row_lock_current_waits:当前等待行锁的数量,不是0就要警觉。

这些指标可以统一接到Prometheus,SecAlert/Grafana可视化即可。技术选型不强制,关键是监控链路要通,告警要能定位到具体服务实例。

5.2 递归式复盘:这次爆满的真正源头

连接池爆满处理完,我一般还会追问几个“为什么”,避免下次换个姿势再来一遍。分享一套排查后的复盘清单:

  1. 为什么连接池会满?——是慢SQL、是泄漏、是大事务、还是参数不匹配?
  2. 为什么慢SQL/泄漏会流到生产?——代码Review没发现?测试环境没有全链路压测?
  3. 为什么监控没有提前发现?——告警阈值太高?指标没接入?还是没有人盯告警?
  4. 为什么紧急扩容没有生效?——当时直接加减实例,还是加连接池大小?数据库有没有扛住?

比如有一次我处理完连接池爆满后复盘,发现根因是一个新上线的列表接口,在for循环里逐条查询数据库,单请求产生几十次查询,高峰期直接打爆连接池。代码Review没拦下来,压测又只跑了功能测试没跑峰值流量。后续整改就两件事:把N+1查询改成IN批量查询;压测流程补上峰值流量场景。改完之后,连接池active水位从95%降到20%以下。

复盘的价值不在于写一篇事故报告,而在于把问题一层层把到根上,把流程漏洞补齐,让同类问题失去复现空间。

最后再聊一个细节。很多人在解决连接池问题时,会优先去数据库侧调max_connections、调wait_timeout,这没有错,但我更建议先从应用侧排查。原因很简单:数据库是共享资源,一个服务的连接池爆满,遭殃的可能不止一个服务。提前在应用侧做连接池参数验证、做泄漏检测、做全链路压测,远比在数据库层面做兜底省心得多。连接池问题看着猛,其实只要把“排查链路”走一遍,大多数都能在半小时内定位到根因。真正难的不是解决问题,而是建立一套机制,让问题在发生前就被指标和规范拦住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询