数据库很闲却拿不到连接:连接池耗尽避坑复盘

有个服务平时跑得好好的,可一到流量高峰就突然集体卡死:大量请求超时,日志清一色报 Connection is not available, request timed out after 30000ms——等了 30 秒都没从连接池拿到一个数据库连接。诡异的是数据库本身一点不忙,CPU 内存都很闲、慢查询日志干干净净,明明数据库很空闲我的服务却拿不到连接,问题显然不在数据库而在那个连接池上。盯着监控看真相浮现:连接池活跃连接数在高峰期一路飙升顶到上限再也下不来,所有连接都显示使用中可数据库那边却没对应的活跃查询,说明这些连接被借走后用完却没归还。顺线查代码真凶水落石出:某些代码路径尤其是抛了异常的路径,拿到的连接没被正确关闭——在连接池里 connection.close 不是真关闭而是归还给池子,正常流程记得 close 可一旦中间抛异常 close 就被跳过,连接被借走永远没还回来,高并发下异常多了泄漏的连接越积越多池子就被耗干。这篇文章从这次数据库很闲服务却拿不到连接的事故出发,讲透连接池:close 为何是归还、用 try-with-resources 保证归还、用 ORM 别破坏框架的自动管理也别让事务夹耗时操作、开启 leakDetectionThreshold 揪泄漏、连接池不是越大越好、举一反三所有借还式池化资源同理,以及把活跃数与 pending 接入监控。

有个服务,平时跑得好好的,可一到流量高峰,就会突然集体"卡死":大量请求超时,日志里清一色地报着同一句错——Connection is not available, request timed out after 30000ms,意思是"等了 30 秒都没从连接池里拿到一个数据库连接"。诡异的是,数据库本身一点不忙,CPU、内存都很闲,慢查询日志里也干干净净。明明数据库很空闲,我的服务却拿不到连接去用它——问题显然不在数据库,而在我和数据库之间那个"连接池"上。

我盯着连接池的监控指标看,真相渐渐浮现:连接池的"活跃连接数"在高峰期一路飙升,很快就顶到了配置的上限(比如 20),然后再也下不来了——所有连接都显示"正在使用中",可数据库那边却没有对应的活跃查询。这说明,这些连接被某些代码"借走"后,用完了却没有归还给连接池。连接池就那么大,借出去的连接一个个有借无还,池子很快就空了,后来的请求自然只能干等,直到超时。

顺着这条线查代码,真凶水落石出:在某些代码路径上——尤其是抛了异常的路径——拿到的数据库连接(或 StatementResultSet)没有被正确地关闭。在连接池的世界里,connection.close() 并不是真的"关闭"连接,而是把它归还给池子。我有几处代码,正常流程里记得 close,可一旦中间抛了异常,close 就被跳过了,那个连接就成了"肉包子打狗"——被借走,永远没还回来。一次两次看不出,高并发下异常多了,泄漏的连接越积越多,池子就被慢慢耗干了。这就是经典的数据库连接泄漏导致连接池耗尽。这篇文章,就从这次"数据库很闲、服务却拿不到连接"的事故出发,把连接池的原理、连接泄漏的坑、以及正确用法,一次讲透。

先摆几个关于连接池的想当然

动手复盘前,先把我自己曾经深信、后来被这次事故教育的几个念头摆出来。

想当然的念头 残酷的真相
"数据库不忙, 服务就不会卡在数据库上" 瓶颈可能是连接池没连接, 而非数据库本身
"connection.close() 就是关闭连接" 连接池里它是"归还连接", 不还就泄漏
"正常流程关了连接就行" 异常路径会跳过 close, 必须放 finally 或用 try-with-resources
"连接池配大一点就不会耗尽" 有泄漏的话, 配多大都会被慢慢漏光, 治标不治本
"连接池就是放一堆连接, 没啥讲究" 大小、超时、泄漏检测, 配置处处是学问

这些念头的共同病根,是对"连接池"这个中间层缺乏理解——把数据库连接当成了一种用之不竭的资源,随手就借,却没意识到连接是一种昂贵且有限的资源,借了就必须还,且"还"这个动作必须在任何情况下都被保证执行。要看清这次事故,得先搞懂连接池到底是干什么的。

