项目里要接新库的时候,最烦的就是重复写数据源配置。我干脆把 Druid 连接管理抽成了一个工具类,DatasourceUtils 是里面最关键的一块。这篇文章是这段时间封装过程的完整复盘,代码可以直接拿回去改,踩过的坑也一并列出来,省得你再掉进同一条河里。
这个工具类解决的核心问题是:多环境切换、多数据源注册、连接池参数统一管理,以及数据源切换时的事务一致性。适合正在做微服务拆分、需要接多个数据库、或者想把项目中散落的 Druid 配置收拢到一个地方的后端开发看。
1. 项目背景与工具类整体设计
1.1 从重复代码里抽出来的 DatasourceUtils
先说背景。当时手上维护着三个后端服务,分别对接订单库、用户库和日志库。每个服务都有一份独立的 Druid 配置,代码结构差不多,但参数各调各的,有的服务把maxActive调到 50,有的还保持着默认 8。更麻烦的是测试环境和预发环境的数据源地址都不一样,每次发版前都要手动改配置文件,改错一次就是线上事故。
后来我决定把数据库连接的初始化逻辑统一收敛到一个公共模块里。这个模块叫 DruidUtils,里面按职责拆了几个类:配置加载、数据源创建、连接获取、连接池状态查询。DatasourceUtils 就是操作数据源入口的门面类,外部服务不直接碰 Druid 的DataSource对象,只和 DatasourceUtils 交互。
这样设计有个直接好处:业务代码里不再出现任何 Druid 特定的类型依赖。以后就算底层把 Druid 换成 HikariCP,业务的改动面也可以控制在一个类内部。
1.2 工具类解决的问题与设计边界
DatasourceUtils 要解决的问题比想象中多。第一是数据源实例的统一创建,不能每次调用getConnection都新建一个连接池,必须保证同一个dataSourceName对应同一个DataSource实例。第二是连接参数的外部化,数据库地址、账号、密码、连接池大小都不该写在工具类代码里。第三是动态添加数据源,比如运营后台需要临时接入一个分析库,不能要求重启服务。
但也不能把所有逻辑都塞进这个类。我给自己定的边界是:DatasourceUtils 只负责数据源的注册、获取、切换、关闭以及基础状态查询。至于 SQL 执行、事务管理、Mapper 扫描这些,继续由 Spring 和 MyBatis 负责,工具类不越界。
这里有一个很容易犯的错误:试图在工具类里直接管理事务。事务的本质是“同一个数据库连接上的多个操作要么都成功,要么都失败”,如果把连接池、事务、动态数据源全部耦合在一个类里,调试起来会非常痛苦。所以 DatasourceUtils 的对外接口只做到连接和上下文切换层面,事务交给 Spring 的@Transactional和AbstractRoutingDataSource配合处理。
2. DatasourceUtils 核心方法实现拆解
2.1 数据源初始化:createDataSource 的几个关键细节
数据源初始化是整套工具里最需要抠细节的部分。我实现了一个createDataSource(String name, DruidProperties properties)方法,内部逻辑大致是:
public DataSource createDataSource(String name, DruidProperties props) { if (contains(name)) { return lookup(name); } DruidDataSource dataSource = new DruidDataSource(); dataSource.setName(name); dataSource.setUrl(props.getUrl()); dataSource.setUsername(props.getUsername()); dataSource.setPassword(props.getPassword()); dataSource.setDriverClassName(props.getDriverClassName()); dataSource.setInitialSize(props.getInitialSize()); dataSource.setMinIdle(props.getMinIdle()); dataSource.setMaxActive(props.getMaxActive()); dataSource.setMaxWait(props.getMaxWait()); dataSource.setValidationQuery(props.getValidationQuery()); dataSource.setTestWhileIdle(true); dataSource.setTestOnBorrow(false); dataSource.setTestOnReturn(false); dataSource.setPoolPreparedStatements(true); dataSource.setMaxPoolPreparedStatementPerConnectionSize(20); dataSource.setRemoveAbandoned(true); dataSource.setRemoveAbandonedTimeout(180); dataSource.setLogAbandoned(true); dataSource.init(); register(name, dataSource); return dataSource; }几个参数值得单独解释。
setTestWhileIdle(true)表示空闲连接在空闲时间超过timeBetweenEvictionRunsMillis时会执行validationQuery验证是否有效。这个一定得开,否则数据库端因为wait_timeout把连接断掉后,连接池里的连接还是“可用”状态,一旦被业务线程拿到,执行 SQL 会直接报“Connection is not available”。我见过好几次线上报错就是这里没配置。
setTestOnBorrow(false)是为了性能。每次从连接池借出连接时都做验证,在高并发下开销很高。只要testWhileIdle和keepAlive配合好,大部分场景不需要testOnBorrow。但如果是内网网络不稳定、防火墙经常清理空闲连接的场景,可以折中把testOnBorrow打开,代价是 QPS 会下降一些。
setRemoveAbandoned(true)表示开启连接回收。这里必须同时设置removeAbandonedTimeout和logAbandoned。removeAbandonedTimeout的单位是秒,我设的是 180,意思是如果一个连接从连接池借出后 180 秒内没有归还,就强制回收。logAbandoned打开后会把出错的调用栈打到日志里,方便排查是哪个业务方法没有正确关闭连接。
还有一个容易被忽视的配置:setMaxPoolPreparedStatementPerConnectionSize(20)。如果项目里大量使用 MyBatis,预编译语句会被缓存在连接上,这个参数不设置的话默认是 -1,也就是不缓存。之后每次执行同样的 SQL 都要重新预编译,会有明显的性能损耗。但也不是调得越大越好,每个连接多缓存一条预编译语句就多占一份内存,连接数大、SQL 量大的项目容易让老年代涨得很快。
2.2 数据源的缓存与生命周期管理
数据源实例不能无限创建,所以我在 DatasourceUtils 内部维护了一个ConcurrentHashMap<String, DataSource>。用putIfAbsent保证并发下同一个数据源名只有一次创建过程。这里有个容易踩的坑:DruidDataSource.init()不是幂等的,如果两个线程同时对同一个数据源调用init(),会抛异常。所以创建动作必须加锁或者依赖 map 的computeIfAbsent。
缓存 map 的 key 我用的是数据源名称,而不是 URL。因为同一个库可能因为读写分离配置出两个数据源,比如orderMaster和orderSlave,它们的 URL 可能是一样的,但连接池参数和隔离级别不同。如果用 URL 做 key,这两个数据源会被误认为同一个。
关闭数据源的方法也要小心。我实现的是:
public void close(String name) { DataSource dataSource = cache.remove(name); if (dataSource instanceof DruidDataSource) { DruidDataSource druidDataSource = (DruidDataSource) dataSource; if (!druidDataSource.isClosed()) { druidDataSource.close(); } } }注意isClosed()判断。重复调用close()虽然不会抛错,但在某些版本里会打印一堆堆栈警告,干扰日志排查。另外,关闭前一定要先从缓存里移除。如果不先移除,后续再创建同名数据源时拿到的还是快被关闭的旧对象,很容易出现“数据源未初始化”的诡异错误。
生命周期管理还包括连接池状态的监控。我在 DruidUtils 里加了一个getStatistic(String name)方法,返回连接池的活跃连接数、空闲连接数、等待获取连接的线程数等指标。这些数据用来做告警非常有用,后面实操部分会讲。
3. 动态多数据源切换的完整实操
3.1 基于 AbstractRoutingDataSource 的切换原理
如果是单数据源,拿到 DataSource 直接注入 SqlSessionFactory 就结束了。但动态数据源需要在每次操作数据库时根据上下文选择不同的库,这就要用到 Spring 提供的AbstractRoutingDataSource。
这个类的原理可以理解为:它本身是一个代理数据源,内部维护一个targetDataSources集合,还有一把determineCurrentLookupKey()的钥匙。每次从它这里拿连接时,它不会直接返回连接,而是调用determineCurrentLookupKey()得到一个 key,再从targetDataSources里取出对应的真实数据源,最后返回真实数据源的连接。
所以 DatasourceUtils 和 Spring 的对接就落在两件事上:第一,把动态数据源注册到AbstractRoutingDataSource里;第二,在每次数据库操作之前设置当前线程的 key。线程 key 我用的是ThreadLocal<String>:
public class DynamicDataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); public static void setDataSourceType(String dataSourceType) { CONTEXT_HOLDER.set(dataSourceType); } public static String getDataSourceType() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }这里强调一下为什么要用ThreadLocal。Spring 的事务管理器在事务开始时从数据源获取连接,这个连接会被绑定到当前线程的事务资源上,直到事务提交或回滚。也就是说,同一个事务里的所有数据库操作必须走同一个连接。如果用实例变量或者全局变量保存当前数据源 key,在并发环境下会混乱。ThreadLocal保证每个线程只看到自己设置的 key,互不干扰。
3.2 与 Spring 集成:从 DataSource 到事务边界
真正配置的时候,我手写了一个DynamicDataSource类继承了AbstractRoutingDataSource:
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSourceType(); } }然后在 Spring 配置里把DynamicDataSource作为主数据源注入给 MyBatis 或 JPA:
@Bean @Primary public DataSource dynamicDataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("order", DatasourceUtils.lookup("orderMaster")); targetDataSources.put("user", DatasourceUtils.lookup("userMaster")); DynamicDataSource dataSource = new DynamicDataSource(); dataSource.setTargetDataSources(targetDataSources); dataSource.setDefaultTargetDataSource(DatasourceUtils.lookup("orderMaster")); return dataSource; } @Bean public SqlSessionFactory sqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dataSource) throws Exception { // MyBatis 配置 }这里有一个非常重要的坑:@Transactional的切换时机。
如果事务已经开启,Spring 在事务管理器启动时就会从DynamicDataSource获取连接。此时如果业务方法里先执行了DynamicDataSourceContextHolder.setDataSourceType("user"),但@Transactional是在方法入口处开启事务,那么事务管理器在开启时就已经调用了一次determineCurrentLookupKey()。如果这个调用发生在setDataSourceType之前,拿到的还是默认数据源,事务中途再切换 key 也没有用,因为连接已经绑定到当前线程了。
解决办法有两种。第一种是使用DataSourceTransactionManager的setDataSource指定默认数据源,然后在每个需要切换数据源的服务方法上手动控制事务,例如用TransactionTemplate包裹。第二种更推荐:把@Transactional的传播行为配成REQUIRED,同时将需要切换数据源的代码放到一个独立类中,让 Spring 的 AOP 在进入这个方法前通过@DataSourceSwitch自定义注解设置 key,然后再开启事务。
我最终实现的是一个DataSourceAspect:
@Aspect @Component public class DataSourceAspect { @Around("@annotation(dataSourceSwitch)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DataSourceSwitch dataSourceSwitch) throws Throwable { DynamicDataSourceContextHolder.setDataSourceType(dataSourceSwitch.value()); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.clear(); } } }然后在业务方法上标注:
@DataSourceSwitch("user") @Transactional public void updateUserAndLog(...) { ... }这样切面在事务拦截器之前执行,key 设置完之后事务管理器再开启事务,拿到的就是正确的数据源。顺序问题算是动态数据源最隐蔽的一个坑,我在这里前前后后调试了两天才彻底搞明白。
4. 常见问题与排查实录
4.1 连接池打满:参数配置与慢 SQL 排查
连接池打满是我在实际项目里遇到最多的问题。现象很直接:应用日志里全是GetConnectionTimeoutException,数据库的活跃连接数达到上限,其他正常业务也跟着受影响。
排查的时候先看监控。我用的工具是 Druid 自带的StatLogger,每隔一段时间输出一次连接池的统计:
DruidDataSource dds = (DruidDataSource) DatasourceUtils.lookup("orderMaster"); System.out.println("active=" + dds.getActiveCount()); System.out.println("poolingCount=" + dds.getPoolingCount()); System.out.println("waitThreadCount=" + dds.getWaitThreadCount());如果是activeCount一直很高,说明有连接没被归还。最典型的场景是 MyBatis 的SqlSession没有关闭。在 Spring 管理下一般不会出现,但如果项目里混用了原生 MyBatis,就要检查finally块里有没有sqlSession.close()。另外,@Transactional标记的方法如果内部自己捕获了异常,导致事务拦截器没有走到回滚分支,连接也可能被长时间占用。
连接池打满的另一个原因是maxActive设置太小。不要照抄网上的配置,要根据业务的压测结果来。我之前有一个服务压测时报错,把maxActive从 20 调到 50 以后问题就消失了。但无脑调大也不是好办法,连接数越大,数据库能承受的并发压力未必跟得上,反而可能把数据库打垮。
4.2 数据源切换失效:事务与连接绑定问题
这是动态数据源最常见的失效场景。明明在方法里调了setDataSourceType,执行 SQL 却还是走默认库。原因我在前面已经说过:事务一开始就已经拿了连接,此时切 key 不生效。
排查技巧是打印当前线程的事务同步状态。可以临时在 Dao 里加一行日志:
log.info("datasource={}, synchronizationActive={}", DynamicDataSourceContextHolder.getDataSourceType(), TransactionSynchronizationManager.isSynchronizationActive());如果synchronizationActive=true,说明当前方法已经处于事务同步状态,再切 key 大概率没用。这时候要检查是不是外层方法上的@Transactional传播到了这个内层方法,或者切面的执行顺序不对。
我用的@DataSourceSwitch切面是利用@Order(Ordered.LOWEST_PRECEDENCE - 1)控制顺序的,确保它在事务切面之前执行。这类排序问题不实际踩一次很难记住。
4.3 连接泄漏:工具类必须处理的定时清理
即使代码写得再小心,也难免有某个历史接口漏写了关闭连接。如果 DatasourceUtils 只提供getConnection()却不管理归还,连接池迟早会被挖空。
我在工具类里单独封装了一个withConnection方法,让调用方以回调方式使用连接,避免手动开启和关闭:
public <T> T execute(String dataSourceName, ConnectionCallback<T> callback) { DataSource dataSource = lookup(dataSourceName); try (Connection conn = dataSource.getConnection()) { return callback.doInConnection(conn); } catch (SQLException e) { throw new RuntimeException("Execute SQL error with datasource " + dataSourceName, e); } }这里用 try-with-resources 保证连接必然归还。如果你的项目还停留在老写法,务必检查那些getConnection()后面有没有对应的finally close()。
另外,Druid 的removeAbandoned机制是最后一道防线。我设置为 180 秒强制回收,但注意这个时间不能设置太短,否则一个正常的慢查询事务执行超过 180 秒会被误杀,导致事务中断,業務数据受损。建议结合logAbandoned观察一段时间,把误杀的连接挑出来单独处理。
4.4 配置项速查:我整理的一个小表格
| 配置项 | 推荐值 | 作用 | 需要注意的点 |
|---|---|---|---|
| initialSize | 5 ~ 10 | 连接池启动时创建的初始连接数 | 不是越大越好,启动时会占用数据库资源 |
| minIdle | 5 ~ 10 | 连接池中允许保持空闲的最小连接数 | 低于这个值,连接池会尝试补充 |
| maxActive | 20 ~ 200 | 连接池最大连接数 | 需要配合数据库最大连接数和压测结果 |
| maxWait | 5000 ~ 10000 | 获取连接的最大等待时间,单位毫秒 | 超时后抛异常,而不是无限等待 |
| validationQuery | SELECT 1 | 连接有效性的检查 SQL | 不要用会锁表的查询,也不要省略 |
| timeBetweenEvictionRunsMillis | 60000 | 空闲连接检测的时间间隔 | 配合 testWhileIdle 使用 |
| minEvictableIdleTimeMillis | 300000 | 连接最小空闲时间,超过则可能被回收 | 如果连接被数据库提前断开,需要配合 keepAlive |
| keepAlive | true | 连接空闲时保持活性 | Druid 1.1.16 以上版本建议开启 |
这个表格里的值只能作为起点,不同业务流量、不同数据库规格都会影响最终参数。关键是要有监控,知道每个连接池的实时状态再去调。
5. 一些更深一层的思考
5.1 从工具类到连接管理组件
DatasourceUtils 如果只做数据源创建和获取,还算不上一个完整的连接管理组件。我后来在 DruidUtils 里又加了几个辅助能力,才觉得真正够用。
第一个是配置热刷新。Spring Cloud 场景下,配置中心改了数据库地址,如果数据源已经创建,需要优雅地重建。实现思路是:保留数据源名称对应的配置版本号,配置中心推送新配置后,比较版本号,如果变化则创建新的DruidDataSource,然后替换缓存里的引用,再等待旧数据源的活跃连接归零后关闭旧实例。这个动作必须在低峰期执行,同时要更新AbstractRoutingDataSource的 target 集合。
第二个是数据源可用性探测。我在工具类里加了一个定时任务,每隔 30 秒对核心数据源执行一次validationQuery。如果连续三次失败,就把这个数据源在路由表中标记为不可用。业务层获取连接时会快速失败并抛出带提示的异常,而不是让每个请求都卡在连接池里等几十秒的超时。这个功能在数据库主备切换时尤其有用。
5.2 监控与告警的接入思路
只有参数配置没有监控,工具类就像闭着眼睛开车。Druid 本身提供了StatViewServlet和StatFilter,但直接暴露在公网风险很大。我的做法是只在内网开放监控页,并且通过DruidUtils暴露一个/internal/druid/stat接口,返回连接池的核心指标。
接告警时我会关注四个指标:活跃连接数超过maxActive的 80%、等待获取连接的线程数持续 > 0、活跃连接数一直不降、数据源不可用。前两个指标一般对应连接泄漏或 SQL 问题,第三个可能意味着事务过长,第四个直接触发主备切换通知。
告警阈值不要设得太敏感。连接池的活跃数在业务高峰期本来就会出现波浪形,只要每次都能降到minIdle附近,基本是健康的。频繁误报反而会让团队对告警失去信任。
最后提醒一句
我在实际项目中反复踩过的最大的坑,不是 Druid 的配置参数记不住,而是动态切换和事务的时序问题。如果你现在刚准备封装 DatasourceUtils,我建议第一版本先不要做动态数据源,先把单数据源的创建、缓存、监控做扎实。等业务真的需要多数据源时,再引入AbstractRoutingDataSource,而且要写清楚切换方法的命名规范,比如统一用@DataSourceSwitch("xxx"),不要散在业务代码里手动调setDataSourceType。这些细节现在花点时间想清楚,以后维护成本会小很多。