曾经有个线上小事故让我印象很深:业务库的读流量越来越大,一张报表表把从库连接池全部占满,DBA半夜打电话说主库CPU也飙了。查了一圈,根因是项目里写的多个数据库工具类各自new了DataSource,代码里到处硬编码切库,事务一开连接就被钉死在某个库上。那次之后我把项目里所有数据源方案重写了一遍,这也是今天这篇东西的来由。
这篇文章聚焦Spring Boot项目里多数据源的连接管理和动态切换。我会先用真实项目视角讲清楚哪些场景真的需要多数据源,再拆解AbstractRoutingDataSource的底层切换机制,然后把一套可落地的注解+切面动态切换方案完整写出来,最后把MyBatis-Plus、事务、连接池这几个重灾区逐个排掉。适合正在做多库接入、读写分离改造,或者面试前想把这个点彻底吃透的Java开发。
1. 先搞清楚:哪些场景真的需要搞多数据源
很多新人一听到"多数据源"就想到搞一个高大上的动态切换框架。但我见过太多项目其实是被迫上了多数据源,结果引入了一堆分布式事务、连接管理、配置同步的麻烦。所以我先把决策前置:别急着写代码,先对号入座。
1.1 真实存在的三类业务场景
多数据源不是设计模式层面上的炫技,而是被业务逼出来的,常见的有这么几类:
- 读写分离(最常见的单业务库拆分):主库负责写,从库负责读。这种场景下数据源往往是同一个MySQL实例集群的不同节点或不同账号,表结构完全一样。核心痛点是读多写少,需要把查询流量导到从库。
- 业务分库:比如订单库、用户库、库存库,它们属于同一个业务域,但被强制拆到不同库。原因是单实例的库撑不住全量数据,或者是团队/运维边界强制拆分。这种场景往往还伴随跨库事务问题,难度会上一个台阶。
- 异构数据源接入:比如业务库是MySQL,但有一部分数据要从Oracle或PostgreSQL同步过来,或者需要直接查询数仓里的ClickHouse。这种情况常见于数据中台、报表系统,数据模型完全不同。
1.2 你以为需要、但可能不需要的情况
还有一种典型情况:微服务推荐拆库,但并不是所有系统都要在代码里做多数据源。如果你只是做报表查询,完全可以走独立的数据服务API,或者用数据同步工具把数据汇聚到一个只读库里再查。直接在代码里连接一堆业务库,容易变成一张巨大的蜘蛛网。
另外,如果你的目标是横向扩展数据库容量,优先考虑的是缓存、分库分表中间件(比如ShardingSphere),而不是在应用层维护一个"库列表"再自己路由。应用层动态切换更适合"少量数据源、规则明确"的场景。超过几十个数据源还靠动态切换框架硬扛,连接管理、配置下发、故障隔离都会很难受。
我在实际项目中判断是否值得用多数据源,就三条标准:数据源数量可控(个位数到十几个)、切换规则可以由当前请求上下文确定(比如按用户、按租户、按读写类型)、以及业务上能接受跨库事务自己兜底。如果三条里有一条不满足,我会马上劝团队换方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据源切换的底层原理:连接级的路由机制
很多教程一上来就贴代码,告诉你用AbstractRoutingDataSource,但没讲清楚它为什么能实现动态切换。这个类我用了几年,越用越觉得它的设计思想值得写一段:本质上它是在连接获取层做了一层路由代理。
2.1 JDBC连接的获取路径
一个普通的Java程序拿到数据库连接的过程大概是:通过DriverManager.getConnection(url, username, password)拿到物理连接。Spring里我们不会直接操作DriverManager,而是配置一个DataSource,它内部维护了一个连接池,当你要操作数据库时,从池里getConnection()取一个物理连接。
Spring的DataSource在大部分框架里都是一个单例bean。MyBatis执行SQL时会从SqlSessionFactory绑定的DataSource去获取连接。也就是说,对MyBatis来说,它认的是"哪个DataSource",而不是"连接字符串是什么"。
动态数据源的关键突破口就在这个"认DataSource"的环节:如果我们能提供一个DataSource,让它在getConnection()时根据当前线程的上下文动态决定返回哪个真实库的连接,那下游的MyBatis、JdbcTemplate完全无感知。
2.2 AbstractRoutingDataSource的工作方式
Spring的org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource恰恰设计成了这个角色。它本身持有一组目标数据源(用key区分),核心逻辑是抽象方法determineCurrentLookupKey()返回一个key,getConnection()内部会用这个key去目标数据源Map里取真正的DataSource。
说得直白点,你可以把它当做一个"路由器":
java复制public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceKey();
}
}
DataSourceContextHolder存储当前线程应该用哪个库(用ThreadLocal实现)。当业务代码调用了"切换到库A",后续在同一线程内的getConnection()就会拿到库A的连接,只要没有切回来,整个线程期间所有数据库操作都落在库A上。
这里有个很重要的设计含义:切换的粒度是线程级别的"当前上下文"。所以事务边界、线程池、异步任务这些和线程相关的东西,最终都会干扰切换结果。后面我会用专门一节讲这些坑。
2.3 为什么不能直接用两个DataSource然后在Service里选
有人可能会问:我不搞代理路由,我直接定义两个DataSource,在Service里通过@Qualifier选择不同的JdbcTemplate或SqlSessionTemplate不就行了?
这个思路在极少数只查一两次的场景是可行的,但它有严重缺陷:
- 侵入性非常强:每段代码都要显式指定用哪个库,业务代码被数据源选择逻辑淹没。
- 事务无法跨库控制:
@Transactional默认绑定单个DataSource上产生的事务管理器,如果你在方法内切换了底层数据源,事务管理器并不知道,连接归属就会错乱。 - 难以应对动态规则:如果切库逻辑来源于请求参数/租户标识,就得在编码层到处传递,可维护性断崖式下跌。
正是因为这些痛点,业界主流的轻量方案才都收敛到"基于AbstractRoutingDataSource的动态路由"上。至于更重的ShardingSphere,也不是不用它,而是它的职责范围更大(分片、分布式事务、读写分离),对项目改动面也更大。很多中小项目只是为了多接一个库,没必要直接把中间件搬进来。
2.4 AbstractRoutingDataSource的懒初始化与连接池迁移
还有个容易忽略的细节:AbstractRoutingDataSource在初始化时并不会立刻创建所有目标数据源的物理连接。默认情况下,它是在目标数据源被第一次路由命中后才去初始化目标数据源对应的连接池。在afterPropertiesSet()里,它只是把targetDataSources的Map保存到resolvedDataSources里,真正的连接池构建由各DataSource实现类自己完成。如果你用的是HikariCP,通常需要等到第一次getConnection()时才触发连接池的创建和预热。
于是你能看到一种现象:应用启动后,动态数据源路由到A库,B库的数据源显示还没有连接。这本身不是故障,但如果某个库在流量突增时才第一次被打到,首次创建连接池会造成初始化的尖刺延迟。对这个问题我的处理方式是在启动完成后主动做一次针对各数据源的连接预热探测,比如用一个只读查询来触发各连接池初始化。
3. 一套可落地的动态多数据源实现方案
下面给出一个我多次在项目里使用的通用实现,不依赖具体ORM或框架组件,Spring Boot + MyBatis(Plus) + HikariCP都能适配。设计目标:开箱即用、注解驱动、支持服务启动时与运行中动态切换。
3.1 依赖与基础配置
这里以Spring Boot 2.7为例,数据库驱