第一件事:连接池是什么,close 为什么是"归还"

先说为什么需要连接池。建立一个数据库连接,是个昂贵的操作:要走 TCP 握手、要做身份认证、要初始化会话……耗时可能要几十毫秒。如果每次查询都现建一个连接、用完就真关掉,那在高并发下,光是反复建连接的开销就能把系统拖垮(这和我们之前聊网络时,短连接反复握手的浪费是同一个道理)。

连接池的思路,就是预先建好一批连接、放在"池子"里反复复用:你要用时,从池里"借"一个(已经建好的,拿来即用);用完了,把它"还"回池里,让下一个请求接着用,而不是真的关掉。所以在连接池框架(HikariCP、Druid 等)里,你拿到的 Connection 其实是一个"代理",你调用它的 close() 方法,框架做的不是"销毁连接",而是"把这个连接标记为空闲、还回池子"。理解这一点至关重要——它意味着,你不 close,这个连接就一直被标记为"使用中",永远回不到池子,也就永远不能被别人用。下面这张图,把这次耗尽的过程画出来:

看懂这张图,事故的根就清楚了:数据库一点不忙,因为那些"泄漏"的连接其实并没有在执行查询,它们只是被借出去后、因为没 close 而一直挂在"使用中"的状态,白白占着池子的名额。问题不在数据库,而在"连接借了没还"——连接池被一堆有借无还的僵尸连接占满,真正想干活的请求反而拿不到连接。接下来,我们就看怎么保证"借了一定还"。

第二件事:用 try-with-resources,让"归还"无法被跳过

根治连接泄漏的核心,就一句话:保证连接(以及 Statement、ResultSet)无论正常还是异常,都一定会被 close 归还。手写 finally 能做到,但层层嵌套、又啰嗦又容易漏(漏关 Statement、ResultSet 同样会泄漏)。Java 7 起的 try-with-resources 是最优雅的解法——把资源声明在 try 后面的括号里,JVM 会保证它们在 try 块结束时(无论怎么结束)自动被 close,顺序还和声明相反、严丝合缝。

// 反例:正常 close, 但中间抛异常就跳过了, 连接泄漏
public List<User> queryBad() {
    Connection conn = dataSource.getConnection();
    PreparedStatement ps = conn.prepareStatement("SELECT * FROM users");
    ResultSet rs = ps.executeQuery();   // 万一这里抛异常...
    List<User> list = parse(rs);
    rs.close(); ps.close(); conn.close();  // ...下面这些就全跳过了, 泄漏!
    return list;
}

// 正解:try-with-resources, 三个资源都会被自动、可靠地归还
public List<User> queryGood() {
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement("SELECT * FROM users");
         ResultSet rs = ps.executeQuery()) {
        return parse(rs);
        // 不管 try 块里正常返回还是抛异常, conn/ps/rs 都会被自动 close 归还
    } catch (SQLException e) {
        throw new DataAccessException(e);
    }
}

try-with-resources 之所以可靠,是因为它把"归还资源"这件事,从"靠程序员记得在每条路径上手动写"变成了"由语言机制强制保证"。任何实现了 AutoCloseable 的资源(连接、Statement、ResultSet、文件流……)都适用。处理数据库连接这类'必须归还'的资源,首选 try-with-resources——它把一个极易出错的纪律,变成了编译器替你兜底的保证。我那次事故,只要所有取连接的地方都用了它,异常路径下连接也会被乖乖归还,池子就不会被漏空。

第三件事:用 ORM/框架时,把资源管理交给框架,但别破坏它

现在很少有人手写 JDBC 了,大多用 MyBatis、JPA/Hibernate、Spring 的 JdbcTemplate 这类框架。好消息是,这些框架通常会自动帮你管理连接的获取和归还——你不用手动 close,框架在操作完成后会替你归还。坏消息是,你仍可能用错姿势,亲手破坏掉这份自动管理,造成泄漏。

// Spring JdbcTemplate / MyBatis 等:框架自动管理连接, 别自己乱拿
// 正常用法:框架替你借、替你还, 你只管写 SQL
jdbcTemplate.query("SELECT * FROM users", rowMapper);  // 连接自动归还

