我做过不少带读写分离、多租户、分库分表需求的SpringBoot项目,发现很多人在多数据源这块踩坑踩得莫名其妙。今天不聊那种“复制两个DataSource然后配两个SqlSessionFactory”的笨办法,而是把真正在工程里落地、能扛住生产压力的多数据源方案拆开揉碎讲一遍,从方案选型到动态切换原理,再到事务、连接池这些容易翻车的细节,全部用实际代码和经验说话。
1. 多数据源方案选型与核心思路拆解
1.1 为什么会有多数据源需求
先说个最常见的场景:公司早期一套系统打天下,订单、用户、商品、日志全在同一个MySQL库里。业务跑到一定规模,DBA就会来找你——主库写入压力太大,把查询拆出去;或者合规要求日志数据保留180天,不能跟业务库混在一起;又或者你接了个第三方系统,对方只给你只读库的访问权限。
这时候“多数据源”就不是锦上添花,而是硬需求。SpringBoot默认只配置一个DataSource,要让多个数据源共存、还能按需切换,看起来简单,做起来有一堆坑。
常见的实现思路有三种:第一种是分包方式,每个数据源配一套独立的SqlSessionFactory,Mapper接口按包名隔离。第二种是用第三方框架,比如MyBatis-Plus的Dynamic-Datasource、ShardingSphere。第三种是自己基于Spring自带的AbstractRoutingDataSource做动态切换。
我自己在中小型项目里最常用的是第三种,原因很简单:不引入额外的重量级依赖,基于Spring原生机制,出问题排查起来思路清晰,而且能完全掌控切换逻辑。
1.2 三种实现方案的优势与硬伤
分包方式其实最不推荐在业务代码里大范围使用。虽然它配置上最直观——每个数据源一套配置类、一套Mapper包——但代价是业务代码里你要在Service层明确指定调哪个Mapper,换数据源等于改调用代码,侵入性太强。而且Mapperscan扫描多个包时,SpringBoot自动配置经常跟你打架,你得想尽办法把自动配置排除掉,维护成本立刻上来了。
MyBatis-Plus的Dynamic-Datasource这种框架级方案确实好用,注解一加就能切库,事务和切换顺序的问题它内部都处理了。但对不依赖MyBatis-Plus的项目强行引入它,多少有点引入不必要的复杂度。而且框架封装度高,遇到框架本身没覆盖的边界情况,你得去翻它的源码才能知道问题出在哪。
AbstractRoutingDataSource这个方案的核心价值在于:它把数据源路由逻辑抽象成了一个“中转器”。我们往Spring容器里注册一个自定义的DataSource,它内部维护一个数据源Map,每次调用getConnection()时,根据当前线程的上下文信息决定到底用哪个真实数据源。这个思路清晰,切换容器的核心机制注意细节,完全够用。
1.3 核心设计思路:一个中转路由+一个上下文
我先画个思路草图给你看。整体结构其实就三块:
- 路由数据源:继承AbstractRoutingDataSource,重写determineCurrentLookupKey()方法,返回当前线程保存的数据源标识
- 数据源上下文:用一个ThreadLocal保存当前线程应该用哪个数据源
- 切换切面:通过AOP或者手动调用,在进入Service方法前把数据源标识写进ThreadLocal,方法结束后清理
这个设计的好处从底层就决定了它的可靠性:连接从哪个数据源来、SQL发到哪个库去,都是在你调用getConnection()的时候才定下来的。也就是说,只要在业务方法执行期间,ThreadLocal里的值是对的,连接就一定是连到对的库。
2. 核心细节解析与实操要点
2.1 环境准备与依赖版本适配
在动手写代码之前,我建议先把依赖版本框定清楚。不同SpringBoot版本在自动配置类上的行为有差异,直接照抄网上教程容易翻车。这里给一套我实测过很多次的组合:
- JDK 1.8或更高(SpringBoot 2.x建议1.8,SpringBoot 3.x需要17+)
- Maven 3.6+
- MySQL 8.x(5.7也兼容)
- SpringBoot 2.3.x~2.7.x(这个区间内代码几乎无差异)
pom.xml里必加的依赖只有两个基础的:spring-boot-starter-web(或其他业务starter)和spring-boot-starter-jdbc。数据库驱动、连接池、ORM框架按你自己项目的实际情况来。这里插一句,连接池不需要单独引入HikariCP依赖,SpringBoot 2.x默认用的就是HikariCP,直接用即可。
2.2 多数据源基础配置
在application.yml中,构造两个基础数据源。多数据源的配置项和单数据源完全一致,注意url、username、password分清楚就行:
spring: datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/business_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 30000 connection-timeout: 30000 pool-name: PrimaryHikariPool secondary: jdbc-url: jdbc:mysql://localhost:3306/log_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 3 maximum-pool-size: 10 idle-timeout: 30000 connection-timeout: 30000 pool-name: SecondaryHikariPool注意这里用了jdbc-url而不是url。在SpringBoot 2.x中,如果你把HikariCP配成第二数据源,必须用jdbc-url,否则HikariCP会报“无法解析url”之类的异常。这个坑非常隐蔽,我第一次踩的时候查了半小时才发现问题。
另外提醒一点:这两个配置节点建议放在自定义的前缀下,比如app.datasource.primary和app.datasource.secondary,不要直接写在spring.datasource.primary下面。因为spring.datasource.*默认会被SpringBoot的自动配置扫到,容易引发“只需要一个数据源但莫名多出来一个”的问题。代码里用@ConfigurationProperties指定前缀读取即可。
2.3 数据源标识枚举与上下文实现
这一步很关键:定义数据源枚举和上下文。枚举的含义是让代码里的每一个数据源都有一个明确的代号,避免到处写魔法字符串。正常的多数据源项目里就两个库,直接写两个枚举项足够,多了的话继续往下加:
public enum DataSourceType { PRIMARY, SECONDARY }上下文类的核心是ThreadLocal。为什么必须用ThreadLocal?因为在一个请求到达后,SpringMVC默认是一个线程处理的,后续的Service层、Mapper层调用都是在同一个线程栈里。用ThreadLocal保存当前数据源标识,在这个线程内部随处可读,请求处理完再把值清理掉,不会串到下一个请求去。
public class DataSourceContextHolder { private static final ThreadLocal<DataSourceType> CONTEXT = new ThreadLocal<>(); public static void setDataSource(DataSourceType type) { CONTEXT.set(type); } public static DataSourceType getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }这里我一开始用的new ThreadLocal<>(),后来线上环境遇到一次诡异问题:某些线程池复用的场景下,线程执行完任务后ThreadLocal没有清干净,导致下一个任务拿到上一个任务残留的数据源标识。排查半天才发现是清理逻辑漏了。所以remove()不只是代码规范问题,是生产环境必须做的事。
2.4 动态路由数据源的实现
接着是路由数据源,继承AbstractRoutingDataSource并重写determineCurrentLookupKey()。Spring在每次获取连接时都会调用这个方法,把它返回的值当作key,去内部的targetDataSources里查对应的真实数据源:
public class DynamicDataSource extends AbstractRoutingDataSource { public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) { super.setDefaultTargetDataSource(defaultDataSource); super.setTargetDataSources(targetDataSources); } @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }这里有一个隐藏的关键点:setTargetDataSources()之后必须调用afterPropertiesSet(),否则内部的数据源Map不会初始化,运行时会报IllegalStateException。如果你用的是构造器注入再加@Bean注册的传统方式,Spring容器启动时会帮你调一次afterPropertiesSet(),问题不大。如果你是手动new DynamicDataSource()然后自己塞数据源,千万记得手动调一次。
2.5 数据源配置类注册
然后写配置类,把上面这些组装起来:
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "app.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties(prefix = "app.datasource.secondary") public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } @Bean @Primary public DynamicDataSource dynamicDataSource( @Qualifier("primaryDataSource") DataSource primary, @Qualifier("secondaryDataSource") DataSource secondary) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put(DataSourceType.PRIMARY, primary); targetDataSources.put(DataSourceType.SECONDARY, secondary); return new DynamicDataSource(primary, targetDataSources); } }这里必须注意:@Primary注解一定标在DynamicDataSource这个Bean上,不能标在primaryDataSource上。因为Spring容器里现在有多个DataSource类型的Bean,SpringBoot自动配置会挑一个作为主数据源注入到JdbcTemplate、MyBatis这些组件里去。如果不加@Primary,启动时会直接报NoUniqueBeanDefinitionException;错标到物理数据源上,虽然能启动,但路由功能实际是失效的——所有请求都走固定那个库。
2.6 动态切换的实现策略
理论上讲,到这一步已经可以在业务代码里手动切换了:
DataSourceContextHolder.setDataSource(DataSourceType.SECONDARY); // 执行查询 DataSourceContextHolder.clear();但手写太容易漏清理了。我更推荐用自定义注解加AOP切面,把切换逻辑从业务代码里剥离出来,调用方只需要加一个@DS注解即可。
首先定义一个注解:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { DataSourceType value() default DataSourceType.PRIMARY; }然后写切面:
@Aspect @Component public class DataSourceAspect { @Around("@annotation(ds) || @within(ds)") public Object around(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DataSourceType original = DataSourceContextHolder.getDataSource(); try { DataSourceContextHolder.setDataSource(ds.value()); return joinPoint.proceed(); } finally { DataSourceContextHolder.setDataSource(original); } } }这个切面比最简单的“设置完直接clear”要稳妥。在设计里,如果方法内部又调了另一个带@DS注解的方法,内层数据源会覆盖外层设置,等内层执行完恢复成外层的数据源标识,不会造成上下文丢失。而直接clear会把外层的数据源标识也干掉,后续代码如果有切换需求就会错乱。很多时候又需要恢复原值,多留这个“保存原值”的逻辑没坏处。
如果用的是SpringBoot 2.x,切面依赖的spring-boot-starter-aop要单独引入。我见过不少项目忘了加这个依赖,结果切面完全没生效,注解加了跟没加一样,数据源切换静默失败。这个问题后面排障部分还会细说。
3. 实操过程与核心环节实现
3.1 MyBatis或JdbcTemplate如何与动态数据源配合
ORM框架配置这步很灵活。最简单的方式是完全不额外配置,让SpringBoot自动配置去找容器里的DataSource。因为我们已经把DynamicDataSource标记为@Primary了,MyBatis和JdbcTemplate自动注入的就是这个中转数据源,SQL执行时它会自动路由。
在MyBatis场景下,如果用的是mybatis-spring-boot-starter,基础配置写好后直接启动,连SqlSessionFactory都不需要手动声明。写代码的时候,操作业务库的Mapper和操作日志库的Mapper可以放在同一个SQL文件里,也可以分开目录,不影响路由。
JdbcTemplate同理,注入后直接使用,底层拿到的连接就是动态路由过的。有些项目里用了JdbcTemplate还想要事务,那是后面第3.3节要特别讲的内容,因为路由事务是最大的坑点。
这里给一个配套的MyBatis配置参考:
mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entitymapper-locations不用区分数据源路径,因为路由是基于运行时上下文而非编译期扫描来决定的,一个扫描路径完全够用。
3.2 一个完整的业务切换样例
假设现在有个需求:从主库查用户信息,从日志库查操作日志。我建一个Service:
@Service public class OrderService { private final UserMapper userMapper; private final LogMapper logMapper; public OrderService(UserMapper userMapper, LogMapper logMapper) { this.userMapper = userMapper; this.logMapper = logMapper; } @DS(DataSourceType.PRIMARY) public User getUserDetail(Long userId) { return userMapper.selectById(userId); } @DS(DataSourceType.SECONDARY) public List<OperateLog> listLogs(Long userId) { return logMapper.selectByUserId(userId); } }在Controller里调用:
@RestController public class DemoController { private final OrderService orderService; public DemoController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/user/{id}") public Result getUserWithLogs(@PathVariable Long id) { User user = orderService.getUserDetail(id); List<OperateLog> logs = orderService.listLogs(id); return Result.ok(user, logs); } }这里因为Controller调用了两个不同数据源上的方法,每次方法进入时切面都会设置对应的数据源,执行完又恢复成空或原值,所以两次查询各自路由到正确的库。上面这段代码看起来简单,但它为什么能跑通,依赖的就是AOP切面在方法进入前设置、退出后恢复这个时序逻辑。
如果这两个查询是在同一个方法里连着执行的,务必要注意切面的粒度。比如把两个查询写到一个@DS(DataSourceType.PRIMARY)注解的方法里,日志库那个查询就会打到主库去,报“表不存在”的错误。这种问题最坑的是它不一定报错——如果你两个库的表结构一样,数据写错了才是最麻烦的。
3.3 事务与多数据源冲突解析
事务这块是多数据源方案里最容易出问题的地方,我把常见约束和解决方案分开说。
先理解Spring事务的机制:@Transactional是基于AOP的,它会在目标方法执行前,从容器中拿一个DataSource,通过DataSource获取Connection,然后开启事务。关键就在这里:事务一旦开启,整个事务期间用的都是同一个Connection,路由逻辑只有在首次获取连接时执行一次。如果你在事务方法内部去切换数据源,即使ThreadLocal的值变了,事务管理器手里的Connection还是老的那个,切了跟没切一样。
具体症状是第一种:在@Transactional方法里调用带@DS注解的另一个方法,数据源切换不生效,SQL还是打到之前那个库。第二种:两个库都参与了事务操作,但没有跨库事务的保障,要么不报错但数据写得不一致,要么报错但只回滚一个库。
我自己在项目里的对策是这样:
首先是约束:不在同一段事务代码里操作两个数据源。业务上还能够接受“日志写入独立提交”这种设计的话,就把日志库的调用放到事务方法外面,或者把日志表的写入做成独立事务。
其次是如果你确实需要在同一个Service方法里操作两个库,建议把主数据源的操作放在事务方法里,从库的操作放到不带事务的方法里,通过编程式事务或独立Service传播机制来控制。
代码层面还有一个终极解法:继承AbstractRoutingDataSource配合TransactionAwareDataSourceProxy。这个类是Spring提供的一个代理DataSource,它能感知当前线程是否存在活跃事务,并在事务存在时返回同一个绑定连接。同时需要在事务管理器里使用路由数据源的代理。但这个方案复杂度比较高,一般中小型项目用不上,真到了需要这一步,更建议直接用ShardingSphere或者考虑改库表设计。
3.4 配置与启动的完整验证流程
配置完所有类,可以直接启动项目做一轮验证。我习惯按这个顺序走:
第一步验证启动无异常。启动日志里如果看到Spring自动配置的DataSource被我们的DynamicDataSource替换掉,说明@Primary生效了。界面上观察HikariCP的启动日志,两个连接池会各自等待初始化。
第二步验证主库路由。不调用任何带@DS的方法,默认情况下determineCurrentLookupKey()返回的入路由数据源里的默认数据源,SQL会打到主库。可以在主库执行一条查询加入日志的方式验证,或者干脆故意在从库不建这张表,看看是否报表不存在的错。
第三步验证从库路由。用带@DS(DataSourceType.SECONDARY)的方法查一次日志库,在日志库执行SHOW PROCESSLIST看是否有来自应用侧的连接。
整个验证过程中我强烈建议把数据源名称、当前线程名打出来:
logging: level: com.zaxxer.hikari: INFO com.example.demo: DEBUG这样排查问题时有迹可循。我已经不止一次遇到“切到从库但没生效”的问题,最后一看日志,SQL全在主库执行了,原因无非是切面没生效或者事务把连接绑死了。
4. 常见问题与排查技巧实录
4.1 循环依赖和数据源冲突问题
启动的时候报The dependencies of some of the beans in the application context form a cycle这类错误,通常是动态数据源和SpringBoot自动配置之间互相依赖导致的。
解决方法是关闭DataSource自动配置,或者在配置类上做排除。在SpringBoot 2.x中,比较优雅的方式是:
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)排除了自动配置后,我们注册的DynamicDataSource就是容器里唯一的DataSource,JdbcTemplate和MyBatis都会直接用这个路由数据源。这是我在多数据源项目里最推荐的方式,简单粗暴,能杜绝大量“自动帮你配了个数据源”的意外。
4.2 切面不生效的排查
前面提到过忘了引入spring-boot-starter-aop会导致切面不生效。实际表现是:项目能正常启动,所有接口也能访问,但该切到从库的查询全打到主库去了,控制台没有任何提示。
排查方法很简单:在切面around方法里加一行日志,看有没有输出。没有输出,说明切面根本没被Spring管理,要么是依赖缺失,要么是切面类没有被扫描到。确认依赖加了、类路径对,再看@EnableAspectJAutoProxy是否需要手动开启——SpringBoot一般在有AOP starter时会自动开启,但如果你用了自定义配置覆盖,需要检查一下。
4.3 动态切换失效与连接池耗尽问题
动态切换失效最常见的原因是代码里漏调clear()导致ThreadLocal残留。之前有个线上问题一直困扰我:某个定时任务在跑的时候会周期性出现“偶尔连错库”。后来排查发现,那个任务用了线程池,线程执行完后ThreadLocal没清理,复用时把上一次的库标识带到了下一次任务。所以在一切手动设置的地方,务必要在finally块里清理,最好统一收到切面里处理,不裸写在业务代码里。
连接池耗尽的症状是接口偶发卡顿、报Connection is not available, request timed out after 30000ms。多数据源场景下,每个连接池是独立的,你需要分别监控。造成耗尽的常见原因是某条慢SQL长期占用连接,或者某个数据源的maximum-pool-size配得太小,不够业务并发用。建议把主库的池子开大一点,从库的池子按需中等配置,不要整齐划一地设置相同值。
4.4 MyBatis缓存层面的隐患
MyBatis的一级缓存是SqlSession级别的,二级缓存是Mapper级别的。在动态数据源场景下,如果开启了二级缓存,同一个Mapper接口的查询结果会被缓存下来,首次是从A库查的,缓存了;第二次切到B库查同一个Mapper,可能命中的是A库的缓存结果。
这不是切换逻辑的问题,是缓存粒度跟不上数据源路由粒度导致的。解决方式要么关闭二级缓存(默认是不开的),要么把命名空间按数据源拆开,或者在XML里设置useCache="false"。
4.5 动态数据源可靠性的进一步思考
纸面实现跑通容易,生产环境可靠性是另一回事。我把这套方案在项目里压了一段时间之后,有几个体会特别深。
第一,路由逻辑越简单越好。别在determineCurrentLookupKey()里做复杂计算、查表、判断。它会在每次获取连接时调用,频率极高,任何额外开销都会被放大。我的经验是它只返回ThreadLocal的值,其他逻辑提前在切面里算好。
第二,数据源数量要克制。路由Map里塞了三五个数据源的场景,代码层面没问题,但运维监控、日志排查的复杂度会成倍上升。宁可在应用层做分组隔离,也别在单个应用里塞太多库。
第三,连接池的监控指标一定要接。HikariCP本身有HikariPoolMXBean可以暴露活性连接数、挂起任务数,通过SpringBoot Actuator配合Prometheus+Grafana,能直观看到每个数据源的池子占用情况。多数据源项目,没有监控几乎等于蒙着眼睛开车。
第四,要考虑数据库账号权限的隔离。既然有了多数据源,建议主库用读写账号,从库用只读账号。应用层面切错了还有数据库兜底挡一下,能减少因为路由混乱导致的脏数据。
5. 个人经验总结:多数据源背后的工程思维
这套多数据源方案,本质上是用一个“上下文标识”把数据源选择权从底层连接获取逻辑中解耦出来。谁设置上下文、何时清理上下文,就成了整个方案的灵魂。用注解加AOP的方式能把这个灵魂问题集中在代码结构上解决,而不是散落在业务逻辑里。
踩过几次坑之后我的习惯是:新建一个多数据源模块时,先花半小时理清楚哪些操作走主库、哪些走从库、有没有跨库事务需求,再动手写代码。方案层面想透了,编码只是执行。
最后再分享一个排查技巧:遇到诡异的数据源路由问题时,别急着猜,先看一眼HikariCP的SHOW PROCESSLIST,再查日志里实际执行的SQL,一步就能定位是切面问题、连接池复用问题,还是事务绑定问题。这个思路能帮你省下大量的排查时间,也是我在多数据源项目里收获最大的实战经验。