一个Spring Boot项目要同时连两个数据库,我见过太多人栽在这件事上。最常见的做法是照着教程建两个SqlSessionFactory,再让每个Mapper绑定其中一个Factory,结果项目跑起来没几天,就会撞上事务失效、Mapper扫不到、连接串库这一堆烂事。今天这篇就来聊清楚多数据源切换的正经做法:用MyBatis配合Spring Boot的动态路由数据源,在运行时按需切换,而不是在启动时把每个Mapper钉死在一个库上。
如果你正被读写分离、多业务库并存这类的需求困住,或者你已经写出了一个能跑的方案但心里不踏实,这篇文章应该正好能帮上忙。我会从原理讲落地,每一步代码都给你能直接抄走的版本,也会把我在真实项目里踩过的坑一并交代清楚。
1. 为什么你的项目需要多数据源切换
1.1 两个典型的真实场景
第一个场景是读写分离。主库承担写入,从库承担查询,同一个用户表、订单表,在库里结构一模一样。业务希望登录、下单写主库,列表查询走从库,减少主库压力。这种情况下,正常情况下不应该拷贝两套Mapper,而是要同一套Mapper,在方法级别决定它跑哪个库。
第二个场景是多业务库并存。一个服务既要读自己的核心库,又要去读另一个业务系统留下的历史库,两个库的表结构、业务语义完全不同,Mapper自然也是完全独立的两套。这里的关键是,业务方法需要明确知道自己用的是哪个数据源,而不是把一堆Mapper按库分开之后,在Service里手工传Factory。
这两类需求看似不同,但技术上的解法是同一个:在调用Mapper之前,动态地决定当前线程应该使用哪个数据源。这就是“多数据源切换”的本质。
1.2 多SqlSessionFactory方案为什么走不通
我刚接触这个需求时,也尝试过配置两个独立DataSource,两个独立SqlSessionFactory,再给不同的Mapper包单独指定Factory。短期内确实能跑通,但很快会遇到几个问题。
一是事务变得极其难管。Spring的@Transactional只绑定在一个事务管理器上,通常一个DataSource对应一个DataSourceTransactionManager。一旦方法里同时操作两个库,你只能把业务拆成两个事务方法,或者引入跨库事务框架,代码会很拧巴。
二是SQL会话工厂之间切换容易迷路。Service里一会儿要调factoryA的Mapper,一会儿要调factoryB的Mapper,开发的时候脑子还能拎得清,等接手的人来维护,几乎必然出乱子。更别提如果两个库存在同名表,那Mapper的方法名、XML命名空间都得小心翼翼地错开。
三是测试和启动时的隐性坑。多个MybatisAutoConfiguration相互干扰,MapperScan配置稍不对,启动时就报“Invalid bound statement (not found)”。你花半天时间排查,最后发现是两个Factory的mapperLocations没有分干净。
1.3 动态路由方案的基本盘
后来我把方案收敛成一套很清晰的架构:外部只暴露一个DataSource,它内部维护一个“目标数据源Map”,再通过ThreadLocal里的Key在每次获取连接时决定真正走哪个库。这个DataSource叫做路由数据源(RoutingDataSource),它是Spring框架里AbstractRoutingDataSource的经典用法。
这个方案的好处是,MyBatis那边完全不用动,只要有一个SqlSessionFactory绑定到路由DataSource上,所有Mapper共享同一套会话工厂。切换动作发生在数据源层,简单直接,也不影响业务代码的Mapper注入。
我把两种方案放在一个表里对比,方便你判断:
| 对比项 | 多SqlSessionFactory | 动态路由DataSource |
|---|---|---|
| 实现复杂度 | 高,配置分散 | 低,集中在一个类 |
| 事务管理 | 需要多个事务管理器 | 只用一个事务管理器,但要注意事务边界 |
| Mapper管理 | 按Factory拆分 | 一套Mapper即可 |
| 切换灵活性 | 启动时绑定,运行期难改 | 方法级注解,运行期切换 |
| 维护成本 | 高,新人容易踩坑 | 低,核心逻辑集中在少数类 |
所以,后面所有步骤都围绕动态路由方案展开。这也是我目前在实际项目里最推荐的多数据源切换实现方式。
2. 核心原理:一个路由DataSource,加一个ThreadLocal
2.1 AbstractRoutingDataSource究竟做了什么
动态路由方案的基石是Spring提供的AbstractRoutingDataSource。它本身也实现了javax.sql.DataSource,但内部不真正持有连接,而是维护了一个targetDataSources,也就是一个Map,Key是数据源名称,Value是对应的目标DataSource。
每次外部代码调用它的getConnection()时,它会先调用一个抽象方法determineCurrentLookupKey()拿到当前应该用的Key,然后从Map里取出对应的目标DataSource,再调用目标DataSource的getConnection()返回连接。
这就好比一个前台接待员,你告诉他“我要找财务部的老王”,他不是自己要办业务,而是去财务部把老王叫出来。确定“老王”还是“小李”的逻辑,就是我们自己实现的那个determineCurrentLookupKey()方法。
2.2 ThreadLocal如何保证线程隔离
determineCurrentLookupKey()里要决定Key,最简单可靠的方案是维护一个ThreadLocal变量。每个线程的ThreadLocal是独立的,我们可以通过set()把当前线程要用的数据源Key放进去,再通过get()取出来。这样,请求A设置“primary”,请求B设置“secondary”,互相完全隔离,谁也不会串线。
这个思路非常直观:你要在同一时间安全地给不同的请求分配不同的数据库连接,就必须让这个“分配标志”跟着请求所在的线程走,而不是搞成一个全局变量。全局变量一旦被某个请求改掉,其他所有请求都会跟着遭殃,这在多数据源场景下是灾难。
2.3 注解加AOP是切换开关的最佳组合
有了ThreadLocal,我们还需要一个优雅的入口来设置它。最简单的做法是在每个Service方法第一行手动调DataSourceContextHolder.setDataSource("secondary"),最后在finally里clear()。但这样会写大量重复代码,而且一旦忘记在异常路径清除,就会污染线程上下文,后续请求可能继续用错库。
我推荐用自定义注解加Spring AOP做切换开关。方法上标一个@DataSource("secondary"),切面在方法执行前把值set进ThreadLocal,方法结束后无论是否抛异常都clear。代码清晰,而且可读性极强。尤其是以后维护的时候,看一眼注解就知道这个方法走哪个库,比翻一堆set/reset代码高效得多。
3. 完整实现:从pom到Controller的每一步
3.1 引入依赖
先看Maven依赖。我这里用的是Spring Boot 2.7.x加MyBatis 3.5.x,MyBatis官方starter可以帮我们省掉很多配置活。AOP切面需要aspectjweaver,Spring Boot starter里没有默认带,需要显式加。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>如果你是Spring Boot 3.x,需要把MyBatis starter版本换成3.0以上,并且使用com.mysql:mysql-connector-j,但核心代码的逻辑完全一样。
3.2 数据源配置:url还是jdbc-url,必须说清楚
在application.yml里配置两个数据源。这里有个常见坑:Spring Boot 2.x的单数据源配置可以直接写spring.datasource.url,但多数据源下我坚持要求每一个数据源使用jdbc-url,因为这个属性才能被HikariDataSource的DataSourceBuilder正确识别,避免某些情况下自动绑定不到导致启动报错或者连接串乱。
spring: main: allow-bean-definition-overriding: true datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/primary_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver secondary: jdbc-url: jdbc:mysql://localhost:3306/secondary_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driverallow-bean-definition-overriding这个配置在自定义DataSource后很重要,因为Spring Boot自动配置也可能创建DataSource,同类型的Bean可能冲突,提前打开覆盖权限可以减少很多莫名其妙的问题。
3.3 上下文Holder与路由DataSource
然后写线程上下文Holder:
public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSource(String dataSourceKey) { CONTEXT.set(dataSourceKey); } public static String getDataSourceKey() { return CONTEXT.get(); } public static void clearDataSource() { CONTEXT.remove(); } }注意一定是remove()而不是简单的set(null),因为set(null)之后ThreadLocal里仍然存在Entry,在某些容器环境下可能造成内存弱引用问题。每次都remove干净最稳妥。
接着是自定义路由数据源:
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceKey(); } }这段代码短,但它是整个多数据源切换的心脏。决定返回null时,使用默认数据源;返回其他Key时,从Map中找对应的目标数据源。
3.4 自定义注解与AOP切面
自定义注解:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DataSource { String value() default "primary"; }AOP切面:
@Aspect @Component public class DataSourceAspect { @Around("@annotation(dataSource)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DataSource dataSource) throws Throwable { DataSourceContextHolder.setDataSource(dataSource.value()); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clearDataSource(); } } }这里切点表达式直接用了@annotation(dataSource),要求方法上必须标注@DataSource。我建议优先放在方法上而不是类上,因为类级别的注解会让此类所有方法都走同一个数据源,显得不够灵活。如果确实整个类都用一个源,那么放在类上也没问题,但注意AOP切面的表达式要同时支持两种写法,网上很多例子里把切点写成@within(dataSource)或复杂组合,维护起来反而麻烦。
3.5 数据源与MyBatis装配
下面是配置类。首选我们创建两个真实的DataSource Bean,然后把它们放进DynamicDataSource的Map里。
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.secondary") public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } @Bean @Primary public DynamicDataSource dynamicDataSource( DataSource primaryDataSource, DataSource secondaryDataSource) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("primary", primaryDataSource); targetDataSources.put("secondary", secondaryDataSource); DynamicDataSource dynamicDataSource = new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(primaryDataSource); return dynamicDataSource; } }注意@Primary不能少。Spring Boot在进行自动配置时,如果存在多个DataSource类型的Bean,它会优先选择标@Primary的那个注入到其他组件中。MyBatis的SqlSessionFactory需要拿到DataSource,如果不标,启动时就会因为“expected single matching bean but found 2”直接失败。
然后配置MyBatis的SqlSessionFactory:
@Configuration @MapperScan(basePackages = "com.example.demo.mapper") public class MyBatisConfig { @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean(); sessionFactoryBean.setDataSource(dataSource); sessionFactoryBean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath*:mapper/**/*.xml")); return sessionFactoryBean.getObject(); } @Bean public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } }这里的核心是@MapperScan只写一个包路径,所有Mapper都归这一个SqlSessionFactory管理,动态切换数据源发生在DataSource层面,所以不需要担心Mapper“去了错误的Factory”。
3.6 启动类排除自动配置
启动类这里有一个微妙的地方:我们手动定义了DataSource和SqlSessionFactory,Spring Boot的DataSourceAutoConfiguration和MybatisAutoConfiguration如果不排除,会再叠加初始化一套,可能引发冲突。我的做法是直接排除:
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, MybatisAutoConfiguration.class }) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }排除后,数据源相关的一切都由我们的配置类接管。这是很多人忽略的一步,如果不排除,最典型的症状是启动后sqlSessionFactory被重复创建,或者在日志里看到一些奇怪的DataSource初始化顺序问题。
3.7 Service和Controller验证
写一个简单的Service用来验证:
public class User { private Long id; private String name; // 省略 getter / setter }Mapper接口:
public interface UserMapper { @Select("select * from user where id = #{id}") User selectById(@Param("id") Long id); }Service:
@Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper = userMapper; } @DataSource("primary") public User getPrimaryUser(Long id) { return userMapper.selectById(id); } @DataSource("secondary") public User getSecondaryUser(Long id) { return userMapper.selectById(id); } }Controller:
@RestController @RequestMapping("/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/primary") public User primary(@RequestParam Long id) { return userService.getPrimaryUser(id); } @GetMapping("/secondary") public User secondary(@RequestParam Long id) { return userService.getSecondaryUser(id); } }如果你在primary_db和secondary_db里都建了同样的user表,分别插入不同id的数据,调用两个接口就能看到不同库的查询结果。到这个程度,一个可用的多数据源切换功能就完成了。
4. 实测踩坑:切换失效和数据串线的排查链路
4.1 事务一旦把DataSource钉死,切换就成了空谈
这个坑几乎是多数据源切换里最隐蔽也最严重的。Spring的@Transactional会在进入方法时从DataSource拿到一个连接,并把它绑定到当前线程的事务资源里,后续所有数据库操作都复用这个连接,压根不会再走DynamicDataSource的determineCurrentLookupKey()。
所以如果你在Service方法上同时标了@Transactional和@DataSource("secondary"),外面看可能正常,实际上查询依然走的是事务管理器初始化时默认绑定的数据源。更麻烦的是,假如一个事务方法内部调用了两个不同数据源的方法,第二个方法就算切了ThreadLocal,拿到的连接还是第一个库的连接,既不会报错也不会切换,静默得让人抓狂。
我的建议是:尽量让多数据源切换发生在事务边界内部的第一层,或者干脆不要在需要切换数据源的方法上加事务。如果业务确实需要事务,那就得接受“单事务单数据源”的现实,通过拆方法、调整传播行为来迂回解决。跨库的事,放到后面“分布式事务”部分说。
4.2 Mapper扫描冲突和XML漏加载
很多人在自定义了SqlSessionFactory之后,启动时发现Mapper接口里定义的SQL一直报Invalid bound statement。这时候几乎可以断定是mapperLocations没有配置对。因为我上面写的配置里的classpath*:mapper/**/*.xml会和项目里的实际目录对应,如果你的XML不是放在src/main/resources/mapper下,就需要同步修改。
另外,注意不要在启动类或者配置类上重复叠加多个@MapperScan。例如排查时看到一个@MapperScan("com.example.mapper"),又有一个自定义MyBatisConfig里的@MapperScan("com.example.mapper"),这种重复扫描往往会引起MapperFactoryBean注册冲突。保持一个入口,用起来最安心。
4.3 ThreadLocal在异步线程里根本传不过去
当Service方法使用@Async,或者内部通过ExecutorService提交任务时,子线程的ThreadLocal不会继承父线程的值。也就是说,父方法里虽然设置了@DataSource("secondary"),但异步线程执行Mapper查询时,DataSourceContextHolder.getDataSourceKey()返回null,最终走了默认库。
这个问题有几个常见处理方式。一是把数据源Key作为参数显式传给异步方法内部,在方法开头手动setThreadLocal。二是使用阿里开源的TransmittableThreadLocal,它可以在线程池任务提交时自动把父线程的上下文传递过去,但需要额外引入依赖。最稳妥的其实是:异步任务不依赖隐式上下文,把库的标识直接作为参数自定义上下传递,逻辑更透明。
4.4 注解不生效的代理陷阱
AOP基于代理,所以@DataSource必须作用在一个从Spring容器里被外部调用的Bean方法上,才能被切面拦截。如果同一个Service内部另一个方法调用this.getPrimaryUser(),AOP切面不生效,因为this是原始对象,不是代理对象。典型表现就是:明明方法上有@DataSource("secondary"),还是走默认主库。
解决方式不外乎三种:把切换方法拆到另一个ServiceImpl里,让容器代理去调;或者注入自身的代理对象;再或者用AopContext.currentProxy(),但要注意设置@EnableAspectJAutoProxy(exposeProxy = true)。我个人更推荐拆方法,代码干净,也符合单一职责。
4.5 连接池实例重复导致“切了等于没切”
还有一个我排查了很久的坑:ThreadLocal里确实设置了“secondary”,DynamicDataSource也返回了secondary对应的DataSource,但查询出来的数据还是primary库的。最后发现是DataSourceConfig里primary和secondary两个DataSource Bean都用了同一个Druid连接池实例,或者说DataSourceBuilder构建时不小心把两个数据源指向了同一个jdbc-url。
检查方法很简单,给两个DataSource配置不同的库端口,或者打印primaryDataSource.getConnection().getMetaData().getURL()对比一下。动态路由的核心是每个目标数据源是独立的连接池,一旦实例串了,路由Key再正确也没有意义。
5. 进阶思路:读写分离、成熟框架和跨库事务
5.1 从多库切换平滑过渡到读写分离
多数据源切换方案稍微扩展,就是一套轻量的读写分离。你只需要把主库和从库分别注册到DynamicDataSource的Map里,再约定好写操作默认走“write”,读操作走“read”。最简单的做法是定义两个注解@Write和@Read,或者继续用@DataSource("write")和@DataSource("read"),在AOP切面里再根据方法名或自定义注解自动决定。
如果你有多个从库,需要做简单的负载均衡,可以在DynamicDataSource里加入一个轮询(RoundRobin)逻辑,每次选择下一个从库作为当前Key。但这一步要小心,不要在事务里负载均衡,否则可能读到旧数据,影响一致性。
我的经验是:读多写少的项目,用这种方法能撑住初期流量。等从库数量变多、数据同步延迟开始成为问题,再升级到ShardingSphere这类更专业的分库分表中间件也不迟。
5.2 不想手写?MyBatis-Plus也有现成方案
如果你用的是MyBatis-Plus,那有一个非常成熟的动态数据源扩展:dynamic-datasource-spring-boot-starter。它内部已经封装了多数据源、读写分离、事务集成等功能,对外提供@DS注解,用法和本文的自定义方案几乎一致。
它的好处是少写很多代码,也帮你处理了不少边界情况,但缺点是你需要额外引入依赖,并且对底层数据源配置有自己的约定。我遇到过一些团队在使用了这个starter之后,出了问题无法排查,因为他们并不理解底层AbstractRoutingDataSource的机制,最后回到手动实现。所以我的观点是:亲手撸一遍本文这套代码,再去用现成框架,心里有底,排查问题也能迅速定位。
5.3 跨库事务要面对的现实
多数据源切换本身不解决跨库事务问题。一个事务里同时更新主库和从库,或者同时写两个业务库,如果发生异常,Spring的本地事务管理器只能保证单个DataSource范围内回滚。另一个库可能已经提交了一半数据,这就产生了数据不一致。
在微服务架构下,跨库事务的问题本质上是分布式事务问题。常见的方案包括两阶段提交(XA)、AT模式的Seata、TCC或可靠消息最终一致性。但这些方案都复杂,有性能代价,不是每个项目都值得引入。
我的原则是:先用数据建模和接口设计避免跨库写操作。比如把一个业务流程拆成两步,两个库各写各的,中间通过消息队列异步推进。如果确实绕不开强一致,才考虑Seata这样的完整方案。不要让一个简单的多数据源切换任务,最后演变成分布式事务大工程,那是过度设计。
回到最初的问题,动态路由DataSource这套方案是我在实际项目里反复验证过,稳定且容易维护的。先用ThreadLocal管住切换Key,用AbstractRoutingDataSource管住连接选择,再用注解和AOP把入口做干净。后面无论你是要接读写分离,还是接MyBatis-Plus的现成框架,底层思路都一样。我建议你照着上面的代码自己启动一个项目试试,然后把事务和异步那几个坑故意踩一遍。踩过之后,你对Spring Boot数据源这块的理解会通透很多。