// 陷阱一:绕过框架, 自己从 DataSource.getConnection() 拿连接又忘了还
Connection conn = dataSource.getConnection();  // 自己拿的, 框架不管!
// ... 用完忘了 close → 泄漏

// 陷阱二:事务边界没管好, 长事务长期占着连接
@Transactional
public void bigMethod() {
    queryDb();              // 占用一个连接
    callRemoteApi();        // 耗时几秒的远程调用, 连接被一直占着!
    queryDb();              // 整个方法期间, 连接都不归还
}
// 一个事务 = 一个连接被独占到事务结束; 事务里夹耗时操作 = 连接被长期霸占

用框架时的关键认知是:把资源管理的主导权交给框架,但别做"绕过框架自己拿连接"或"让事务长时间占着连接"这类破坏性操作。第二个陷阱尤其值得警惕——@Transactional 方法在整个事务期间会独占一个连接,如果你在事务里塞了远程调用、文件 IO 等耗时操作,这个连接就会被长时间霸占,高并发下同样会把连接池占满。这和我们之前聊数据库死锁时"事务要短小、别夹耗时操作"的原则,完全是一回事。所以:事务里只放真正需要原子性的数据库操作,耗时的活儿挪到事务外。

第四件事:开启连接池的"泄漏检测",让泄漏自己现身

修代码是治本,但你怎么知道还有没有漏网的泄漏点?好在主流连接池都内置了泄漏检测功能。以 HikariCP 为例,配置一个 leakDetectionThreshold(泄漏检测阈值,单位毫秒):如果一个连接被借出去后,超过这个时间还没归还,连接池就会打印一条告警日志,并附上当初借走这个连接的代码堆栈——泄漏点的"案发现场",直接送到你眼前。

// HikariCP 配置:开启连接泄漏检测
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(20);
// 连接借出超过 30 秒还没归还, 就打印告警 + 借走它的代码堆栈
config.setLeakDetectionThreshold(30_000);   // 关键!揪出泄漏点
config.setConnectionTimeout(10_000);        // 拿连接最多等 10 秒, 别傻等 30 秒
config.setIdleTimeout(600_000);             // 空闲连接 10 分钟后回收
config.setMaxLifetime(1_800_000);           // 连接最长存活 30 分钟, 防陈旧连接

这个功能是排查连接泄漏的利器:它把"连接慢慢漏光、最后池子耗尽"这种滞后、难定位的故障,变成了"一旦有连接超时未还,立刻报警并指出是哪段代码借的"的即时、可定位问题。我那次事故后,第一件事就是把 leakDetectionThreshold 打开,很快就在告警日志里看到了那几处"借了没还"的代码堆栈,逐个修掉。对于连接池,泄漏检测应该作为一项默认配置常态开启——它是你的'连接哨兵',在池子被漏空之前就把泄漏点揪出来。

第五件事:连接池不是越大越好,要科学地定大小

遇到"连接不够用",很多人的第一反应是"把连接池调大点"。但这往往是误区——如果存在泄漏,池子配多大都会被慢慢漏光,调大只是延缓爆发、治标不治本;而即便没有泄漏,连接池也不是越大越好。连接数过多,反而会因为大量连接争抢数据库资源(CPU、锁、IO),导致数据库整体变慢、上下文切换开销增大,适得其反。

# 连接池大小要科学评估, 不是拍脑袋设大
# HikariCP 官方有个经验公式(供参考, 非绝对):
#   连接数 = (核心数 × 2) + 有效磁盘数
# 一个 4 核、单盘的库, 起点大约是 (4×2)+1 = 9 左右
# 关键认知:
#   - 数据库能高效并发处理的连接数是有限的, 远没你想的那么多
#   - 池子的合理大小, 往往比直觉小得多(几十个常常够了, 别动辄上百)
#   - 真正的吞吐瓶颈, 多在数据库处理能力, 而非连接数量
maximum-pool-size=20      # 按压测和上面的思路定, 别盲目调大
minimum-idle=5            # 保持少量常驻空闲连接, 应对突发
connection-timeout=10000  # 拿不到连接的等待上限

