1. 这不是配置文件,而是一份数据库连接的“健康体检报告”
很多人第一次看到druid.properties文件时,下意识觉得它只是个“填参数的地方”:url 写上、username 填好、password 配对——点个运行,连上了就完事。我当年在某高校实验室带学生做课程设计时,也这么教过。结果第三周项目上线压测,数据库连接池瞬间打满,接口响应从200ms飙到8s,日志里全是wait timeout和abandoned connection的报错。排查三天才发现,问题根本不在SQL写得有多烂,而在于那份被当成“摆设”的druid.properties——它根本没被当回事。
Druid 不是普通连接池,它是带监控、能诊断、会自愈的数据库连接“健康管家”。.properties文件里的每一行,都不是静态参数,而是对这个管家下达的“体检指令”和“干预策略”。比如maxActive=20看似只是设个上限,实则是在告诉 Druid:“你最多允许20个连接同时‘上岗’,一旦超员,新请求就得排队等位;但若队列太长、等待太久,你得主动拒绝,别让整个系统卡死。”这背后涉及线程阻塞模型、连接生命周期管理、JVM堆外内存分配逻辑——它早已超出传统 JDBC 工具类的范畴,进入资源治理层面。
而JDBCUtils工具类,更常被简化成一个“封装了 getConnection() 的静态方法集合”。但真正用过半年以上生产环境的人会明白:它不该是try-catch-finally的搬运工,而应是连接生命周期的“守门人”。它要判断当前线程是否已持有连接(避免嵌套获取导致泄漏),要识别事务上下文是否已开启(防止非事务操作意外提交),甚至要在close()调用前检查该连接是否已被标记为“疑似异常”(比如执行超时后未显式关闭,但底层 socket 已断开)。这些细节,光靠Class.forName()+DriverManager.getConnection()是撑不住的。
所以,把druid.properties和JDBCUtils放在一起讲,本质是在讲一套完整的数据库连接治理方案:前者定义“规则与边界”,后者执行“调度与兜底”。它们共同构成 Java Web 应用中离数据最近、又最容易被忽视的“第一道防线”。你不需要立刻写出一个分布式连接池,但必须清楚:当getConnection()返回 null 或抛出SQLException时,问题大概率不出在 SQL 本身,而出在这两个看似简单的组件之间。
提示:不要把
druid.properties当作“可有可无的配置项”。它和logback.xml一样,是系统运行时的“行为契约”。改一个参数,可能影响的是整个应用的吞吐量拐点和故障恢复时间。
2. druid.properties 的12个关键参数,每个都对应一次真实故障
Druid 官方文档列了上百个配置项,但实际项目中,真正需要你逐字推敲、反复调优的,不超过15个。我根据过去三年在多个中型业务系统(含电商订单、IoT设备上报、教育平台题库服务)中的调参经验,筛选出最常引发线上问题的12个参数,并附上它们对应的典型故障场景、根因逻辑和实测建议值。这不是参数手册的搬运,而是故障现场的复盘笔记。
2.1 maxActive:连接池的“最大承载力”陷阱
- 典型故障:高峰期大量请求卡在
getConnection(),线程堆栈显示DruidDataSource.getConnectionDirect()长时间阻塞。 - 根因逻辑:
maxActive并非“最大连接数”,而是“最大活跃连接数”。当所有连接都被占用且无空闲时,新请求将进入等待队列。若队列长度(maxWait)设置过大或为 -1(无限等待),线程将永久挂起,最终拖垮整个 Tomcat 线程池。 - 实测建议:
- 初始值 =
(QPS × 平均SQL耗时) × 1.5。例如 QPS=300,平均SQL耗时=150ms →300×0.15=45→ 建议设为60。 - 生产环境严禁设为
-1。我们曾在线上将maxActive设为100,但maxWait误配为-1,导致一次慢SQL引发全站雪崩。 - 注意:该参数在 Druid 1.2.0+ 版本中已更名为
maxActive→maxOpenPreparedStatements是另一参数,勿混淆。
- 初始值 =
2.2 initialSize:冷启动时的“第一波连接”准备度
- 典型故障:应用刚启动的前2分钟,大量请求响应缓慢,监控显示 DB 连接数从0缓慢爬升至目标值。
- 根因逻辑:
initialSize决定应用启动时预热创建的连接数。若为0,首个请求触发连接创建,需完成 TCP 握手、SSL协商、认证、初始化 session 等完整流程,耗时通常在100–300ms。高并发下,这会造成“启动抖动”。 - 实测建议:
- 设为
maxActive的 30%–50%。如maxActive=60,则initialSize=20。 - 必须配合
testOnBorrow=false使用(见2.4节),否则预热连接会在首次借用时被校验,失去预热意义。 - 某次灰度发布中,我们将
initialSize从0调至30,首屏加载时间下降42%,效果立竿见影。
- 设为
2.3 minIdle:连接池的“最低保障线”
- 典型故障:夜间低峰期,DB 连接数归零,凌晨定时任务触发时,大量连接集中创建,DB 端出现瞬时连接风暴,触发防火墙限流。
- 根因逻辑:
minIdle是连接池中始终维持的最小空闲连接数。它不等于“永不销毁”,而是由timeBetweenEvictionRunsMillis和minEvictableIdleTimeMillis共同控制其存活。若minIdle=0,空闲连接会被快速回收,下次请求需重新创建。 - 实测建议:
- 设为
maxActive的 20%–30%,且不低于5。如maxActive=60,则minIdle=15。 - 关键组合:
minIdle=15+timeBetweenEvictionRunsMillis=60000(1分钟检测一次)+minEvictableIdleTimeMillis=1800000(30分钟才回收空闲连接)。这样既保底,又防长连接僵死。 - 注意:
minIdle过高会导致 DB 端连接数长期虚高,增加 DB 负载,需与 DBA 协同确认。
- 设为
2.4 testOnBorrow / testOnReturn / testWhileIdle:连接校验的“三选一”迷思
- 典型故障:数据库主从切换后,应用持续报
Communications link failure,但连接池监控显示连接数正常,重启应用才恢复。 - 根因逻辑:这三个参数控制连接校验时机,但存在严重误区:
testOnBorrow=true:每次借连接前校验 → 性能损耗大(每次加一次SELECT 1);testOnReturn=true:每次还连接后校验 → 校验结果无法反馈给下个借用者;testWhileIdle=true:空闲连接在检测周期内自动校验 →唯一推荐方案,校验与业务请求解耦。
- 实测建议:
- 统一设为
testOnBorrow=false、testOnReturn=false、testWhileIdle=true。 - 必须配套
validationQuery=SELECT 1和timeBetweenEvictionRunsMillis=30000(30秒检测一次)。 - 某金融类项目曾因
testOnBorrow=true导致每秒多出2万次SELECT 1,占用了DB 15%的CPU资源。
- 统一设为
2.5 timeBetweenEvictionRunsMillis:空闲连接的“体检频率”
- 典型故障:DB 主库宕机后,应用仍持续向失效连接发送请求,报错
Connection refused,持续5分钟才恢复正常。 - 根因逻辑:该参数决定空闲连接检测线程的执行间隔。若设为
300000(5分钟),则失效连接最长需5分钟才能被发现并剔除。testWhileIdle=true的效果完全依赖此值。 - 实测建议:
- 生产环境建议
15000–30000(15–30秒)。低于10秒会增加检测线程负担,高于60秒风险过高。 - 必须与
minEvictableIdleTimeMillis配合:后者应 ≥ 前者的2倍。如timeBetweenEvictionRunsMillis=30000,则minEvictableIdleTimeMillis至少为60000。 - 我们在线上将该值从
300000调至20000,主从切换故障恢复时间从5分钟缩短至40秒内。
- 生产环境建议
2.6 removeAbandonedOnBorrow:连接“失踪人口”的强制清理开关
- 典型故障:应用运行2天后,连接池活跃数持续上涨,
activeCount达到maxActive,但poolingCount(空闲数)为0,大量请求超时。 - 根因逻辑:
removeAbandonedOnBorrow=true表示当借连接时发现池中无空闲连接,且存在“疑似被遗弃”的连接(即被借用后超过removeAbandonedTimeout未归还),则强制回收这些连接。这是防止连接泄漏的最后一道保险。 - 实测建议:
- 生产环境必须开启:
removeAbandonedOnBorrow=true+removeAbandonedTimeout=180(3分钟)。 - 开启后,Druid 会在日志中打印
abandon connection警告,这是重要线索!务必接入日志监控,告警该关键词。 - 某次故障中,正是通过
abandon connection日志定位到某段未加finally的 DAO 代码,修复后泄漏消失。
- 生产环境必须开启:
2.7 filters:监控与防护的“插件化入口”
- 典型故障:无法定位慢SQL来源,只知道
DruidStatFilter报了executeSlowSqlMillis=2000,但不知道是哪个 Controller、哪个 Service 调用的。 - 根因逻辑:
filters=stat,wall,log4j是 Druid 的核心扩展点。stat提供监控,wall是防火墙(防SQL注入),log4j将日志输出到 Log4j。但默认stat不开启详细链路追踪。 - 实测建议:
- 必须添加
druid.stat.mergeSql=true(合并相同SQL统计)和druid.stat.logSlowSql=true(记录慢SQL)。 - 关键配置:
druid.web-stat-filter.url-pattern=/*+druid.web-stat-filter.exclusions=*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*,开放/druid监控页。 - 某教育平台通过
/druid页面发现,80%的慢查询来自一个未加索引的LIKE '%keyword%'查询,优化后DB负载下降60%。
- 必须添加
2.8 poolPreparedStatements:预编译语句的“双刃剑”
- 典型故障:应用启动后内存持续增长,GC 频繁,
jmap -histo显示com.alibaba.druid.pool.DruidPooledPreparedStatement实例数超10万。 - 根因逻辑:
poolPreparedStatements=true会缓存PreparedStatement对象,减少重复编译开销。但每个PreparedStatement占用约2KB内存,且与连接绑定。若连接数多、SQL种类多,极易内存溢出。 - 实测建议:
- 中小项目慎用:除非确认 SQL 种类极少(<50条)且并发高,否则设为
false。 - 若启用,必须设
maxOpenPreparedStatements=20(每个连接最多缓存20个PS)。 - 我们在一个报表系统中启用后,单机内存占用增加1.2GB,后改为
false,性能无明显下降,内存回归正常。
- 中小项目慎用:除非确认 SQL 种类极少(<50条)且并发高,否则设为
2.9 connectionProperties:驱动层的“隐性开关”
- 典型故障:MySQL 8.0 升级后,应用报
Unknown system variable 'tx_isolation',连接失败。 - 根因逻辑:
connectionProperties用于向 JDBC Driver 传递底层参数。MySQL 5.7 与 8.0 的事务隔离级别变量名不同(tx_isolationvstransaction_isolation),Driver 需要显式告知。 - 实测建议:
- MySQL 8.0+ 必加:
connectionProperties=useUnicode=true;characterEncoding=utf8;serverTimezone=Asia/Shanghai;cacheServerConfiguration=true;useServerPrepStmts=false;rewriteBatchedStatements=true。 - 关键项
rewriteBatchedStatements=true可将addBatch()批处理重写为INSERT INTO ... VALUES (),(),(),提升批量插入3–5倍。 - PostgreSQL 项目需加
currentSchema=public,避免 schema 查找开销。
- MySQL 8.0+ 必加:
2.10 useGlobalDataSourceStat:全局监控的“总开关”
- 典型故障:多个数据源(主库、从库、配置库)共存,但
/druid页面只显示一个数据源的统计,无法分源查看。 - 根因逻辑:
useGlobalDataSourceStat=true启用全局统计,所有 Druid 数据源共享同一套监控指标。若为false,则每个数据源独立统计,/druid默认只展示第一个。 - 实测建议:
- 多数据源项目必须设为
true,并在/druid页面顶部选择对应数据源。 - 配合
druid.stat.useGlobalDataSourceStat=true(Spring Boot 中)确保生效。 - 某微服务架构中,正是通过开启此开关,发现从库读取延迟高达2s,推动DBA优化主从同步机制。
- 多数据源项目必须设为
2.11 failFast:连接失败时的“快速熔断”
- 典型故障:DB 临时不可用,应用启动时卡在
DruidDataSource.init(),Tomcat 等待超时后直接退出,无法降级。 - 根因逻辑:
failFast=true表示初始化时若首个连接失败,立即抛异常终止启动;false则继续尝试,直到initialSize达成或超时。 - 实测建议:
- 开发/测试环境设为
false:允许应用先启动,便于前端联调。 - 生产环境设为
true:避免应用“带病上岗”,后续请求全部失败。 - 必须与
initConnectionSQLExceptionValidatorClassName配合,自定义校验逻辑(如跳过特定错误码)。
- 开发/测试环境设为
2.12 phyTimeoutMillis:物理连接的“终极保质期”
- 典型故障:网络抖动后,部分连接处于半开状态(TCP连接未断,但DB已不响应),应用持续向其发送请求,超时堆积。
- 根因逻辑:
phyTimeoutMillis是 Druid 对物理连接(socket)设置的硬性超时。超过此时间未收到响应,Druid 强制关闭该 socket。这是socketTimeout的上层兜底。 - 实测建议:
- 设为
30000(30秒),略大于socketTimeout(通常设为25000)。 - 必须与
removeAbandonedOnBorrow形成双重防护:前者防网络层僵死,后者防应用层泄漏。 - 某次 IDC 网络割接中,该参数使僵死连接在32秒内被清除,避免了大规模请求积压。
- 设为
注意:以上12个参数并非孤立存在,它们构成一张联动网络。例如
minIdle和timeBetweenEvictionRunsMillis共同决定空闲连接的“生存策略”;removeAbandonedOnBorrow和phyTimeoutMillis共同构成连接健壮性的“双保险”。调参不是填数字,而是理解这张网的张力分布。
3. JDBCUtils 工具类的5层进化:从“语法糖”到“连接中枢”
很多团队的JDBCUtils还停留在“封装DriverManager.getConnection()”的阶段,这就像用算盘处理大数据——能跑,但早已脱离时代。我梳理了自己参与过的6个中大型项目中JDBCUtils的演进路径,将其划分为5个明确层级。每个层级都对应一次技术认知的跃迁,也对应着一次线上事故的教训。
3.1 第一层:基础封装(L1)——解决“重复写 try-catch”
public class JDBCUtils_L1 { public static Connection getConnection() throws SQLException { return DriverManager.getConnection( "jdbc:mysql://localhost:3306/test", "root", "123456"); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) try { rs.close(); } catch (SQLException e) { } if (stmt != null) try { stmt.close(); } catch (SQLException e) { } if (conn != null) try { conn.close(); } catch (SQLException e) { } } }- 价值:消除样板代码,降低入门门槛。
- 缺陷:
- 硬编码 URL/账号,无法适配多环境;
close()方法未处理null安全(if (rs != null)是补丁,非设计);- 无连接池,高并发下 DB 瞬间被打垮。
- 适用场景:单机 Demo、教学实验、一次性脚本。
- 我的经历:某次学生课程设计答辩,80%的项目卡在此层,一压测就崩,根源就是没引入连接池。
3.2 第二层:连接池集成(L2)——拥抱 Druid 的“标准姿势”
public class JDBCUtils_L2 { private static DruidDataSource dataSource; static { dataSource = new DruidDataSource(); dataSource.setUrl("jdbc:mysql://localhost:3306/test"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(5); dataSource.setMaxActive(20); // ... 其他基础参数 } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { JdbcUtils.close(rs); // Druid 自带工具类 JdbcUtils.close(stmt); JdbcUtils.close(conn); // 注意:Druid 的 close() 是归还,非销毁 } }- 价值:引入连接池,支撑基本并发;利用 Druid 原生
JdbcUtils.close(),语义清晰(归还而非关闭)。 - 缺陷:
- 初始化逻辑硬编码在
static块,无法动态刷新配置; - 未处理事务上下文,
getConnection()获取的连接无法保证在同一事务中; close()方法仍是“一刀切”,未区分连接来源(是新创建?还是从 ThreadLocal 获取?)。
- 初始化逻辑硬编码在
- 关键认知突破:
DruidDataSource.getConnection()返回的是代理对象,close()调用的是DruidPooledConnection.close(),本质是归还到池中。这是理解连接池生命周期的第一课。
3.3 第三层:ThreadLocal 绑定(L3)——实现“同线程同连接”
public class JDBCUtils_L3 { private static final ThreadLocal<Connection> CONNECTION_HOLDER = new ThreadLocal<>(); private static DruidDataSource dataSource; // ... 初始化同 L2 public static Connection getConnection() throws SQLException { Connection conn = CONNECTION_HOLDER.get(); if (conn == null) { conn = dataSource.getConnection(); CONNECTION_HOLDER.set(conn); } return conn; } public static void closeConnection() { Connection conn = CONNECTION_HOLDER.get(); if (conn != null) { try { conn.close(); // 归还到池 } catch (SQLException e) { // 记录日志,但不抛出,避免破坏业务逻辑 log.error("Close connection error", e); } finally { CONNECTION_HOLDER.remove(); // 必须 remove,防内存泄漏 } } } }- 价值:支持事务传播。DAO 层多次调用
getConnection(),获取的是同一个物理连接,保证了BEGIN/COMMIT/ROLLBACK的一致性。 - 缺陷:
CONNECTION_HOLDER.remove()必须在finally中执行,否则线程复用时会携带旧连接;- 未考虑异步场景(如
CompletableFuture),子线程无法继承父线程的ThreadLocal; - 未提供手动释放连接的 API,
closeConnection()是“一刀切”,无法精细控制。
- 血泪教训:某次促销活动,因忘记在
finally中remove(),导致 Tomcat 线程池复用时,新请求拿到旧连接,事务混乱,订单重复扣款。修复后加了单元测试,专门验证ThreadLocal清理。
3.4 第四层:事务模板与资源管理(L4)——告别手写 try-catch-finally
public class JDBCUtils_L4 { // 事务模板方法:自动开启、提交、回滚、释放连接 public static <T> T executeInTransaction(Supplier<T> action) throws SQLException { Connection conn = getConnection(); try { conn.setAutoCommit(false); T result = action.get(); conn.commit(); return result; } catch (Exception e) { conn.rollback(); throw e; } finally { closeConnection(); } } // 资源安全的查询模板 public static <T> T query(String sql, PreparedStatementSetter setter, ResultSetExtractor<T> extractor) throws SQLException { Connection conn = getConnection(); PreparedStatement ps = null; ResultSet rs = null; try { ps = conn.prepareStatement(sql); if (setter != null) setter.setValues(ps); rs = ps.executeQuery(); return extractor.extractData(rs); } finally { JdbcUtils.close(rs); JdbcUtils.close(ps); // 不 close conn,由调用方负责(如事务模板) } } } // 使用示例 String name = JDBCUtils_L4.executeInTransaction(() -> { JDBCUtils_L4.update("UPDATE user SET balance = balance - ? WHERE id = ?", ps -> { ps.setDouble(1, 100.0); ps.setLong(2, 1L); }); return JDBCUtils_L4.query("SELECT name FROM user WHERE id = ?", ps -> ps.setLong(1, 1L), rs -> rs.next() ? rs.getString("name") : null); });- 价值:
executeInTransaction()封装事务边界,业务代码专注逻辑;query()/update()模板自动管理PreparedStatement和ResultSet生命周期,调用方无需关心close();- 连接释放策略清晰:事务模板内
closeConnection(),查询模板内不释放(由外层事务管理)。
- 缺陷:
- 未集成 Spring 的
@Transactional,与主流框架脱节; executeInTransaction()是阻塞式,不支持响应式编程;- 错误处理粒度粗,
catch (Exception e)无法区分业务异常与系统异常。
- 未集成 Spring 的
- 工程实践:我们要求所有 DAO 方法必须使用
query()/update()模板,禁止直接操作Connection。Code Review 时重点检查ThreadLocal是否正确清理、事务模板是否覆盖所有写操作。
3.5 第五层:框架融合与可观测性(L5)——成为应用的“数据神经中枢”
@Component public class JDBCUtils_L5 { @Autowired private DruidDataSource dataSource; @Autowired private Tracer tracer; // OpenTracing 接入 @Value("${druid.monitor.enable:true}") private boolean monitorEnable; // 1. 连接获取增强:埋点、超时控制、来源标记 public Connection getConnection(String caller) throws SQLException { long start = System.currentTimeMillis(); try { Connection conn = dataSource.getConnection(); if (monitorEnable) { String traceId = tracer.activeSpan() != null ? tracer.activeSpan().context().traceId() : "N/A"; // 记录连接获取事件到监控系统 Metrics.recordConnectionAcquire(caller, traceId, System.currentTimeMillis() - start); } return conn; } catch (SQLException e) { Metrics.recordConnectionAcquireFail(caller, e.getSQLState()); throw e; } } // 2. 连接释放增强:泄漏检测、慢释放告警 public void closeConnection(String caller) { Connection conn = CONNECTION_HOLDER.get(); if (conn == null) return; long start = System.currentTimeMillis(); try { conn.close(); } finally { long cost = System.currentTimeMillis() - start; if (cost > 500) { // 释放耗时 > 500ms,视为异常 log.warn("Slow connection close in {}, cost {}ms", caller, cost); } CONNECTION_HOLDER.remove(); } } // 3. 提供连接池实时快照(供 Actuator 端点使用) public DruidStatManager getStatManager() { return dataSource.getStat(); } }- 价值:
- 可观测性:连接获取/释放全程埋点,关联 TraceID,可下钻到具体 Controller;
- 防御性编程:慢释放告警、失败统计,提前发现隐患;
- 运维友好:暴露
DruidStatManager,与 Spring Boot Actuator 集成,/actuator/druid可查实时指标; - 框架融合:作为 Spring Bean,可被 AOP、
@Async、@Scheduled等无缝调用。
- 核心理念:
JDBCUtils不再是工具类,而是应用数据访问层的“中枢神经系统”,它感知每一次连接的脉搏,记录每一笔 SQL 的轨迹,为稳定性保驾护航。 - 落地效果:在某电商平台,L5 版本上线后,连接泄漏问题 100% 定位到代码行,平均修复时间从 4 小时缩短至 15 分钟;慢 SQL 定位从“猜”变为“查”。
提示:L5 并非终点。随着云原生发展,我们已在探索 L6:连接池自动扩缩容(基于 QPS 和连接池使用率)、跨 AZ 连接亲和性调度、SQL 执行计划自动比对。但 L1 到 L5 的演进,已足够覆盖 95% 的业务场景。
4. 从配置到工具类:一次完整的故障排查与优化实战
理论终需落地。我以某次真实的线上故障为蓝本,还原从druid.properties参数异常到JDBCUtils逻辑缺陷,再到最终优化的完整闭环。过程不加修饰,保留所有试错步骤和思考链条,因为这才是工程师最该掌握的能力。
4.1 故障现象:凌晨3点,订单创建接口 P99 响应时间从 300ms 飙升至 4200ms
- 监控图表:
/druid页面显示ActiveCount持续在 50(maxActive=50),PoolingCount为 0,WaitCount在 200–300 波动。 - 日志特征:大量
java.sql.SQLException: wait millis 30000, active 0, maxActive 50, creating 0。 - 初步判断:连接池已满,新请求排队等待,但无连接被释放。
4.2 第一步:检查 druid.properties —— 发现removeAbandonedOnBorrow被注释
- 配置文件片段:
# removeAbandonedOnBorrow=true # removeAbandonedTimeout=180 - 为什么注释是错的?
removeAbandonedOnBorrow=true是连接泄漏的“最后一道闸门”。当连接被借用后长时间未归还(如代码中return前忘记close),Druid 会强制回收它。注释掉,等于拆除保险丝。 - 验证动作:
- 在测试环境
druid.properties中取消注释,重启应用; - 模拟泄漏:在 DAO 中写一段
getConnection()后不close()的代码; - 观察
/druid页面:ActiveCount上升后,在removeAbandonedTimeout时间后,ActiveCount下降,PoolingCount回升,且日志出现abandon connection。
- 在测试环境
- 结论:配置缺失是直接原因,但根源是代码存在泄漏。
4.3 第二步:分析 JDBCUtils —— 定位ThreadLocal清理漏洞
- 查看
JDBCUtils代码(L3 版本):public static void closeConnection() { Connection conn = CONNECTION_HOLDER.get(); if (conn != null) { try { conn.close(); } catch (SQLException e) { log.error("Close error", e); } // ❌ 缺少 CONNECTION_HOLDER.remove() } } - 为什么
remove()如此关键?
Tomcat 使用线程池,请求处理完后线程不会销毁,而是归还给池等待下次复用。若ThreadLocal不remove(),下次请求会拿到上一个请求遗留的Connection对象。而该连接很可能已关闭(conn.close()后状态为 closed),再次conn.createStatement()就会抛SQLException: connection closed,但我们的closeConnection()方法对此无感知,导致连接永远无法归还,池被“吃掉”。 - 复现步骤:
- 在
closeConnection()中故意删除CONNECTION_HOLDER.remove(); - 启动应用,发起10次请求;
- 查看
CONNECTION_HOLDER中的Connection对象:第10次请求的ThreadLocal值,是第1次请求创建的连接(已关闭)。
- 在
- 修复方案:
public static void closeConnection() { Connection conn = CONNECTION_HOLDER.get(); if (conn != null) { try { conn.close(); } catch (SQLException e) { log.error("Close error", e); } finally { CONNECTION_HOLDER.remove(); // ✅ 加入 finally } } }
4.4 第三步:深挖代码 —— 发现 DAO 层的“伪事务”陷阱
- 查看订单创建 DAO:
public void createOrder(Order order) { Connection conn = JDBCUtils.getConnection(); // L3 版本 try { // 插入订单主表 insertOrder(conn, order); // 插入订单明细(循环插入) for (OrderItem item : order.getItems()) { insertOrderItem(conn, item); } // ❌ 此处没有 commit,也没有 close } catch (Exception e) { // ❌ 没有 rollback throw e; } // ❌ 没有 closeConnection() } - 问题本质:
这是一个典型的“伪事务”。getConnection()从ThreadLocal获取连接,但未在方法内commit()或rollback(),也未closeConnection()。连接一直被持有,直到线程结束(Tomcat 线程池回收时,ThreadLocal被清空,连接才被 Druid 回收)。 - 为何
removeAbandonedOnBorrow没起作用?
因为removeAbandonedTimeout=180(3分钟),而订单创建接口平均耗时 2.5 秒,远低于阈值,Druid 认为连接“还在正常使用”,不回收。 - 修复方案:
- 短期:在 DAO 方法末尾强制
closeConnection(); - 中期:升级到 L4 版本,使用
executeInTransaction()模板; - 长期:接入 Spring
@Transactional,交由框架管理。
- 短期:在 DAO 方法末尾强制
4.5 第四步:参数调优 —— 基于监控数据的精准调整
故障期间
/druid数据:指标 值 说明 ActiveCount50 池已满 PoolingCount0 无空闲连接 WaitCount250 250个请求在排队 WaitMillis30000 等待超时( maxWait=30000)CreateCount0 无新连接创建( maxActive已达上限)调优决策:
maxActive=50合理(QPS≈300,平均SQL耗时≈150ms →