
凌晨两点监控群里刷出一屏红色的GetConnectionTimeoutException: wait millis 60000, active 20, maxActive 20, creating 0。做后端的人对这个报错应该不陌生它是连接池被榨干之后发出的最后一声哀嚎。有意思的是这个异常本身几乎从来不指向真正的问题所在它告诉你的是池子空了而不是谁把连接借走之后没还。这篇东西把这次排查的完整过程摊开讲从怎么读这行日志到怎么在一堆业务代码里把那个不肯归还连接的家伙揪出来再到修完之后怎么防止它卷土重来。不管你是刚接手一个历史包袱很重的老系统还是自己的服务最近开始出现零星超时都能从里面找到能直接抄的做法。全文围绕连接泄漏这一根线展开涉及active、maxActive、wait millis这些关键字段的判读方法以及定位、修复、防护三个阶段的完整实操。1. 先把这行日志拆开字段本身就是排查地图很多人看到异常的第一反应是往上翻堆栈找自己业务的类名然后发现全是框架代码直接懵了。其实这条异常消息里的每个字段都是 Druid 主动喂给你的现场快照读懂它们排查方向就已经定了一半。1.1 wait millis 60000不是等了 60 秒而是没人还Druid 在getConnectionDirect里等连接大致逻辑是如果maxWait 0走pollLast(nanos)这个带超时的等待如果配的是 0 或者负数走takeLast()无限期阻塞。等到超时还没捞到可用连接才把当前池子的状态拼进消息里抛出来。所以wait millis 60000至少透露两件事。第一件事你的maxWait配的是 60000 毫秒。这个值往往是默认值或者被某位同事抱着配大一点保险的心态加上去的。第二件事调用方真的在这里干等了整整 60 秒。这才是真正的危险信号——一个请求霸占着 Web 容器的工作线程 60 秒不放如果并发量有几百线程池会被瞬间抽干紧接着就是接口大面积超时甚至健康检查失败被摘流量。我见过不少团队的处理方式是把maxWait从 60s 调到 120s然后观察好像不报错了。这属于典型的饮鸩止渴。池子里本来就没有空闲连接等 120 秒和等 60 秒拿到的结果是一样的区别只是线程被占用的时间翻倍雪崩来得更彻底。真正合理的做法是往反方向调。1.2 active 20 与 maxActive 20两个数字相等基本可以确诊active表示当前已经借出去、还没有归还的连接数。maxActive是连接池允许的上限。当这两个数字完全相等说明池子里一个空闲连接都不剩所有连接都躺在某个调用方手里。这里有个很容易被忽略的细节active统计的是借出未还的数量而不是正在执行 SQL的数量这两者的差别大得离谱。一个连接被getConnection()拿走之后如果代码在做别的事情——发 HTTP 请求、等锁、写文件、甚至就是单纯忘了关——它依然算在active里但它对数据库一点压力都没有。所以当你看到active20而你确信数据库那边show processlist只有三四个活跃查询时答案基本就浮出水面了有十几个连接被借走之后既没干活也没归还。反过来说如果active只有 3、maxActive是 20却依然抛出了这个异常那方向就完全不同了。那种情况大概率不是泄漏而是物理连接创建慢、数据库侧握手卡住、或者池子内部在等某个锁。所以拿到这条异常第一件事永远是把active和maxActive摆在一起看。1.3 creating 0 与字段速查表creating表示当前正在创建中的物理连接数量。Druid 里这个计数主要覆盖正在createPhysicalConnection这个过程。它是 0说明池子此刻并没有在拼命建立新连接也就排除了连接创建慢导致等待这条线。把这四个字段串起来active maxActive且creating 0指向的结论非常明确池子满员且没有任何扩容动作有 N 个连接被长期占用无法回收。字段含义本次现场解读wait millis 60000调用方等待连接的时长上限来自maxWaitmaxWait 配置过大失败前线程被占用 60 秒active 20已借出未归还的连接数池子被占满没有空闲连接maxActive 20池子允许的最大连接数上限本身不大20 个连接被耗尽说明泄漏速度很快creating 0正在创建中的物理连接数排除创建慢场景问题在归还环节而非获取环节这张表我后来直接贴进了排查记录里新人照着填空就能快速对齐方向比口头解释半天管用。2. 连接泄漏的底层逻辑借出去的水杯没还回来搞清楚字段含义只是第一步。要真正定位问题得先理解连接池这个东西到底在做什么以及泄漏这个词在技术上究竟指什么。2.1 池化模型close() 被偷偷改写了数据库物理连接是很贵的资源。建立一次 TCP 连接、完成身份认证、初始化会话状态在网络条件一般的机房里可能要几十毫秒。你不可能每次查询都走一遍这个流程所以连接池的做法是提前建好一批连接养着谁要用就借出去用完还回来。Druid 的实现方式是DruidDataSource.getConnection()返回的并不是驱动的Connection而是一个叫DruidPooledConnection的包装对象。这个包装类重写了close()方法——你调close()的时候它并没有真的关闭物理连接而是把连接归还到池子的数组里同时把activeCount减一。关键在于这个归还是你必须主动触发的。Java 没有析构函数那套东西JVM 的 GC 只管内存不管你在池子里挂着的连接引用。代码路径上没有走到close()这个连接就会永远停留在active状态直到进程重启。这就是泄漏的全部本质借出去的连接对象被丢弃了但它绑定的物理连接依然在池子里只是再也回不到空闲队列。2.2 泄漏的三种典型形态我把踩过的坑归了归类基本上逃不出这三种。第一种是异常路径断链。这是最经典的形态也是最容易发生的。代码正常执行时close()会走到一旦中间某个环节抛了异常后面的close()就被跳过了。// 反例异常一抛下面两行永远执行不到 Connection conn dataSource.getConnection(); Statement st conn.createStatement(); ResultSet rs st.executeQuery(sql); st.close(); conn.close();这段代码在测试环境跑一万次都不会出问题因为测试数据不会触发异常。上线之后某个参数导致的类型转换失败、某条脏数据触发的约束冲突只要出现一次就永久泄漏一个连接。第二种是连接跑出了作用域。比如把一个Connection或者ResultSet塞进ThreadLocal塞进异步任务的闭包里塞进缓存里然后主流程结束了容器里还挂着引用。这种泄漏最阴险因为代码看起来写得很规范try-finally一个不少只是归还的时机被无限推迟了。第三种是框架层的事务状态没清干净。用 Spring 的DataSourceUtils.getConnection()从当前事务上下文里拿连接这个拿到的是绑在线程上的ConnectionHolder你直接调close()是没用的——它只是把引用计数减一真正归还的时机是事务提交或回滚时由DataSourceTransactionManager统一处理。如果事务没正常结束连接就一直挂着。2.3 为什么本地和测试环境永远复现不出来这是很多人排查到一半就卡住的原因。本地起个服务压测跑一万个请求active稳稳地停在两三个一点问题没有。原因有三个缺一不可。第一泄漏点往往藏在特定数据才会走的异常分支里测试数据的覆盖面根本不够。第二泄漏是累积的你得连续触发 20 次泄漏才能把maxActive20的池子占满本地那点请求量连门都摸不到。第三泄漏的速度通常和某个低频业务有关比如每天凌晨跑一次的批处理任务、或者某个运营后台的导出功能这些在本地压根不会被执行。所以判断是不是泄漏有个非常实用的经验法则看是不是重启即恢复、运行几小时后逐渐恶化。如果是九成是泄漏如果重启也没用、或者故障是突发的、伴随数据库侧其他报错那就先往基础设施方向查。3. 定位手段怎么把泄漏点从几十万行代码里揪出来方向确定了接下来是最难的一步——找到那个具体的位置。业务代码几十万行靠肉眼读是不可能读完的。我一般按下面的顺序来从成本最低的手段开始。3.1 打开 logAbandoned让 Druid 自己举报这是整个排查过程中最有价值的一个开关。Druid 提供一个叫removeAbandoned的机制配合logAbandoned使用它会记录连接被借出时的调用栈当这个连接超过removeAbandonedTimeout还没归还时就把栈打印出来。spring: datasource: druid: remove-abandoned: true remove-abandoned-timeout: 180 log-abandoned: true max-active: 20 max-wait: 3000配好之后重启只要业务里再出现一次借了不还日志里就会出现类似这样的内容直接告诉你哪个类、哪一行调用了getConnection()。注意removeAbandoned不只是打印日志它还会强制回收超时连接。如果某个业务确实需要长事务比如批量导入跑五分钟这个开关会把它正在用的连接抢走导致业务抛连接已关闭的异常甚至造成数据写入不完整。第一次开启时建议先把removeAbandonedTimeout设得远大于业务最长事务耗时观察一两天日志收集完线索后立刻关掉。我这次的现场logAbandoned打出来的栈指向一个导出功能它在一个for循环里逐条查询连接是在循环外拿的但循环体内有段try-catch把异常吞了然后continue继续下一轮。这个连接在整个循环里被持有如果循环了一万次这个连接就被占用了几分钟。并发几个导出请求池子立刻就满了。3.2 用 jstack 看谁在等但要明白等的人不是元凶很多人第一反应是jstack抓栈看哪几个线程卡住了。这招有用但要理解它的局限性。jstack -l 12345 /tmp/stack.txt grep -n DruidDataSource /tmp/stack.txt | head -50你会看到一批线程停在DruidDataSource.takeLast或者pollLast上栈帧里通常带着GetConnectionTimeoutException的影子。但这些人全是受害者不是元凶。它们只是排队等着拿连接本身并没有持有任何连接。真正的元凶线程jstack往往抓不到。原因很简单泄漏发生之后那个线程可能早就跑完自己的逻辑回到线程池里了或者干脆已经结束了。线程没了但它借走的那根连接对象还挂在DruidDataSource的activeConnections数组里成了一条孤魂。所以jstack只能用来确认确实在等连接不能用来定位泄漏点。3.3 反射捞池子里的活跃连接看谁挂得最久比jstack更有效的一招是直接把池子内部状态捞出来看。Druid 的DruidDataSource提供了getActiveConnections()能拿到当前所有借出未还的连接对象。写个小接口挂到应用的运维端点上需要的时候调一下。// 思路示意具体 API 以你使用的 Druid 版本为准 DruidDataSource ds (DruidDataSource) dataSource; ListDruidPooledConnection actives ds.getActiveConnections(); long now System.currentTimeMillis(); for (DruidPooledConnection conn : actives) { long heldMs now - conn.getConnectedTime(); if (heldMs 30_000) { System.out.println(borrowed at conn.getConnectedTime() , held for heldMs ms); } }跑一次就能看清楚有 15 个连接已经挂了十几分钟没动只有 5 个是刚刚借出去的。这个比例一出来泄漏就实锤了。更进一步如果开启了logAbandoned这些连接对象上还会带上借出时的堆栈直接就能定位到具体代码行。这里有个小技巧值得分享把这个端点的输出做成定时任务每分钟打一次快照到日志里记录active、poolingCount空闲连接数、还有持有时长最长的三个连接的借出时间。等故障复现的时候历史快照就是完整的案发现场记录比事后复盘靠脑补靠谱得多。4. 常见泄漏场景逐个排查定位到大致范围之后剩下的就是对照经验清单逐项核对。我把这些年遇到过的、以及帮别人排查时见过的场景整理出来命中率相当高。4.1 手工 JDBC 与资源未关闭这是最古老也最常见的一类。除了前面说的try-finally缺失还有几个变种容易被漏掉。一种是Statement和ResultSet没关。严格来说Connection.close()会连带关闭它派生出的所有Statement所以单纯漏关Statement通常不会造成池子级别的泄漏。但有两种情况例外一是用了服务端游标比如 MySQL 的流式查询、Oracle 的REF CURSOR未读完的ResultSet会占住会话资源二是某些驱动在Statement未关闭时会缓存预编译语句长期累积会拖慢连接。另一种是SqlSession泄漏。用 MyBatis 的时候如果手工sqlSessionFactory.openSession()之后没有在finally里close()底层的Connection就不会归还。这个场景在批处理代码里特别常见因为批处理经常绕过 Spring 的事务管理直接操作。// 正例用 try-with-resources异常路径也能保证归还 try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { // 业务逻辑 }4.2 事务边界处理不当这一类比手工 JDBC 隐蔽得多因为代码看起来完全正常Transactional注解也打上了。最常见的是异常被吞。事务方法里抛异常Spring 默认会回滚并清理连接但如果异常在方法内部被catch掉了并且没有重新抛出Spring 认为方法正常返回会尝试提交事务。提交时如果又失败了或者代码里在catch块里做了别的事情继续往下走事务状态就可能和DataSourceUtils里绑定的ConnectionHolder对不上连接释放流程被打断。还有一种更直接的手工操作事务管理器。// 危险写法一旦中间抛异常commit 和 rollback 都不会执行 TransactionStatus status transactionManager.getTransaction(def); doSomething(); transactionManager.commit(status);正确的做法是把这段包在try-catch里catch中调用transactionManager.rollback(status)。或者干脆用TransactionTemplate它内部帮你处理了这些边界。我个人的偏好是业务代码里永远不要出现PlatformTransactionManager的直接调用需要编程式事务就用TransactionTemplate能少踩九成的坑。4.3 异步任务与线程池的串味服务用Async或者自己维护线程池之后这一类问题开始变多。第一种情况是连接被带到了异步线程。主线程在一个事务里拿到了连接然后把任务丢给线程池执行任务里用到了这个连接但事务上下文是绑定在主线程的ThreadLocal上的异步线程里根本看不到close()行为也就不受事务管理器控制归还时机彻底错乱。第二种情况是线程池复用导致的事务状态残留。TransactionSynchronizationManager用的是ThreadLocal正常来说事务结束时框架会清理。但如果某个异步任务在事务未结束的情况下被强制中断比如Future.cancel()或者线程池shutdownNow()清理逻辑可能被跳过下一个复用这个线程的任务就会看到一个脏的事务上下文。这类问题的排查方法是在异步任务的入口和出口都打印当前线程名和TransactionSynchronizationManager.isActualTransactionActive()对比日志就能看出上下文有没有串。4.4 场景与排查动作对照表现象特征大概率原因优先排查动作重启后几小时开始出现逐渐加重异常路径未归还开启 logAbandoned 收集堆栈只有某个低频功能上线后才出现特定业务代码泄漏对比功能上线时间与故障起始时间并发压测时才会出现连接持有时长过长检查事务粒度和循环内查询伴随Connection is closed异常removeAbandoned 强制回收了在用连接调大 removeAbandonedTimeout 或临时关闭数据库侧processlist连接数正常但池子满借出未使用用 getActiveConnections 看持有时长5. 修复、参数与防护体系找到泄漏点改掉代码只是第一步。这一节讲怎么把参数配对、怎么建立防护让它不再复发。5.1 maxWait 到底该配多少前面说过 60 秒太长了。那多少合适我的经验值是1000 到 3000 毫秒。这个数字怎么来的假设你的接口 SLA 是 500 毫秒那么一个请求在连接池上排队超过 1 秒就已经注定要超时了继续等下去只会占用线程。配 3000 是一个平衡点既能容忍数据库偶发的短暂抖动比如一次 checkpoint 导致的几百毫秒卡顿又不会让请求堆积到失控。提示maxWait配小之后会看到更多GetConnectionTimeoutException报错。这是好事它把线程被堵死转化成了请求快速失败前者会导致整个服务不可用后者只会影响那一小部分请求。指标上你会看到错误率上升但成功率依然保持在高位这是健康的形态。5.2 maxActive 该配多少这个问题不能只看单机要算总量。先算单机需求。假设峰值 QPS 是 800平均一次数据库交互耗时 3 毫秒那么理论上需要的连接数是800 × 0.003 2.4个。但这个算法有个大坑它算的是纯 SQL 执行时间而实际占用连接的时间包括事务开始到事务结束的全程。假设一个事务平均持续 20 毫秒TPS 是 200那么需要200 × 0.02 4个连接。真实场景还要再留余量。慢查询、批处理任务、管理后台的统计报表都会临时占走一批连接所以生产环境常见的配置是单实例 20 到 50。然后是总量校验这一步经常被忽略。假设你有 8 个应用实例每实例maxActive20那么峰值总连接数是 160。而 MySQL 的max_connections默认是 151还包含其他客户端运维工具、从库同步、监控采集的连接。这意味着你的池子还没满数据库那边就已经先拒绝了。实例数单实例 maxActive总连接需求与默认 max_connections151 的关系22040安全42080安全留一半余量820160已超出默认上限需要调整815120需要预留至少 30 给其他客户端1610160需要调整数据库侧配置一个实用的公式是单实例 maxActive ≤ (数据库 max_connections × 0.7 - 其他客户端占用) / 实例数。按 151 算8 个实例的情况下(151 × 0.7 - 20) / 8 ≈ 10。这个数字看起来比 20 小很多但它才是真正安全的配置。5.3 监控指标与告警阈值修完代码之后必须建立监控否则下次泄漏你还得从报警群里重新走一遍流程。要采集的指标有三类。连接池饱和度active / maxActive。这个比值持续超过 0.8 超过一分钟就该告警了。注意是持续因为瞬时打满是正常的。等待线程数waitThreadCount。只要有线程在等待说明池子已经不够用了哪怕它最终没超时也值得看一眼。泄漏信号removeAbandoned触发的次数。这个计数只要不为零就说明有连接被强制回收了背后必然有代码问题。建议把它单独做一条告警而不是混在错误日志里。这三个指标 Druid 都通过DruidStatManagerFacade暴露出来了接 Prometheus 的话用druid-spring-boot-starter自带的DruidStatViewServlet加上自定义采集器就能搞定。5.4 代码规范层面的防线工具能兜底但最有效的还是不让泄漏代码写进去。第一业务代码里禁止直接出现dataSource.getConnection()。这条规则可以用静态代码扫描Sonar 或者自定义的 Checkstyle 规则来强制一旦出现就卡住合并请求。第二凡是出现openSession、getConnection、createStatement的地方代码评审时重点看两件事资源有没有try-with-resources或者finally兜底异常路径上资源会不会被跳过。第三异步、线程池、缓存这三处地方出现的任何数据库对象引用都要单独拎出来讨论。这三处是泄漏的高发区多花两分钟比事后排查两天划算。6. 常见问题速查与踩坑实录6.1 排查动作速查表报错或现象先做什么再做什么active 等于 maxActive开 logAbandoned 收堆栈按堆栈定位具体代码行active 远小于 maxActive 却超时查数据库侧连接创建是否变慢检查网络与认证耗时重启就好了判断为泄漏从低频业务功能入手排查报错集中在某个接口检查该接口的连接持有链路看是否有循环内查询或长事务日志里出现连接被强制关闭检查 removeAbandonedTimeout 是否过小评估长事务的真实耗时只有高峰期出现算连接池总量是否够复核 maxActive 与实例数配比6.2 我踩过的几个坑第一个坑是盲目开启强制回收。最开始我只看到removeAbandoned能解决池子被占满这个问题就直接在生产开上了。结果一个跑了很久的批量任务被拦腰截断部分数据只写了一半后面花了不少时间做数据修复。这件事之后我定了个规矩任何会主动干预运行中连接的开关都必须先做全量业务耗时统计确认没有超过阈值的场景并且必须在预发环境跑满一轮完整的业务周期。第二个坑是把日志收集和压测混在一起做。logAbandoned打印的堆栈很长如果泄漏点在一个高频路径上日志量会爆炸式增长磁盘和采集链路都会被拖垮。正确做法是在低峰期开启配合限流或者采样收集到足够线索后立即关闭把收集到的堆栈离线分析。第三个坑是用 Arthas 的watch命令追踪close方法调用。我当时的想法是看看哪些连接没调 close于是对DruidPooledConnection.close加了监听。结果这个方法调用频率极高每次调用都要做一次增强和结果收集服务响应时间立刻上了一个台阶。这类高频方法的追踪用条件表达式把范围收窄比如只追踪持有超过 10 秒的连接或者干脆换成logAbandoned这种由框架自己处理的方式。6.3 怎么区分真泄漏和假泄漏这个坑必须单独说因为我见过不止一次把数据库故障误判为连接泄漏的情况。真泄漏的特征是渐进的、有规律的应用重启后一切正常跑几个小时或者一两天之后开始零星报错报错频率逐渐增加重启又能恢复。池子的活跃连接数曲线是一条缓慢爬升然后卡在高位的斜线。假泄漏的特征是突发的、伴随其他信号的报错突然大量出现同时数据库侧有慢查询告警、连接数飙升、或者主从延迟变大。这个时候池子里的连接确实也都借出未还但原因不是代码忘了关而是数据库响应变慢每个请求的持有时长被拉长了。原本 3 毫秒的查询变成 3 秒池子里的 20 个连接瞬间就不够用。区分方法很简单去看持有时长最长的那些连接它们对应的 SQL 是什么。如果 SQL 各不相同且都在正常执行那就是数据库慢如果发现某些连接挂在同一个借出点上半小时没动过那才是真泄漏。我这次的现场属于后者logAbandoned打出来的栈全部指向同一个方法一个变量名拼错导致的异常在循环里被反复吞掉每次循环泄漏一个连接跑了两百次就把池子占死了。改起来其实只有一行——把catch块里的continue改成throw再用try-with-resources包住连接。但从前到后定位到这一行花了大半天。最后分享一个小习惯我在每个项目的运维端点里都留了一个/ops/conn-pool的接口输出当前活跃连接数、空闲连接数、等待线程数以及持有时长最长的三个连接的借出时间和堆栈。平时没人看出问题的时候这个接口省下来的时间大概等于一整个通宵。