1. 这不是“要不要关”的问题而是“不关会怎样”的现实拷问在写第一行代码调用db.connect()的时候没人教过你连接池里那根线到底连着什么等你第一次在日志里看到Too many connections报错才意识到——原来数据库连接不是用完就自动消失的纸巾而是一根根真实占用系统资源的“活线”。我带过的三个应届生团队有两人在上线前一周都栽在同一类问题上本地跑得好好的压测一开MySQL 直接拒绝新连接后台服务大面积超时。查下来90% 的根源不是 SQL 写得差而是conn.close()被注释掉了或者被包在if (debug)里又或者——更隐蔽地——藏在某个finally块里但因为上层异常没抛出、finally根本没执行。这不是疏忽是认知断层我们习惯把“连接”当成轻量级对象可它背后绑着 TCP socket、服务端线程、内存缓冲区、事务上下文甚至可能锁住某张表的元数据。MySQL 默认最大连接数是 151PostgreSQL 是 100Oracle 按 license 计费而一个 Spring Boot 应用在并发 200 QPS 下若每个请求打开 3 个连接且不释放10 秒内就能把连接池耗尽。这不是理论推演是我去年在物流调度系统里实测的数据未关闭连接的微服务在 47 秒后开始出现 503第 63 秒全链路熔断。所以“为什么要关闭数据库连接”这个问题本质上是在问“你愿意为每一条没关掉的连接支付多少 CPU、内存、锁等待和业务中断的成本”答案从来不是“可以不关”而是“不关的代价你是否真的承担得起”。2. 连接背后的四重资源枷锁从网络层到事务层的真实开销2.1 网络层TCP 连接不是“无感存在”而是持续占坑很多人以为Connection对象只是个 Java 类实例关不关只影响 JVM 堆内存。错。当你调用DriverManager.getConnection(jdbc:mysql://...)底层触发的是完整的 TCP 三次握手。客户端发起 SYN服务端回 SYN-ACK客户端再发 ACK——这三步完成后操作系统内核才在客户端和服务端各自创建一个 socket 文件描述符fd。这个 fd 会一直保留在进程的文件描述符表中直到显式调用close()或进程退出。Linux 系统对单个进程的 fd 数量有限制ulimit -n默认常为 1024一旦耗尽连日志文件都打不开。更致命的是 TIME_WAIT 状态当连接由客户端主动关闭即你的conn.close()该 socket 会进入 TIME_WAIT持续 2MSL通常 60 秒期间同一五元组源IP源端口目的IP目的端口协议无法复用。我曾遇到一个高频查询服务每秒新建 50 个连接却不关闭结果不到 3 分钟客户端机器的netstat -an | grep TIME_WAIT | wc -l就突破 3000后续所有新连接都卡在 SYN_SENT因为本地端口被占满。这不是数据库的问题是操作系统在说“你已越界”。2.2 服务端层每个连接都是数据库的一个“专属服务员”MySQL 的每个连接对应服务端的一个线程thread_per_connection 模式或协程thread_pool 模式。这个线程要分配栈空间默认 256KB、维护连接状态用户权限、字符集、时区、SQL_MODE、缓存查询结果query_cache 已弃用但 prepared statement 缓存仍在、持有表锁或行锁。举个具体例子你在事务中执行SELECT * FROM orders WHERE status pending FOR UPDATE这条语句会为扫描到的每一行加上排他锁。如果连接不关闭事务不提交也不回滚这些锁就一直挂着。另一个业务线程想更新同一订单就会卡在Waiting for table metadata lock或Waiting for row lock上。我亲眼见过一个电商结算服务因上游调用方未关闭连接导致事务悬挂 17 分钟最终锁住整张inventory表下游 12 个微服务全部阻塞。PostgreSQL 更严格每个连接独占一个 backend processpg_stat_activity视图里能看到state idle in transaction的僵尸连接它们不干活但吃内存、占连接数、阻塞 VACUUM。2.3 连接池层你以为在复用其实是在“借壳还魂”现代应用几乎都用 HikariCP、Druid 或 Tomcat JDBC Pool它们管理的不是物理连接而是“连接代理”。当你dataSource.getConnection()池子返回一个包装过的ProxyConnection调用conn.close()时并非真正关闭 TCP而是将物理连接归还给池子供下一次getConnection()复用。但这里有个关键陷阱归还的前提是连接处于健康、可复用状态。如果连接在使用中发生网络闪断、MySQL 主从切换、wait_timeout超时默认 8 小时这个连接就被标记为“stale”池子会在下次归还时检测并销毁它。但如果连接从未被归还即close()没被调用池子就永远不知道它已失效。HikariCP 的leakDetectionThreshold参数默认 0即关闭就是为此而生——它会在连接被借用超过设定毫秒后打印堆栈警告。我在金融风控系统里把阈值设为 3000030 秒上线首日就捕获了 47 个泄漏点最久的一个连接被持有了 2 小时 17 分钟原因竟是某个异步回调里忘了加try-finally。连接池不是保险柜它是精密流水线漏掉一个工件整条线都会卡顿。2.4 事务与会话层连接关闭会话终结不关悬停风险数据库连接和数据库会话session是一体两面。每个连接启动时服务端为其创建一个会话上下文存储临时变量、用户变量var、临时表、事务隔离级别、当前数据库USE db_name。如果你开了事务BEGIN执行了几条UPDATE然后忘记COMMIT或ROLLBACK再让连接“自然死亡”比如 JVM 重启MySQL 会强制回滚但这个过程不可控它可能在回滚大事务时触发innodb_lock_wait_timeout也可能因磁盘 I/O 延迟导致回滚时间过长期间锁依然有效。更危险的是“隐式提交”场景执行CREATE TABLE、ALTER TABLE、DROP TABLE等 DDL 语句时MySQL 会自动提交当前事务。但如果你在 DDL 前还有未提交的 DML这部分变更就意外提交了——而你根本没意识到。PostgreSQL 则更彻底任何 DDL 都在自己的事务中执行不会影响当前事务但连接不关那个事务就一直开着。我处理过一个报表导出功能它先BEGIN查 10 张表生成汇总数据最后COMMIT。开发认为“反正最后会提交”于是删掉了中间所有close()。结果某次导出因内存溢出 OOMJVM 崩溃连接断开事务被回滚但前端用户已收到“导出成功”提示数据却没落库——这是典型的“连接未关 事务未控”双重灾难。3. 实操验证三步亲手掐住连接泄漏的咽喉3.1 第一步用SHOW PROCESSLIST实时揪出“僵尸连接”别等报警才行动。登录 MySQL执行SHOW FULL PROCESSLIST;重点关注Time列单位秒和State列。正常连接的Time应该是 0 或个位数State是Sleep空闲或Query正在执行。如果看到大量Time 60且State Sleep的记录基本可以判定是应用层未关闭连接。我习惯加个条件过滤SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO FROM information_schema.PROCESSLIST WHERE TIME 30 AND COMMAND Sleep;这个查询能立刻暴露问题连接的来源 IP 和数据库名。有一次我们发现所有TIME 300的连接都来自10.20.30.40:54321立刻定位到部署在该 IP 的一台测试机其上的旧版脚本还在轮询调用老接口每次调用都新建连接却不关闭。PROCESSLIST是数据库的“心电图”30 秒看一次比任何监控都直接。3.2 第二步在应用层埋点用P6Spy拦截所有连接生命周期SHOW PROCESSLIST只能看到结果看不到源头。要追踪哪段代码打开了连接却没关必须在 JDBC 层做拦截。P6Spy是最轻量的方案它是一个代理驱动配置好后所有getConnection()、close()、prepareStatement()调用都会被记录到日志。步骤如下下载p6spy.jar最新版 3.9.1放入项目lib目录修改jdbc.url将jdbc:mysql://替换为jdbc:p6spy:mysql://创建spy.properties放入src/main/resourcesmodulelistpsql.jndi.P6SpyDriver # 记录所有操作 logMessageFormatcom.p6spy.engine.spy.appender.MultiLineFormat # 只记录连接相关 executionThreshold1000 # 日志输出到控制台生产环境建议改 file appendercom.p6spy.engine.spy.appender.StdoutLogger # 过滤掉健康检查等干扰项 excludecategoriesinfo,debug,result,batch启动应用后你会看到类似日志10:23:45.123|connectionId123|urljdbc:mysql://db:3306/mydb|executionTime0|categoryconnection|operationgetConnection|resultsuccess 10:23:45.456|connectionId123|urlnull|executionTime0|categoryconnection|operationclose|resultsuccess 10:23:46.789|connectionId124|urljdbc:mysql://db:3306/mydb|executionTime0|categoryconnection|operationgetConnection|resultsuccess # 注意这里没有对应的 close 日志connectionId124后再无close这就是泄漏点。结合stacktracetrue配置还能打出调用堆栈精准定位到OrderService.java:87行。我用这套方法在一个 20 万行的遗留系统里3 小时内定位到 12 处泄漏最深的一处在三层嵌套的Callable回调里。3.3 第三步用jstackjmap组合拳锁定 JVM 内的连接引用链当 P6Spy 显示连接被打开但没关而代码里明明写了close()问题往往出在异常流中。这时需要看 JVM 里Connection对象是否还被强引用着。步骤获取 Java 进程 PIDjps -l生成线程快照jstack -l pid thread.log生成堆快照jmap -dump:formatb,fileheap.hprof pid用 Eclipse MATMemory Analyzer Tool打开heap.hprof执行 OQL 查询SELECT c FROM java.sql.Connection c WHERE c.retainedHeapSize 10000这会列出所有大内存的 Connection 实例。右键选中一个点击 “Path To GC Roots → with all references”MAT 会展示从 GC Root如静态变量、线程栈局部变量到该 Connection 的完整引用链。我曾在一个 Spring Batch 任务里发现JdbcTemplate被注入到一个Component类中该类又被Async方法引用而Async方法里开了连接异常时finally块因Async的线程上下文丢失而未执行。MAT 的引用链清晰显示ThreadPoolTaskExecutor - AsyncExecutionInterceptor - MyBatchProcessor - JdbcTemplate - Connection证据确凿。这比读 1000 行日志高效得多。4. 不同场景下的关闭策略从裸 JDBC 到云原生的七种解法4.1 场景一裸 JDBC新手必学老手必查这是最原始也最容易出错的方式。核心原则Connection、Statement、ResultSet必须在finally块中关闭且按创建逆序关闭。Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DriverManager.getConnection(url, user, pwd); ps conn.prepareStatement(SELECT * FROM users WHERE id ?); ps.setLong(1, userId); rs ps.executeQuery(); while (rs.next()) { System.out.println(rs.getString(name)); } } catch (SQLException e) { // 处理异常 } finally { // 逆序关闭ResultSet - PreparedStatement - Connection if (rs ! null) try { rs.close(); } catch (SQLException e) { /* 忽略 */ } if (ps ! null) try { ps.close(); } catch (SQLException e) { /* 忽略 */ } if (conn ! null) try { conn.close(); } catch (SQLException e) { /* 忽略 */ } }为什么必须逆序因为ResultSet依赖StatementStatement依赖Connection。如果先关Connectionrs.close()会抛SQLException。为什么catch里忽略异常因为此时主逻辑已失败关闭失败是次要问题不应掩盖主异常。Java 7 的 try-with-resources 是语法糖但本质相同try (Connection conn DriverManager.getConnection(...); PreparedStatement ps conn.prepareStatement(...); ResultSet rs ps.executeQuery()) { // 业务逻辑 } // 自动按逆序 close()但注意try-with-resources要求资源实现AutoCloseable而某些老旧 JDBC 驱动如 Oracle 10g的Connection可能不支持必须手动。4.2 场景二Spring JDBC Template企业级主力JdbcTemplate的设计哲学是“模板方法 回调”它帮你管理连接生命周期。只要不手动调用getDataSource().getConnection()你就无需关心close()。// ✅ 正确JdbcTemplate 自动管理连接 public User findUser(Long id) { return jdbcTemplate.queryForObject( SELECT * FROM users WHERE id ?, new Object[]{id}, new BeanPropertyRowMapper(User.class) ); } // ❌ 错误手动获取连接却未关闭 public User findUserWrong(Long id) { Connection conn null; try { conn dataSource.getConnection(); // 泄漏起点 // ... 手动执行 } finally { if (conn ! null) conn.close(); // 必须写但违背 Spring 哲学 } }JdbcTemplate的execute()、query()、update()等所有方法内部都封装了getConnection()→doInConnection()→close()的完整流程。它的DataSourceUtils.doReleaseConnection()方法会判断连接是否来自连接池若是则归还而非关闭。所以用JdbcTemplate的唯一铁律是永远不要脱离它的 API 去碰DataSource。我见过最离谱的案例一个团队为“性能优化”在JdbcTemplate外层自己写了个连接缓存结果缓存的连接全是 stale 的每小时报 200 次Communications link failure。4.3 场景三MyBatis半自动需警惕 XML 陷阱MyBatis 的SqlSession是连接的门面。SqlSession必须关闭且不能跨线程共享。// ✅ 正确try-with-resources try (SqlSession session sqlSessionFactory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); return mapper.findById(userId); } // ✅ 正确手动关闭Spring 整合时由 SqlSessionTemplate 管理 SqlSession session sqlSessionFactory.openSession(); try { UserMapper mapper session.getMapper(UserMapper.class); return mapper.findById(userId); } finally { session.close(); // 关键 }陷阱在 XML 映射文件里。select标签的fetchSize属性若设得过大如fetchSize10000MyBatis 会一次性拉取所有数据到内存ResultSet不会及时释放。更隐蔽的是foreach循环中的IN查询若传入集合过大生成的 SQL 超过max_allowed_packetMySQL 会断连而 MyBatis 的重试机制可能让连接卡在半关闭状态。解决方案用RowBounds分页或改用游标分页Cursor-based Pagination。4.4 场景四JPA/HibernateORM 的双刃剑Hibernate 的Session和 JPA 的EntityManager是连接的抽象。它们的生命周期由事务管理器Transactional控制而非手动close()。// ✅ 正确交给 Spring 事务管理 Transactional public User updateUser(Long id, String name) { User user entityManager.find(User.class, id); user.setName(name); return user; // commit 时自动 flush close connection } // ❌ 错误手动 open/close破坏事务边界 public User updateUserWrong(Long id, String name) { Session session sessionFactory.openSession(); Transaction tx null; try { tx session.beginTransaction(); User user session.get(User.class, id); user.setName(name); tx.commit(); // 这里 connection 归还但 session 未 close } finally { if (session ! null session.isOpen()) session.close(); // 必须 } }Transactional的魔力在于Spring 的TransactionSynchronizationManager会将EntityManager绑定到当前线程commit()时自动调用entityManager.close()实际是归还连接。但如果你在Transactional方法里手动emf.createEntityManager()这个新EntityManager就不在 Spring 管理范围内必须手动close()。我处理过一个审计日志功能它需要在事务外记录操作开发用了PersistenceContext(type PersistenceContextType.TRANSACTION)注入结果日志写入和业务更新共用一个连接事务回滚时日志也被回滚——正确做法是用PersistenceContext(type PersistenceContextType.EXTENDED)或PersistenceUnit。4.5 场景五异步编程CompletableFuture / Async这是泄漏高发区。Async方法运行在独立线程ThreadLocal绑定的Connection不会自动传递。常见错误// ❌ 错误Async 中直接用 JdbcTemplate Async public void asyncUpdate(Long orderId) { jdbcTemplate.update(UPDATE orders SET status ? WHERE id ?, shipped, orderId); // 连接可能泄漏因为 Async 线程无事务上下文 } // ✅ 正确用新事务或显式管理 Async Transactional(propagation Propagation.REQUIRES_NEW) public void asyncUpdateSafe(Long orderId) { jdbcTemplate.update(UPDATE orders SET status ? WHERE id ?, shipped, orderId); }Propagation.REQUIRES_NEW会挂起当前事务开启新事务新事务有自己的连接Async结束时自动归还。另一种方案是用TransactionTemplateAutowired private TransactionTemplate transactionTemplate; Async public void asyncUpdateWithTemplate(Long orderId) { transactionTemplate.execute(status - { jdbcTemplate.update(UPDATE orders SET status ? WHERE id ?, shipped, orderId); return null; }); }4.6 场景六云原生 Serverless函数计算的瞬时性在 AWS Lambda、阿里云 FC 中函数实例可能被复用Cold Start 后的 Warm Start。数据库连接若在函数外初始化会被多个请求复用但若不显式关闭下次调用时连接可能已失效wait_timeout。最佳实践public class OrderFunction { private static DataSource dataSource; // 静态变量跨调用复用 static { // 初始化连接池设置 validationQuerySELECT 1 HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://...); config.setValidationTimeout(3000); config.setConnectionTestQuery(SELECT 1); dataSource new HikariDataSource(config); } public String handleRequest(String input) { try (Connection conn dataSource.getConnection()) { // 每次请求都新取 // 业务逻辑 } // 自动归还 return OK; } }关键点validationQuery确保取出的连接是活的try-with-resources保证每次请求结束都归还。切忌在 handler 外getConnection()并保存为成员变量——那等于把连接绑定到函数实例生命周期而实例可能存活数分钟远超数据库wait_timeout。4.7 场景七连接池参数调优HikariCP 的七个生死参数连接池不是配了就行参数错了比不配还糟。HikariCP 的核心参数必须理解其物理意义参数名推荐值物理意义错误后果maximumPoolSizeCPU 核数 × (4~8)最大并发连接数设太大MySQL 拒绝连接设太小请求排队minimumIdlemaximumPoolSize的 50%空闲连接保底数设为 0高峰时新建连接慢增加延迟connectionTimeout3000030秒从池取连接的最长等待时间设太短频繁超时设太长线程卡死idleTimeout60000010分钟空闲连接最大存活时间设太长连接池积压 stale 连接maxLifetime180000030分钟连接最大寿命防 MySQL wait_timeout必须 MySQLwait_timeout默认 28800 秒leakDetectionThreshold6000060秒连接借用超时告警阈值生产必须开启设为业务最长 SQL 时间 × 2validationTimeout30003秒连接有效性检测超时设太短健康检查失败设太长故障发现慢我在线上将maxLifetime设为 280000028分钟因为 MySQLwait_timeout288008小时但网络设备如 SLB可能有 5 分钟空闲断连所以留足缓冲。leakDetectionThreshold设为 60000上线后每天自动捕获 3~5 个泄漏点平均修复时间从 4 小时降到 15 分钟。5. 真实故障复盘从连接泄漏到全站雪崩的七小时5.1 故障时间线一场由单行代码引发的连锁反应T00:0010:00 AM运维同学发布新版本包含一个“用户行为分析”微服务其核心逻辑是监听 Kafka 用户点击事件实时写入 ClickHouse用于 OLAP和 MySQL用于画像。代码中ClickHouse 使用clickhouse-jdbcMySQL 使用HikariCP。T01:2311:23 AM监控告警MySQLThreads_connected从 80 涨到 120Threads_running从 5 涨到 25。DBA 登录执行SHOW PROCESSLIST发现 42 个连接Time 300State Sleep来源 IP 全是行为分析服务。T02:4712:47 PM订单服务开始超时/order/create接口 P95 延迟从 200ms 升至 2s。排查发现其连接池HikariPool-1的ActiveConnections持续为 20满IdleConnections为 0。jstack显示大量线程卡在HikariPool.getConnection()的await()上。T04:152:15 PM全站告警支付服务 503库存服务GET /stock?sku123返回 404实际是连接池耗尽HTTP 客户端超时返回 404。此时Threads_connected达 151MySQL 上限新连接全部被拒。T05:523:52 PM紧急回滚行为分析服务。Threads_connected开始下降但因大量连接处于TIME_WAIT下降缓慢。T06:304:30 PM连接数回落至 30订单服务延迟恢复正常。T07:005:00 PM复盘代码定位到问题行行为分析服务中处理 Kafka 消息的KafkaListener方法里有一段 MySQL 写入逻辑// ❌ 问题代码 public void onMessage(ConsumerRecordString, String record) { Connection conn null; try { conn dataSource.getConnection(); // 每条消息都新建连接 PreparedStatement ps conn.prepareStatement(INSERT INTO clicks ...); ps.setString(1, record.value()); ps.executeUpdate(); // 忘了 conn.close() } catch (Exception e) { log.error(kafka consume error, e); } // conn 未关闭且无 finally 块 }Kafka 每秒推送 50 条消息每条消息新建一个连接maximumPoolSize20连接池瞬间打满。而 MySQL 的wait_timeout28800这些连接在池子里“睡”了 8 小时直到被新请求唤醒才发现已断连触发重建进一步加剧压力。5.2 根本原因与防御体系重建根本原因有三层代码层违反 JDBC 基本规范getConnection()后无close()框架层未启用leakDetectionThreshold未能提前预警架构层行为分析服务与核心订单服务共享同一 MySQL 实例缺乏物理隔离。防御体系重建措施立即在所有微服务application.yml中强制添加spring: datasource: hikari: leak-detection-threshold: 60000 max-lifetime: 1800000 connection-timeout: 30000中期为分析类服务单独申请 MySQL 只读从库写操作走 Kafka Flink 实时同步杜绝直连主库长期推行“连接使用守则”纳入 Code Review Checklist新增 PR 必须通过 SonarQube 规则java:S2095资源必须关闭。5.3 一份可落地的《连接使用守则》这是我给团队制定的、已执行 18 个月的规则每一条都来自血泪教训所有getConnection()调用必须包裹在try-with-resources中。禁止conn dataSource.getConnection()后手动管理。Transactional方法内禁止PersistenceContext之外的任何EntityManager或Session手动创建。ORM 就是为解放双手而生。异步方法Async、CompletableFuture中数据库操作必须声明Propagation.REQUIRES_NEW。绝不允许跨线程共享连接。连接池maximumPoolSize必须基于压测结果设定公式QPS × 平均SQL耗时秒× 2。例如 100 QPS × 0.1s × 2 20设为 25留 20% 余量。leakDetectionThreshold在测试环境设为 1000010秒预发环境 3000030秒生产环境 6000060秒。告警必须接入钉钉群5 分钟未响应自动升级。每周五下午DBA 执行SELECT * FROM information_schema.PROCESSLIST WHERE TIME 300邮件抄送所有后端负责人。让“连接健康”成为团队共同 KPI。新项目立项时架构评审必须包含“数据库连接治理方案”章节明确连接池选型、参数、监控指标。没有这一章不予排期。最后分享一个小技巧在DataSourceBean 初始化后加一段启动检查Bean public DataSource dataSource() { HikariDataSource ds new HikariDataSource(); // ... 配置 // 启动时验证连接池可用性 try (Connection conn ds.getConnection()) { log.info(DataSource validated successfully.); } catch (Exception e) { throw new RuntimeException(DataSource init failed, e); } return ds; }这行代码不能防止泄漏但它能在服务启动时就告诉你“连接池配错了”而不是等到用户投诉时才去救火。连接管理的本质不是写多少行close()而是建立一套让错误无法发生的防御体系。你关掉的不是一根连接而是未来可能爆发的雪崩引信。