连接池泄漏:那个让我凌晨三点写检讨的bug,比失恋还难受

2026-07-08 11 0

连接池泄漏:那个让我凌晨三点写检讨的bug,比失恋还难受

事情是这样的。那是一个普通的周五晚上,我正准备收拾东西下班,享受一个美好的周末。然后监控报警了——服务不可用,所有接口返回500。

我当时的感受,就两个字:麻了

排查了一圈,发现数据库连接数爆了。MySQL 默认最大连接数是151,而我们的应用服务器显示已经使用了 300+ 个连接。这意味着什么?意味着有连接借了没还,像信用卡逾期不还一样,利滚利,最后把整个系统拖垮了。

罪魁祸首呢,是一个看起来人畜无害的代码片段:

public User getUserById(Long id) {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        // ... 业务逻辑
        return user;
    } catch (SQLException e) {
        log.error("查询失败", e);
        return null;
    }
    // 这里 conn.close() 永远不会被调用!
}

看到了吗?conn.close() 写在 try 块外面。如果业务逻辑抛出了任何异常(不只是 SQLException),连接就永远不会被释放。这就是传说中的连接池泄漏

连接池到底是个什么东西?

在解释连接池之前,先说说没有连接池的时候是什么情况。

每次请求来了,应用要查数据库。它需要先和数据库建立 TCP 连接,然后做身份认证,这个过程大概需要 10-50 毫秒。如果你的 QPS 是 1000,那光是建立连接就要消耗 10-50 秒,还玩个屁。

连接池的出现,就是为了解决这个问题。它的核心思想很简单:连接复用。预先创建好一批连接放在池子里,请求来了从池子里借一个,用完了还回去,不用每次都重新建立连接。

主流的连接池有这几个:HikariCP、Druid、DBCP、C3P0。其中 HikariCP 是目前性能最强的,Spring Boot 2.0 之后默认用的就是它。Druid 是阿里开源的,在国内用户量很大,主要胜在监控功能完善。

连接池的五个死亡陷阱

陷阱一:拿了对却忘了还

这是最常见的泄漏原因。我当年那个 bug 就是这种情况。更隐蔽的是,有时候不是没写 close(),而是 close() 写在错误的位置。

// 错误示例:异常情况下 close() 不会被执行
Connection conn = ds.getConnection();
if (conn != null) {
    doSomething();  // 这里抛异常了
    conn.close();   // 这行永远不会执行
}

// 正确写法:try-with-resources
try (Connection conn = ds.getConnection()) {
    doSomething();
}  // 自动关闭,无论是否异常

现在的 Java 版本都支持 try-with-resources 了,强烈建议大家把所有的资源操作都包在里面。这不是最佳实践,这是及格线

陷阱二:连接超时设置太宽松

很多同学配连接池参数的时候,抄网上的"最佳实践",把连接超时设置成 30 秒甚至 60 秒。这是个巨大的坑。

想象一下这个场景:数据库突然卡住了,所有连接都在等待响应。你的连接池有 20 个连接,现在全部卡住了。新请求进来要借连接,发现池子里一个可用的都没有,只能等待。如果等待时间设得太长,整个服务线程会全部阻塞,然后你就会看到服务 hang 死的壮观场面。

我的建议:连接获取超时(connectionTimeout)设 5 秒足矣。超过 5 秒还拿不到连接,说明数据库已经出问题了,继续等下去也没意义,不如快速失败。

HikariCP_config:
  maximumPoolSize: 20
  minimumIdle: 5
  connectionTimeout: 5000      # 5秒拿不到连接就超时
  idleTimeout: 600000          # 10分钟空闲自动回收
  maxLifetime: 1800000         # 连接最大生命周期30分钟
  connectionTestQuery: SELECT 1

陷阱三:maximumPoolSize 拍脑袋设置

有人问,连接池大小设多少合适?有人说 10,有人说 20,有人说 CPU 核数 * 2。

我的回答是:看情况。这个问题的答案取决于你的数据库服务器配置、网络延迟、业务特性。

有一个经典的公式:

connections = (核心数 * 2) + 有效磁盘数

