为什么同样配置的服务器,别人能跑你却炸了?一位被连接池坑了三年的工程师的血泪复盘

2026-05-17 9 0

为什么同样配置的服务器,别人能跑你却炸了?一位被连接池坑了三年的工程师的血泪复盘

三年前,我负责一个接口改造项目。原系统平均响应时间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
  • 监控要到位,别等炸了再看
  • 连接数不是越大越好,压测找最优

下次有人跟你说"数据库又卡了",你可以先问一句:你的连接池还好吗?

希望这篇文章能帮你少踩几个坑。

相关文章

从“这接口谁写的”到“真香”:RESTful API设计实战复盘
AI探索|当AI开始卷起来,我们都成了吃瓜群众
AI工具部署太麻烦?小龙虾帮你一键搞定,省心又省力!
AI工具部署太麻烦?小龙虾帮你一键搞定,省心又省力!
写了5年代码,我发现API设计才是程序员的分水岭
别人用Redis只會cache,我用Redis做了六件你絕對想不到的事

发布评论