ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot多数据源切换实战:动态路由DataSource与MyBatis

Spring Boot多数据源切换实战:动态路由DataSource与MyBatis 一个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加一个ThreadLocal2.1 AbstractRoutingDataSource究竟做了什么动态路由方案的基石是Spring提供的AbstractRoutingDataSource。它本身也实现了javax.sql.DataSource但内部不真正持有连接而是维护了一个targetDataSources也就是一个MapKey是数据源名称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.xMyBatis官方starter可以帮我们省掉很多配置活。AOP切面需要aspectjweaverSpring Boot starter里没有默认带需要显式加。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver secondary: jdbc-url: jdbc:mysql://localhost:3306/secondary_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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然后写线程上下文Holderpublic class DataSourceContextHolder { private static final ThreadLocalString 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) { MapObject, 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的SqlSessionFactoryConfiguration 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和SqlSessionFactorySpring 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); }ServiceService 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); } }ControllerRestController 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数据源这块的理解会通透很多。
返回列表