正确的做法是:先确保没有泄漏(代码 + 泄漏检测),再通过压测来确定一个合理的池大小,而不是靠"出问题就调大"的条件反射。连接池大小本质上要匹配"数据库能高效并发处理的请求数",这个数往往比人们直觉的要小。一个被泄漏拖垮的池子,扩容只是把"撑到耗尽"的时间从一小时延长到两小时,问题依旧;只有堵住泄漏,池子才能真正稳定。记住:连接池耗尽,绝大多数时候要先查'是不是漏了',而不是先想'是不是小了'。

第六件事:举一反三——所有"池化资源"都是同一个道理

连接泄漏这个坑,它的本质规律,适用于一切"池化的、需要借还的有限资源"——数据库连接池、HTTP 连接池、线程池、对象池……它们都遵循同一套逻辑:资源有限、借了必须还、还的动作必须在任何情况下都被保证。理解了连接池,你就理解了一大类资源管理问题。

// 同一个道理, 适用于各种"借还式"的池化资源:

// 1. HTTP 连接池(如 Apache HttpClient):响应用完要 close, 否则连接泄漏
try (CloseableHttpResponse resp = httpClient.execute(request)) {
    return parse(resp);   // try-with-resources 保证归还连接
}

// 2. 任何从池里"借"的资源, 都要在 finally / try-with-resources 里"还"
Resource r = pool.borrow();
try {
    use(r);
} finally {
    pool.release(r);      // 借了就一定要还, 无论如何
}
// 核心心法:凡是"从有限的池子里借来的东西", 都要为它的"归还"负全责

这种"借还式资源管理"的心法,是工程素养里很基础、却极其重要的一环。无论资源叫什么名字,只要它是"从一个有限的池子里借出来、用完要还回去"的,你就必须确保归还动作万无一失——用 try-with-resources、用 finally,用一切能保证执行的机制。一个工程师对"资源借还"的敬畏程度,往往直接决定了他写的系统在高负载、高异常率下的稳定性。到这儿,连接池的方方面面就齐了。我把排查思路收成一张决策图:

把这套理解建立起来,连接池耗尽这类问题就能被快速定位和根治。最后,拧成几条可直接照做的铁律:

  1. 连接池里 close() 是"归还"不是"销毁",不 close 连接就泄漏, 永远回不了池。
  2. 用 try-with-resources 管理连接/Statement/ResultSet,让归还由语言机制强制保证。
  3. 用框架就把资源管理交给框架,别绕过它自己拿连接, 别让事务夹耗时操作霸占连接。
  4. 常态开启泄漏检测(leakDetectionThreshold),让泄漏点带着堆栈主动现身。
  5. 连接池不是越大越好,有泄漏调大也没用, 大小靠压测定, 往往比直觉小。
  6. 连接池耗尽先查"是不是漏了",而非先想"是不是小了", 别用扩容掩盖泄漏。
  7. 举一反三,所有"借还式池化资源"都同理, 为每一次"借"的"还"负全责。

一张连接池排查速查表

把连接池相关的现象、成因和对策汇成一张表,出问题时对照着查。

现象 多半的成因 对策
获取连接超时, 数据库却很闲 连接泄漏, 有借无还 try-with-resources + 泄漏检测
活跃连接顶满下不来 连接被借走未归还 揪出泄漏代码并修复
异常多时才耗尽 异常路径跳过了 close close 放 finally / 用自动归还
连接被长期独占 事务里夹了耗时操作 事务短小, 耗时操作移出
连接用着用着失效报错 连接陈旧/被数据库/防火墙断开 设 maxLifetime, 连接保活检测
调大池子也没用 泄漏没堵, 治标不治本 先堵泄漏, 再按压测定大小

治本之外:把连接池指标接入监控

修好泄漏、配好参数后,我做的最有价值的一件事,是把连接池的核心指标接入了监控。因为连接泄漏是个"渐进式"的故障——它不会瞬间爆发,而是连接一个一个地漏,池子一点一点地空,直到某个高峰彻底耗尽。这意味着,只要你盯着曲线,完全可以在它真正耗尽、引发事故之前,就看出"活跃连接数在缓慢爬升、不回落"的苗头,提前介入。

