为什么同样配置的服务器,别人能跑你却炸了?一位被连接池坑了三年的工程师的血泪复盘
三年前,我负责一个接口改造项目。原系统平均响应时间80ms,某次上线后暴涨到800ms。团队疯狂优化SQL、缓存、索引……折腾了两周,毫无改善。
最后我抓了一份火焰图,发现一个诡异的现象:99%的耗时卡在一个我们完全没动过的地方——数据库连接的获取。
这事儿之后,我花了很长时间研究连接池,发现一个让人血压飙升的事实:80%的连接池问题,都是工程师自己写出来的。
先说个冷知识
你以为连接池的核心问题是"数量不够"?错。连接池的第一个坑,是连接泄漏——Connection Leak。
看这段代码:
public Result query(String sql) {
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
// 假设这里忘了关,或者抛异常没走到关的部分
return parseResult(rs);
}
这个方法看起来没问题,但实际上——如果parseResult抛了个异常,或者你在某个分支提前return了,这个连接就永远不会被归还到池里。
一次泄漏问题不大。但你的服务每秒处理100个请求,假设有1%的概率走到这个漏路径——1小时后,你的连接池就空了。
空了的连接池会怎样?后续请求会阻塞在getConnection()上,线程堆积,CPU打满,对外表现为"服务挂了"。
而你的代码里,可能就一行没关。
连接泄漏的三大元凶
经过三年的踩坑,我总结出连接泄漏的三个高发场景:
元凶一:异常吞掉了 finally
这是最常见的。代码写了:
Connection conn = null;
try {
conn = ds.getConnection();
// 业务逻辑
return result;
} finally {
conn.close(); // 理想很丰满
}
但实际项目里,这种写法满地都是:
Connection conn = null;
try {
conn = ds.getConnection();
if (condition) return early; // 提前return,漏了
doSomething();
} catch (Exception e) {
log.error(e);
// 吞掉,没往外抛
} finally {
if (conn != null) conn.close(); // 好的,你以为你关了
}
但问题是:如果conn.close()抛了异常,之前的return根本不会等它。在某些JVM实现里,finally里的异常会覆盖try里的return。这意味着你的连接可能根本没关成功。
元凶二:多线程乱用连接
这个坑比较隐蔽。某些老代码里,有人会这样做:
// 在一个线程里获取连接,传给另一个线程用
final Connection conn = ds.getConnection();
executor.submit(() -> {
// 另一个线程用这个conn
doWork(conn);
});
// 第一个线程很快结束,conn被close,但第二个线程还在用
conn.close();
JDBC的Connection对象不是线程安全的。跨线程共享轻则数据错乱,重则直接crash。而且你永远不知道是谁先close了谁。
元凶三:嵌套获取连接
看这个:
public void outer() {
Connection conn = ds.getConnection();
inner(); // 里面又拿了conn
conn.close();
}
public void inner() {
// 这里又拿了连接
Connection c = ds.getConnection();
// ...
c.close(); // 没问题,但池被用两次
}
正常情况下这没问题。但如果你在inner里抛了异常没捕获,导致内层conn没关,外层的conn就还不了。
更骚的是,如果你把内层的conn赋值给外层的变量:
Connection conn = null;
try {
conn = ds.getConnection();
conn = inner(conn); // inner 换了新的conn引用
// 第一个conn凉了
} finally {
conn.close(); // 关的是inner的conn,第一个泄漏了
}
这种情况,编译器不会报错,测试也很难发现。只有在高并发下才会慢慢暴露。
我的解决方案
说完坑,说怎么填。
第一:自动代理,把close变成归还
大部分连接池支持连接健康检测。在你归还连接时,池会检测连接是否还活着。
// HikariCP 的配置示例
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
// 归还连接后,池会检测这个连接是否可用
config.setConnectionTestQuery("SELECT 1");
config.setIdleTimeout(600000); // 10分钟没用的连接,移出池
config.setMaxLifetime(1800000); // 连接最大生命周期30分钟,到期强制关闭
但注意:连接池的检测不是万能的。它只能检测物理层面的连通性,不能检测逻辑层面的"这个连接被你玩坏了"。
第二:用try-with-resources,强制自动关
Java 7之后的try-with-resources是神器:
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
// 所有资源在这里自动关闭
// 编译器会生成finally块,即使抛异常也会关
return execute(ps);
} // 这里自动close
关键点:try-with-resources的close是在finally里执行的,而且close本身的异常不会吞掉原来的异常。这是Java 7之后就有的语法,但很多老项目还在用老写法。
第三:连接池监控,别等炸了才看
我现在的标配是:
- 连接池活跃数 > 80% 阈值告警
- 等待获取连接的时间 > 100ms 告警
- 每个实例的连接泄漏次数 > 0 告警(是的,泄漏一次就该查)
告警不是为了让你半夜爬起来,而是让你在用户投诉之前发现问题。
一个真实案例
我们有个遗留服务,用的是c3p0连接池。有一次上线后,DB连接数从平时的50飙升到500+,数据库快炸了。
查了半天,发现是某个新接口用了新的数据库查询方法,里面有个隐藏的:
if (someCondition) {
throw new RuntimeException("数据异常");
}
stmt.executeQuery(sql);
在c3p0里,连接被close时才会检测是否健康。而这个接口的逻辑是:先拿连接,抛异常,不走正常流程,直接跳出去——没关连接。
更骚的是,这个接口在某些参数组合下才会触发这个分支,平时测试根本测不到,只有在特定业务场景下才会炸。
解决方式:try-with-resources + 代码Review强制要求。两个月后,类似问题再没出现过。
最后说个反直觉的
连接池不是越大越好。很多人觉得"我内存够,连接池调大点",结果调完发现更慢了。
原因是:连接数和线程数是配合关系。每个连接都需要内存和CPU来维护,而数据库的并发能力是有上限的。连接数太多,反而会造成线程竞争和上下文切换。
我的经验公式:
连接池大小 ≈ (核心数 × 2) + 磁盘数
或者更简单:先按 10-20 个连接起步,跑压测看响应时间和吞吐量,找到拐点再调。
别相信"连接池越大越好"的鬼话。够用就行。
总结
连接池是个容易被忽视的存在——平时没事,出事就是大事。
记住三句话:
- 用try-with-resources,别手动close
- 监控要到位,别等炸了再看
- 连接数不是越大越好,压测找最优
下次有人跟你说"数据库又卡了",你可以先问一句:你的连接池还好吗?
希望这篇文章能帮你少踩几个坑。