但这个公式是给 Oracle 用的,MySQL 的话,建议先从 20 开始压测,观察连接等待时间和数据库 CPU 使用率。如果数据库 CPU 还很闲但应用侧频繁等待,那就加连接;如果数据库 CPU 快爆了,说明连接已经够多了。

我之前的教训:把 maximumPoolSize 设成 100,结果数据库连接数经常打满,查询延迟暴涨。后来压测发现,其实 30 个连接就够了,多的都是浪费。

陷阱四:忘了处理无效连接

连接池里的连接,不是一直可用的。数据库可能会因为各种原因断开连接:

  • 数据库重启了
  • 网络闪断
  • 数据库的 wait_timeout 超时了(MySQL 默认是 8 小时)
  • 防火墙把空闲连接断了

如果你的应用没有定期检查连接有效性,就会遇到一种神奇的 bug:平时测试好好的,部署到生产环境跑了一晚上,第二天早上就全挂了。原因是数据库在夜里把空闲连接收了,你的连接池还以为那些连接能用。

所以,连接有效性检查必须开

HikariCP:
  connectionTestQuery: SELECT 1           # 每次借用前测试连接
  # 或者更现代的做法(MySQL Connector/J 8.0.23+):
  connectionTestQuery: null
  keepaliveTime: 30000                     # 每30秒检测空闲连接

陷阱五:多数据源配置混乱

这是个大坑。很多项目有主库和从库,或者有多个业务数据库。如果不小心把配置搞混了,轻则数据错乱,重则数据泄露。

举个例子:订单库的配置被错误地指向了用户库,然后订单查询全是别人的数据。这个场景我在一个创业公司见过,他们的 DBA 差点提刀来找我。

建议:每个数据源起一个明确的名字,代码里统一通过名字引用,不要用默认的。

如何检测连接池泄漏?

光知道坑在哪还不够,还得有工具能发现泄漏。下面是几个实战中用得最多的方法:

1. Druid 的泄漏检测

druid:
  removeAbandoned: true
  removeAbandonedTimeout: 30     # 30秒未归还视为泄漏
  logAbandoned: true             # 打印泄漏日志

开启这个配置后,如果一个连接被借出去 30 秒还没还回去,Druid 会自动把它标记为泄漏并关闭,同时打印堆栈信息。生产环境强烈建议开这个。

2. HikariCP 的监控指标

HikariCP 自带一堆 JMX 指标,重点关注这三个:

  • Pool.HikariPool-1.Active - 当前活跃连接数
  • Pool.HikariPool-1.Idle - 当前空闲连接数
  • Pool.HikariPool-1.Wait - 等待获取连接的线程数

如果 Wait 数持续大于 0,说明连接不够用,或者有连接泄漏。如果 Active 数等于 maximumPoolSize 且持续很高,说明连接池已经是瓶颈了。

最后说几句

连接池这玩意儿,平时不起眼,一出问题就是大事。就像你家门口的鞋架,平时没人注意,但如果哪天塌了,你连门都出不去。

本文的坑,都是我或者我同事真实踩过的,每一个都有血和泪。如果你正在经历类似的困惑,或者正在写新的数据访问代码,把本文的检查清单过一遍:

  1. 所有 Connection 操作是否都在 try-with-resources 里?
  2. 连接超时是否设在了 5 秒左右?
  3. maximumPoolSize 是否经过压测验证?
  4. 连接有效性检查是否开启?
  5. 多数据源配置是否有明确的命名和隔离?

如果这五条都做到了,恭喜你,你已经比 80% 的 Java 程序员更懂连接池了。

至于我那个凌晨三点的 bug,修完之后我写了一篇详细的复盘报告,发给了全组。然后我给自己立了个规矩:所有的资源操作,必须用 try-with-resources,谁敢手写 close() 就 review 不给过

规矩是死的,但代码是活的。希望你们不要重蹈我的覆辙。

相关文章

还在为部署AI工具秃头?来找小龙虾,39块搞定一切!
为什么你的SQL慢得像蜗牛?删库跑路的同事留给我的遗产
当AI开始整活:最近那些让我笑出声的新玩意儿
从入门到踩坑:我用 OpenClaw 搞钱的日子
还在为部署AI工具秃头?小龙虾帮你一键搞定!
RESTful API 设计指北:我踩过的那些坑和总结的套路

发布评论