我重点监控这几个指标:活跃连接数(active)、空闲连接数(idle)、等待获取连接的线程数(pending)。正常情况下,活跃连接数应该随流量起伏、用完就回落;如果你看到它只升不降、阶梯式地往上爬,那几乎就是泄漏的铁证。而 pending(等待连接的线程数)一旦持续大于 0,说明池子已经供不应求,离超时报错不远了,该立刻告警。

// 把连接池指标暴露给监控(HikariCP 内置了 metrics 支持)
config.setMetricRegistry(metricRegistry);   // 接入 Micrometer/Prometheus 等
// 监控面板重点盯:
//   hikaricp_active_connections   活跃连接(只升不降 = 泄漏苗头)
//   hikaricp_idle_connections     空闲连接
//   hikaricp_pending_threads      等待连接的线程(>0 持续 = 即将耗尽, 告警!)
//   hikaricp_connection_timeout_total  获取连接超时次数(出现就是已经在出事)
// 对"活跃数持续走高""pending 持续 >0"设告警, 在耗尽前就介入

有了这套监控,连接池耗尽就从一个"突然爆发的黑天鹅",变成了一个"有迹可循、可提前预警"的可观测过程。很多渐进式的资源耗尽问题(连接、内存、文件句柄、goroutine……),都有这个共性:它们在彻底爆发前,一定会在某条监控曲线上留下'只增不减'的脚印——能不能提前发现它们,取决于你有没有盯着那条曲线。

写在最后

这次"数据库很闲、服务却拿不到连接"的事故,给我最深的体会,是它再一次印证了一个我反复领悟的道理:系统的瓶颈,常常不在那个看起来最该出问题的地方。所有人的第一反应都是"数据库扛不住了",可数据库明明闲得发慌;真正的病灶,藏在我和数据库之间那个不起眼的"连接池"里,藏在几行"借了连接却在异常时忘了归还"的代码里。排查问题时,最危险的就是被'想当然的嫌疑人'带偏——盯着数据库使劲查,却忽略了那个默默承载着一切、又最容易被忽视的中间层。真相往往不在聚光灯下,而在那些我们习以为常、从不多看一眼的接缝处。

而这次事故的根因——连接借了没还——又朴素得近乎"小学生错误":借东西要还,这是连孩子都懂的道理。可在代码的世界里,这个朴素的道理,因为异常路径的存在、因为 close() 名不副实的语义、因为高并发把微小的疏漏放大成致命的故障,而变得格外容易被违背。这让我愈发敬畏"资源管理"这件看似基础、实则深刻的事:每一个被获取的资源——连接、内存、锁、文件、线程——背后都跟着一份'必须释放'的承诺,而一个可靠的系统,正是由无数个被认真兑现的'释放承诺'撑起来的。那些会在高负载下崩溃的系统,往往不是输在了什么高深的算法上,而是输在了一个又一个"借了忘还"的小小疏忽里。愿你我都能对每一次"获取资源"保持一份清醒的责任感,在拿起它的那一刻,就想好它该如何、在何处、以何种方式被可靠地放下——因为正是这份对"有借有还"的执着,默默地决定了我们的系统,能不能扛住那一个又一个真正的高峰。

如果你手上也有连着数据库的服务,不妨今天就花二十分钟做三件小事自查。第一,如果还有手写 JDBC 的地方,全部检查是不是用了 try-with-resources,把连接、Statement、ResultSet 都纳入自动归还;有裸 close 的,尤其是 close 不在 finally 里的,优先改掉。第二,在连接池配置里打开 leakDetectionThreshold(比如设 30 秒),让潜在的泄漏点带着堆栈主动暴露出来。第三,把连接池的活跃数、pending 线程数接入监控并设告警。这三步成本都不高,却能帮你在下一个流量高峰把连接池漏空、服务集体卡死之前,就把隐患拆除。资源管理这件事,平时不显山露水,关键时刻却往往是决定系统生死的那道坎——把它做扎实,你的服务才能在真正的压力面前稳得住。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

用户A看到了用户B的信息:ThreadLocal 串号避坑

2026-5-30 12:39:30

技术教程

偶发 connection reset:连接池复用死连接避坑

2026-5-30 12:51:00

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索