一次线上MySQL连接池泄漏故障复盘:从连接数耗尽到根因定位

作者 lucy · 2026-04-11

⚠️ 声明:本文相关内容仅供参考,实际效果因场景不同可能差异很大,请结合自身情况判断,谨慎参考。

故障背景

某日下午,线上监控系统突然告警:大量用户反馈页面加载缓慢,部分接口直接超时。登录运维后台一看,数据库连接数已经打满,所有新请求全部在等待连接,最终导致服务雪崩。

这是一套基于 Java 的后端服务,使用 Druid 连接池,数据库为 MySQL 8.0。单台应用服务器 QPS 不高,但故障发生时连接池里的连接全部处于 Active 状态,没有任何空闲连接可用。

初步排查

先用 show processlist 查看当前所有连接状态:

mysql> show processlist;
+-----+--------------+--------------------+--------+---------+------+----------+-----------------------+
| Id  | User         | Host               | db     | Command | Time | State    | Info                  |
+-----+--------------+--------------------+--------+---------+------+----------+-----------------------+
| 301 | app_user     | 10.0.x.x:12345     | myapp  | Sleep   |  892 |          | SELECT * FROM orders  |
| 302 | app_user     | 10.0.x.x:12345     | myapp  | Sleep   |  891 |          | SELECT * FROM orders  |
| 303 | app_user     | 10.0.x.x:12345     | myapp  | Sleep   |  890 |          | SELECT * FROM orders  |
| ... (大量 Sleep 状态的连接,Time 持续增长)|
+-----+--------------+--------------------+--------+---------+------+----------+-----------------------+

大量连接处于 Sleep 状态,且 Time 字段持续增长,说明这些连接已经在数据库端空等了很长时间没有被释放。连接池配置的最大连接数是 50,此时已经全部耗尽。

根因定位

查看应用日志,发现大量类似错误:

Caused by: java.sql.SQLNonTransientConnectionException: Could not retrieve transaction isolation
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:123)
    ...
Caused by: com.mysql.cj.jdbc.exceptions.CommunicationsException: The last packet successfully received
    from the server was 45,321 milliseconds ago.

结合日志和代码,很快定位到问题所在。项目中有一个订单查询接口,代码大致如下:

@GetMapping("/orders")
public List<Order> getOrders(Long userId) {
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        conn = dataSource.getConnection();
        ps = conn.prepareStatement("SELECT * FROM orders WHERE user_id = ?");
        ps.setLong(1, userId);
        rs = ps.executeQuery();
        List<Order> list = new ArrayList<>();
        while (rs.next()) {
            Order o = new Order();
            o.setId(rs.getLong("id"));
            o.setAmount(rs.getBigDecimal("amount"));
            list.add(o);
        }
        return list;
    } catch (Exception e) {
        log.error("查询订单失败", e);
        return Collections.emptyList();  // ❌ 资源泄漏在这里!
    } finally {
        // 正常返回时释放资源
        closeQuietly(rs, ps, conn);
    }
}

问题出在异常处理分支:当 try 块中途抛出异常,且异常被 catch 捕获后直接 return,此时 finally 块虽然会执行,但 closeQuietly 的实现如果有任何疏忽,就会导致资源泄漏。

再深入看 closeQuietly 方法:

private void closeQuietly(ResultSet rs, PreparedStatement ps, Connection conn) {
    try {
        if (rs != null) rs.close();
    } catch (Exception ignored) {}
    try {
        if (ps != null) ps.close();
    } catch (Exception ignored) {}
    try {
        if (conn != null) conn.close();
    } catch (Exception ignored) {}
}

表面看起来没问题,但真正的问题是:dataSource.getConnection() 抛出 SQLException 时,conn 变量本身就是 null,此时 finally 块里的 closeQuietly 根本不会被调用,因为 finally 只在 try 块进入后才会执行。

复盘结论

根因分析:

  1. 某些边缘场景(如数据库连接创建过程中被中断),导致 connnull
  2. 外层 catch 捕获异常后直接返回,finally 未进入
  3. 数据库连接从未被释放,持续占用连接池资源
  4. 随着请求量增加,连接池资源耗尽,服务雪崩

解决方案

方案一:使用 try-with-resources(推荐)

@GetMapping("/orders")
public List<Order> getOrders(Long userId) {
    // 使用 try-with-resources,自动释放资源
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE user_id = ?")) {
        ps.setLong(1, userId);
        try (ResultSet rs = ps.executeQuery()) {
            List<Order> list = new ArrayList<>();
            while (rs.next()) {
                Order o = new Order();
                o.setId(rs.getLong("id"));
                o.setAmount(rs.getBigDecimal("amount"));
                list.add(o);
            }
            return list;
        }
    } catch (SQLException e) {
        log.error("查询订单失败", e);
        return Collections.emptyList();
    }
}

方案二:确保资源安全释放

@GetMapping("/orders")
public List<Order> getOrders(Long userId) {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        // ... 业务逻辑
        return list;
    } catch (Exception e) {
        log.error("查询订单失败", e);
        return Collections.emptyList();
    } finally {
        // 无论如何都要释放,即使用户已经返回
        if (conn != null) {
            try {
                conn.close();
            } catch (SQLException ignored) {}
        }
    }
}

方案三:使用持久框架

推荐使用 MyBatis / JPA 等持久层框架,由框架统一管理数据库连接的获取和释放,从源头避免手动管理连接的问题。

监控加固

上线修复后,额外增加了以下监控告警:

  • 连接池活跃连接数 > 80% 告警
  • 单次请求占用连接时长 > 5s 告警
  • 定时任务扫描并强制关闭长时间空闲的 Sleep 连接
# 定时清理 MySQL 长时间 Sleep 连接
mysql -uadmin -p -e "
SELECT id, user, host, db, command, time
FROM information_schema.processlist
WHERE command='Sleep' AND time > 300;
" | awk 'NR>1 {print }' | xargs -I{} mysql -uadmin -p -e "KILL {}"

经验总结

  1. 永远使用 try-with-resources 管理数据库连接、Statement、ResultSet 等资源,不要依赖手动 close
  2. 连接池要配置合理的监控,提前告警比事后补救成本低得多
  3. 异常处理不要静默吞掉,至少记录日志并有明确的降级逻辑
  4. 定期巡检线上连接状态,发现异常及时处理

这类资源泄漏问题在开发环境很难复现,往往在高并发、长时间运行后才暴露。建议在代码审查时重点关注手动资源管理的路径,确保每一条获取的连接都有对应的释放路径。

发表评论

苏ICP备18039580号-2