
维护老项目的时候最常见的情况就是数据源连接散落在各个业务层里有人直接new DruidDataSource()有人getConnection()之后忘了归还还有人把连接池的参数每个环境各写一套改一次配置要翻好几个文件。后来我实在看不下去抽了一个DatasourceUtils工具类统一封装了 Druid 连接池的创建、连接的获取和归还把事务场景也一并处理了。这篇文章我会把从封装动机到最终代码、从参数配置到踩坑排错的过程完整记下来。如果你也在用 Druid 这套连接池或者打算做一个统一的数据源入口这篇值得参考。1. 为什么放着Spring自带的DataSourceUtils不用非要自己再包一层1.1 每个项目里都有的样板代码我先说说最开始为什么会有封装这个念头。起因是项目中打印 SQL 日志时发现一个诡异现象明明接口里只是做了一次简单的查询select * from xxx where id ?却耗时 800 毫秒。后来看DruidDataSource的监控数据才发现连接池里活跃连接数一直在涨空闲连接数却长期是 0说明很多连接借出去之后就没有归还过每次新的请求来了只能重新建立物理连接而建连接的耗时就占了请求时间的大头。这类问题出在每个业务代码里都有一套重复的“连接获取三板斧”Connection conn null; try { conn dataSource.getConnection(); // 业务操作 } catch (SQLException e) { throw new RuntimeException(e); } finally { if (conn ! null) { conn.close(); } }这段代码本身没啥错但问题在于每个项目、每个业务层都在写而且数据源从哪里来、怎么配置、参数是什么各有各的写法。有人直接Class.forName注册驱动再DriverManager.getConnection有人用一个全局配置类读取配置文件还有人直接new DruidDataSource()然后手动 set 一堆参数总之乱象丛生。一旦连接池配置要调整比如把maxActive从 50 改成 100你得把所有写死了连接池的地方全找出来改一遍。1.2 Spring DataSourceUtils帮我们解决了哪一半问题Spring 确实提供了一个内置的工具类org.springframework.jdbc.datasource.DataSourceUtils它做了一件很重要的事情让获取连接的操作感知当前线程上的事务状态。也就是说如果现在外层方法开启了Transactional内层方法再调用DataSourceUtils.getConnection(dataSource)拿到的其实是同一个事务绑定的连接而不是重新从池里借一个。这一点非常关键因为 JdbcTemplate 之所以能保证在同一事务里所有 SQL 执行用的是同一个连接靠的就是这个机制。但使用DataSourceUtils有一个不讨喜的地方它要求调用者自己持有DataSource对象然后每次都要传进去Connection conn DataSourceUtils.getConnection(dataSource);这就导致数据源对象还是在业务代码里传来传去没有一个统一的地方管理。而且它只管连接的获取和事务绑定自身不负责连接池的创建、配置、监控也不解决“每个环境参数不一样”的配置管理问题。1.3 我给自己定的三条封装目标既然要让工具类真正可用不是简单包一层皮就完事我先列了三条需求统一入口业务代码里想拿连接只需要一行DatasourceUtils.getConnection()不需要关心数据源从哪来、是什么类型。可配置化连接池参数从配置文件读取不同环境开发、测试、生产通过外部配置切换代码不写死任何敏感环境参数。可观测能方便地拿到当前活跃连接数、空闲连接数、等待队列长度这些指标出了问题可以快速判断是连接泄漏还是参数配置不合理。带着这三条需求我开始动手实现。在写代码之前先把 Druid 连接池的一些关键参数和工作方式重新梳理了一遍因为很多坑其实就是参数理解不到位导致的。2. 动手封装前先把Druid连接池的关键参数吃透2.1 连接池的本质预建连接复用连接在配置参数之前需要先建立一张整体图景连接池到底是怎么工作的。Druid 在启动时会按initialSize创建初始数量的物理连接放在池里。应用要连接时从池里拿出一个空闲连接如果空闲不够且当前连接数没到maxActive就新建一个用完之后归还到池里。如果所有连接都被占满新的请求就需要等待等待时间由maxWait控制。同时后台还有一个回收线程周期性检查哪些连接空闲太久、哪些连接是无效的然后进行回收和重建。理解这一点对配置参数非常重要。比如testOnBorrow如果设置成true每次借出连接时都会执行一次validationQuery来验证连接是否有效这在高并发场景下会对数据库造成额外的压力会显著拖慢接口响应。所以默认应该用testWhileIdle而不是testOnBorrow。2.2 常用参数的意义和推荐值我把平时最常用的配置参数整理成了下面这个表也是我当时配置 Druid 的参考基准参数作用我常用的初始值initialSize启动时创建的物理连接数5minIdle池中保底的最小空闲连接数5maxActive池中最大活跃连接数50maxWait获取连接的最大等待时间毫秒60000timeBetweenEvictionRunsMillis后台回收线程的运行间隔60000minEvictableIdleTimeMillis连接空闲多久后可以被回收300000validationQuery校验连接是否有效的 SQLSELECT 1testWhileIdle空闲连接在做回收检查时是否先校验一下truetestOnBorrow借出连接时是否立即校验falsetestOnReturn归还连接时是否校验falsepoolPreparedStatements是否缓存PreparedStatementtruemaxPoolPreparedStatementPerConnectionSize每个连接缓存 PreparedStatement 的数量202.3 三个容易误配的参数决定连接池生死上面这些参数里有三个特别容易配错我在实际项目中都踩过单独拿出来说一下。第一个是maxWait。如果不配maxWait或者设置成负数那么当连接池打满时线程会一直阻塞在获取连接的调用上接口表现为“卡死”而不会报超时错误。表面上看是接口响应慢实际上是线程全部堆在了getConnection这一行。所以我会建议一定给maxWait设置一个明确的超时时间宁可让调用方快速失败也不要让线程无限等下去。第二个是validationQuery。不同数据库的校验 SQL 不一样MySQL 是SELECT 1Oracle 是SELECT 1 FROM DUAL。如果validationQuery和数据库类型不匹配回收线程每次检查都会抛异常连接池会不停地创建新连接最终把数据库连接数打满。第三个是testWhileIdle必须配合timeBetweenEvictionRunsMillis使用才有意义。如果回收线程的运行间隔设置得太长比如 10 分钟那么空闲了很久的连接可能在被借出时已经断开了而testOnBorrow又是 false就会把已经坏掉的连接拿给业务层使用。合理的方式是让回收间隔短一些让testWhileIdle定期把坏连接清出去。说实话如果数据库和客户端之间有防火墙等网络设备连接很容易被中间设备静默断开这种情况下testWhileIdle尤其重要。3. DatasourceUtils工具类的核心实现3.1 单例模式管理数据源不是炫技而是必须考虑清楚了参数之后就是代码设计了。工具类最核心的数据源管理我用了静态内部类单例模式而不是double-checked locking的写法。为什么这样选一是因为DruidDataSource本身不是轻量对象它在初始化时会创建物理连接涉及数据库 I/O我希望它在整个应用生命周期里只建一次。二是静态内部类的单例写法既保证了线程安全又不需要同步锁竞争代码还算干净。当然如果后续要支持多数据源这个单例就不能用了需要用MapString, DruidDataSource按数据源名称区分这是后话。数据源初始化的逻辑我用一个initDataSource方法负责它从配置类中读取参数set到DruidDataSource上。为了防止重复初始化还加了一个原子标记来判断。这里不推荐直接每次调用都 new 一个数据源因为那相当于把连接池生命周期完全搞乱了。3.2 对外方法的完整设计我先展示这个工具类对外提供的核心 APIpublic final class DatasourceUtils { private DatasourceUtils() { } // 获取数据源懒加载 public static DataSource getDataSource() { return DataSourceHolder.INSTANCE; } // 获取连接兼容事务环境 public static Connection getConnection() { return DataSourceUtils.getConnection(getDataSource()); } // 归还连接 public static void releaseConnection(Connection connection) { DataSourceUtils.releaseConnection(connection, getDataSource()); } // 获取当前活跃连接数 public static int getActiveCount() { DataSource dataSource getDataSource(); if (dataSource instanceof DruidDataSource druidDataSource) { return druidDataSource.getActiveCount(); } return -1; } // 获取空闲连接数 public static int getIdleCount() { DataSource dataSource getDataSource(); if (dataSource instanceof DruidDataSource druidDataSource) { return druidDataSource.getIdleCount(); } return -1; } private static final class DataSourceHolder { private static final DataSource INSTANCE createDataSource(); private static DataSource createDataSource() { DruidDataSource druidDataSource new DruidDataSource(); // 这里从配置对象读取参数不要硬编码 druidDataSource.setUrl(DbConfig.getUrl()); druidDataSource.setUsername(DbConfig.getUsername()); druidDataSource.setPassword(DbConfig.getPassword()); druidDataSource.setDriverClassName(DbConfig.getDriverClassName()); druidDataSource.setInitialSize(DbConfig.getInitialSize()); druidDataSource.setMinIdle(DbConfig.getMinIdle()); druidDataSource.setMaxActive(DbConfig.getMaxActive()); druidDataSource.setMaxWait(DbConfig.getMaxWait()); druidDataSource.setTimeBetweenEvictionRunsMillis(DbConfig.getTimeBetweenEvictionRunsMillis()); druidDataSource.setMinEvictableIdleTimeMillis(DbConfig.getMinEvictableIdleTimeMillis()); druidDataSource.setValidationQuery(DbConfig.getValidationQuery()); druidDataSource.setTestWhileIdle(DbConfig.isTestWhileIdle()); druidDataSource.setTestOnBorrow(DbConfig.isTestOnBorrow()); druidDataSource.setTestOnReturn(DbConfig.isTestOnReturn()); druidDataSource.setPoolPreparedStatements(DbConfig.isPoolPreparedStatements()); druidDataSource.setMaxPoolPreparedStatementPerConnectionSize( DbConfig.getMaxPoolPreparedStatementPerConnectionSize()); return druidDataSource; } } }注意几个细节getConnection()内部用的是org.springframework.jdbc.datasource.DataSourceUtils而不是直接dataSource.getConnection()。这么做的原因在下面细说。还有一个是getActiveCount()这类监控方法我只在数据源是DruidDataSource实例时才返回值保证工具类即便未来替换成其他连接池调用方也不会报错。3.3 为什么获取连接不能直接调用dataSource.getConnection直接dataSource.getConnection()会不会有问题单独看这一段没有大问题但放到事务场景里就会出乱子。比如一个方法标注了Transactional它内部调用了另一个方法那个方法也去拿连接。如果这两个方法都直接用dataSource.getConnection()那么它们拿到的很可能不是同一个连接。外层的数据库事务是建立在连接 A 上的内层方法用连接 B 执行了 SQL那么内层的 SQL 是不是参与外层事务大概率不参与因为 SQL 执行在不同的连接上。外层事务回滚时内层已提交的变更不会跟着回滚数据完整性就出问题了。而 Spring 的DataSourceUtils.getConnection(dataSource)在调用时会先去TransactionSynchronizationManager里查当前线程有没有绑定事务资源如果已经绑定了就返回绑定的那一个。所以用它替换裸的dataSource.getConnection()就能保证事务传播生效这也是封装工具类最值得强调的一个设计决策。4. 连接获取与归还最容易踩的坑4.1 归还连接用releaseConnection而不是直接close很多人在写finally块时用的是conn.close()这个习惯在用了 Spring 事务同步机制之后会造成问题。如果你是用DataSourceUtils.getConnection(dataSource)拿到的连接然后调conn.close()那么这个执行路径分为两种情况如果当前没有事务conn.close()确实会直接将连接归还给连接池但如果当前有事务conn.close()的行为就不那么优雅了——它可能把绑定在事务上的连接直接关掉而外层事务后续的 SQL 还指望这段连接继续执行。正确做法是在finally块中调用DataSourceUtils.releaseConnection(conn, dataSource)。这个方法内部会判断当前线程是否已有事务绑定这个连接如果有它只是减少引用计数或不做真实关闭如果没有则真正释放回连接池。我封装后的工具类把这两步合并成了一个方法调用方只需要做Connection conn null; try { conn DatasourceUtils.getConnection(); // 业务逻辑 } finally { DatasourceUtils.releaseConnection(conn); }4.2 一个真实的连接泄漏排查过程有一次同事提了个问题说某个定时任务跑完后连接池活跃数下不来。我一看代码就发现问题了他的写法是Connection conn DatasourceUtils.getConnection(); PreparedStatement ps conn.prepareStatement(select ...); ResultSet rs ps.executeQuery(); // 处理结果整个方法里没有try-finallyconn在方法结束前都没有关闭。虽然这看起来是个“低级错误”但问题却很有迷惑性rs和ps都没有单独关闭即便conn.close()执行了连接是否真正归还也要取决于数据库驱动和连接池的实现。我当时排查的路径是这样的先看 Druid 监控页面的活跃连接曲线确认泄漏是集中在定时任务运行时间段内出现。在jstack线程快照里找持有连接但未释放的线程堆栈定位到某个定时任务的方法。打开该方法的源码确认Connection对象没有被close也没有被releaseConnection。这类问题最可怕的地方是它平时不爆发连接池默认 10 分钟回收一次空闲连接如果你定时任务 5 分钟跑一次连接刚泄漏掉还没来得及积压就被回收了系统不会崩。但一旦任务频率变高、每次泄漏数量变大maxActive很快就打满了届时所有正常业务的取连接请求都会超时。所以我在见过几次类似问题后给工具类加了个隐藏的“防呆机制”监控方法里暴露活跃数指标同时把这些指标接入告警活跃数持续超过阈值就报警。连接池代码本身防不住写错代码的人但监控能第一时间把问题暴露出来。4.3 多数据源场景下工具类该怎么扩展如果你的项目里只有一个数据库上面的单例写法完全够用。但现实中很常见的是按业务域分库比如用户库和订单库。这种情况下单例DataSource就不满足需求了。我的做法是把工具类改造成按名称管理多个DruidDataSource类似这样private static final MapString, DataSource DATA_SOURCES new ConcurrentHashMap(); public static DataSource getDataSource(String name) { return DATA_SOURCES.computeIfAbsent(name, DatasourceUtils::createDataSource); } public static Connection getConnection(String name) { return DataSourceUtils.getConnection(getDataSource(name)); } public static void releaseConnection(Connection conn, String name) { DataSourceUtils.releaseConnection(conn, getDataSource(name)); }这里有个关键点releaseConnection时必须传入同一个DataSource实例。因为 Spring 的DataSourceUtils在释放连接时要靠dataSource去匹配当前线程绑定的事务资源。如果你用 A 数据源拿连接却用 B 数据源作为参数去释放事务同步管理器里找不到对应的绑定连接就不会被正确归还。5. 监控能力Druid连接池的另一个核心价值5.1 打开StatFilter把监控数据吐出来Druid 相比其他连接池最大的优势不是性能而是监控能力。默认情况下监控是关闭的需要在初始化时配置一个过滤器。在工具类里初始化 Druid 时我加上了这几行try { druidDataSource.setFilters(stat,wall,log4j2); } catch (SQLException e) { throw new IllegalStateException(初始化 Druid 监控过滤器失败, e); }stat统计 SQL 执行次数、耗时、并发数等。wallSQL 防火墙能拦截掉大部分注入攻击。log4j2输出 SQL 日志。如果你只想看监控stat就够用了。生产环境我会建议同时开wall它对常见的select * from user where id 1 or 11之类的注入 SQL 会直接拦截等于给数据访问层加了一道额外的安全门。5.2 通过工具类暴露一眼就能看的指标有了stat过滤器Druid 会暴露一堆监控数据但大部分团队不会专门搭一个可视化系统去接入。我更推荐的做法是把这些指标用最朴素的方式从工具类里吐出来。上面代码里已经写了getActiveCount()和getIdleCount()我在项目里还会额外加几个public static long getWaitThreadCount() { if (getDataSource() instanceof DruidDataSource druidDataSource) { return druidDataSource.getWaitThreadCount(); } return -1; } public static long getConnectCount() { if (getDataSource() instanceof DruidDataSource druidDataSource) { return druidDataSource.getConnectCount(); } return -1; } public static long getCloseCount() { if (getDataSource() instanceof DruidDataSource druidDataSource) { return druidDataSource.getCloseCount(); } return -1; }拿这些指标干什么用我个人的习惯是写一个轻量的接口返回这三种数据的快照再配合受保护的访问控制只有运维和开发环境能查看。在排查问题时这个快照比看日志高效得多。比如getConnectCount()和getCloseCount()长期差距很大基本可以断定连接没有被合理释放。5.3 从慢SQL统计里找到性能瓶颈stat过滤器统计的慢 SQL 数据里最有价值的就是那些执行时间超过预设阈值的 SQL。有一次我们在排查一个报表页面加载慢的问题接口本身逻辑不复杂就是查一张几百万行的表。Druid 监控里发现这条查询的executeCount不高但fetchRowCount特别大单次查询取了几万行数据。问题不在 SQL 本身而是业务把全量数据都拉到内存再过滤。调整思路是加条件在 SQL 层过滤或者分页查询接口耗时直接从 5 秒降到了 300 毫秒。这种问题在业务日志里很难发现因为接口整体耗时被下游节点消耗了日志只会显示接口调用超时不会告诉你到底是哪条 SQL 慢、慢在哪个环节。而 Druid 的监控统计能把耗时精确到具体 SQL是定位数据库性能问题最直接的一层。6. 我踩过的坑和最终版本的工具类代码6.1 三个典型配置事故讲完监控再说三个我在实际工程里踩过的配置事故希望大家绕开。第一个是maxActive设置得过大。有人会想既然连接池要紧着够用那就使劲调大。但连接数是硬资源MySQL 的max_connections默认是 151老版本可能更小。假设你有两个服务实例每实例maxActive150一个实例到了高峰期就把数据库连接数占满另一个实例完全拿不到连接。正确的设置方式是要考虑服务实例数、数据库连接上限、以及单接口的并发需求综合评估而不是拍脑袋设个很大的数。第二个是minIdle设置成 0。如果没有把minIdle维持在一个合理水平业务低谷期所有空闲连接都会被回收到了高峰期又要重新创建连接。而创建物理连接在极端情况下是几十毫秒甚至上百毫秒的开销系统会出现在低谷后的第一个请求特别慢的现象。让minIdle保持一个适中值比如和initialSize一致能避免这种“冷启动”冲击。第三个是数据库密码里有特殊字符。这个问题很阴间配置文件里的密码包含了或#这类字符驱动解析时被当成了 URL 参数分隔符导致连接失败。排查时你看到异常是Access denied for user但实际上账号密码没错。如果你们用的是property namepassword value.../这种方式配置密码里的特殊字符一般不会出问题但如果你把密码拼接在 JDBC URL 里就要用URLEncoder处理。我的建议是企业内部环境密码统一走环境变量或配置中心不要放在仓库里。6.2 最终版DatasourceUtils代码可以直接抄作业把以上经验沉淀进去我最终版本的DatasourceUtils大概是这个样子public final class DatasourceUtils { private static final MapString, DataSource DATA_SOURCES new ConcurrentHashMap(); private static final String DEFAULT_DS default; private DatasourceUtils() { } public static DataSource getDataSource() { return getDataSource(DEFAULT_DS); } public static DataSource getDataSource(String name) { return DATA_SOURCES.computeIfAbsent(name, DatasourceUtils::createDataSource); } public static Connection getConnection() { return getConnection(DEFAULT_DS); } public static Connection getConnection(String name) { return org.springframework.jdbc.datasource.DataSourceUtils.getConnection(getDataSource(name)); } public static void releaseConnection(Connection connection) { releaseConnection(connection, DEFAULT_DS); } public static void releaseConnection(Connection connection, String name) { org.springframework.jdbc.datasource.DataSourceUtils.releaseConnection(connection, getDataSource(name)); } private static DataSource createDataSource(String name) { DruidDataSource druidDataSource new DruidDataSource(); // 按 name 读取对应的配置 DruidProperties props DruidProperties.load(name); druidDataSource.setName(name); druidDataSource.setUrl(props.getUrl()); druidDataSource.setUsername(props.getUsername()); druidDataSource.setPassword(props.getPassword()); druidDataSource.setDriverClassName(props.getDriverClassName()); druidDataSource.setInitialSize(props.getInitialSize()); druidDataSource.setMinIdle(props.getMinIdle()); druidDataSource.setMaxActive(props.getMaxActive()); druidDataSource.setMaxWait(props.getMaxWait()); druidDataSource.setTimeBetweenEvictionRunsMillis(props.getTimeBetweenEvictionRunsMillis()); druidDataSource.setMinEvictableIdleTimeMillis(props.getMinEvictableIdleTimeMillis()); druidDataSource.setValidationQuery(props.getValidationQuery()); druidDataSource.setTestWhileIdle(props.isTestWhileIdle()); druidDataSource.setTestOnBorrow(props.isTestOnBorrow()); druidDataSource.setTestOnReturn(props.isTestOnReturn()); druidDataSource.setPoolPreparedStatements(props.isPoolPreparedStatements()); druidDataSource.setMaxPoolPreparedStatementPerConnectionSize( props.getMaxPoolPreparedStatementPerConnectionSize()); try { druidDataSource.setFilters(stat,wall); } catch (SQLException e) { throw new IllegalStateException(初始化 Druid filter 失败, e); } return druidDataSource; } public static MapString, Integer stats() { MapString, Integer stats new HashMap(); DATA_SOURCES.forEach((name, dataSource) - { if (dataSource instanceof DruidDataSource druidDataSource) { stats.put(name .active, druidDataSource.getActiveCount()); stats.put(name .idle, druidDataSource.getIdleCount()); stats.put(name .waitThread, druidDataSource.getWaitThreadCount()); } }); return stats; } }DruidProperties是一个普通的配置类内部从环境变量或配置中心读取参数这里就不把它的代码全贴出来了大家根据自己的配置管理方式去实现即可。6.3 这个工具类还能怎么扩展实际项目里我后续又给这个工具类加了一个轻量的定时“体检”逻辑每隔一段时间利用 Druid 的DruidDataSourceStatManager能力采集各数据源的连接使用情况判断是否存在连接数持续增长的情况。如果在没有并发起伏的后台任务运行期间活跃连接数却在稳步上升那就基本锁定了连接泄漏直接报警提示需要排查代码。另一个扩展方向是动态数据源切换。早期做读写分离时我给DatasourceUtils增加了一个基于ThreadLocal的“从库”标记如果某个请求里显式标记了READ_ONLY那么getConnection()就走只读数据源。这个逻辑本质上是把路由规则从业务代码里抽离出来放到工具类里做统一处理。如果你的业务里也有类似的读写分离需求可以参考这个思路但要注意在请求结束时一定要清理ThreadLocal否则后续线程复用时会误拿到上一次的标记。在整个封装过程中我最深的体会是工具类不要只做一个“取连接、还连接”的搬运工要把它当成数据访问层的基础设施来设计把连接池的参数管理、事务同步、监控告警、多数据源扩展都考虑进去。一个真正好用的DatasourceUtils不是代码写得有多花哨而是能让团队里每个开发者在写数据访问代码时不用思考“连接从哪里来、到哪里去”这些问题只需要关心自己的业务 SQL。做到这一点工具类就算成功了。