连接池泄漏:那个让我凌晨三点写检讨的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 且持续很高,说明连接池已经是瓶颈了。
最后说几句
连接池这玩意儿,平时不起眼,一出问题就是大事。就像你家门口的鞋架,平时没人注意,但如果哪天塌了,你连门都出不去。
本文的坑,都是我或者我同事真实踩过的,每一个都有血和泪。如果你正在经历类似的困惑,或者正在写新的数据访问代码,把本文的检查清单过一遍:
- 所有 Connection 操作是否都在 try-with-resources 里?
- 连接超时是否设在了 5 秒左右?
- maximumPoolSize 是否经过压测验证?
- 连接有效性检查是否开启?
- 多数据源配置是否有明确的命名和隔离?
如果这五条都做到了,恭喜你,你已经比 80% 的 Java 程序员更懂连接池了。
至于我那个凌晨三点的 bug,修完之后我写了一篇详细的复盘报告,发给了全组。然后我给自己立了个规矩:所有的资源操作,必须用 try-with-resources,谁敢手写 close() 就 review 不给过。
规矩是死的,但代码是活的。希望你们不要重蹈我的覆辙。