Spring Boot 多数据源配置与动态切换——一套代码跑通三个业务库
Spring Boot 多数据源配置与动态切换——一套代码跑通三个业务库
适读人群:有多数据库业务场景需求的 Java 工程师 | 阅读时长:约18分钟 | 核心价值:掌握 Spring Boot 多数据源配置的两种主流方案,实现运行时动态切换数据源
一、老李的"三库并存"难题
老李上个月给我打电话,说遇到一个棘手的问题。他们公司做了一次业务拆分:原来的大单体系统要同时对接三个数据库——主业务库(订单、用户)、财务库(账单、流水)、第三方接入库(合作商数据)。这三个库分别在不同的 MySQL 实例上,字段设计也不同。
他说:用三套独立的 Service、Mapper、DataSource 是能搞定,但项目里有大量"跨库逻辑",比如下订单同时要写订单库和财务库,这种情况硬切换特别难受,代码里到处是 primaryDataSource 和 financeDataSource 的切换,维护成本极高。
我告诉他,这个场景用 AbstractRoutingDataSource 实现动态数据源,配合 AOP 注解切换是最优解。这篇文章我把这个方案完整讲清楚,包括配置、切换逻辑、事务处理,以及真实踩过的坑。
二、两种主流多数据源方案对比
在动手之前,先明确两种主流方案,选对了才不走弯路:
方案一:多个独立的 DataSource Bean
优点:简单直接,不同库的配置完全隔离
缺点:业务代码里需要显式注入不同的 DataSource/JdbcTemplate/SqlSessionFactory
适合:两个库之间业务逻辑基本不交叉,各自独立的模块方案二:AbstractRoutingDataSource 动态路由
优点:对业务代码透明,通过注解声明用哪个库,其余代码无感知
缺点:实现复杂一些,跨库事务需要额外处理
适合:多个库之间有业务交叉、需要频繁切换的场景老李的场景适合方案二。我的建议是:能用方案一解决的,不要上方案二;只有当"切换数据源"的需求频繁出现在业务逻辑里,才引入动态路由。
三、方案一:独立 DataSource 配置
先演示方案一,作为基础理解。
application.yml:
spring:
datasource:
primary:
url: jdbc:mysql://localhost:3306/main_db?useSSL=false&characterEncoding=utf8
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
finance:
url: jdbc:mysql://localhost:3306/finance_db?useSSL=false&characterEncoding=utf8
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.DriverDataSource 配置类(以主库为例):
package com.example.config;
import com.zaxxer.hikari.HikariDataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import javax.sql.DataSource;
/**
* 主业务库配置。
* @MapperScan 指定此数据源对应的 Mapper 接口所在包,
* 并通过 sqlSessionTemplateRef 绑定到对应的 SqlSessionTemplate。
*/
@Configuration
@MapperScan(
basePackages = "com.example.mapper.primary",
sqlSessionTemplateRef = "primarySqlSessionTemplate"
)
public class PrimaryDataSourceConfig {
@Bean(name = "primaryDataSource")
@Primary
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean(name = "primarySqlSessionFactory")
@Primary
public SqlSessionFactory primarySqlSessionFactory(
@Qualifier("primaryDataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
// 指定 Mapper XML 文件路径
factoryBean.setMapperLocations(
new PathMatchingResourcePatternResolver()
.getResources("classpath:mapper/primary/*.xml")
);
return factoryBean.getObject();
}
@Bean(name = "primaryTransactionManager")
@Primary
public DataSourceTransactionManager primaryTransactionManager(
@Qualifier("primaryDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean(name = "primarySqlSessionTemplate")
@Primary
public SqlSessionTemplate primarySqlSessionTemplate(
@Qualifier("primarySqlSessionFactory") SqlSessionFactory factory) {
return new SqlSessionTemplate(factory);
}
}财务库的配置类结构完全一样,换个前缀 finance 即可,此处省略重复代码。
四、方案二:动态数据源(核心方案)
4.1 定义数据源 Key 枚举
package com.example.datasource;
/**
* 数据源类型枚举,对应三个业务库。
*/
public enum DataSourceType {
/** 主业务库:订单、用户 */
PRIMARY,
/** 财务库:账单、流水 */
FINANCE,
/** 第三方接入库 */
THIRD_PARTY
}4.2 数据源上下文持有者(ThreadLocal)
package com.example.datasource;
/**
* 使用 ThreadLocal 持有当前线程的数据源类型。
* 线程结束时必须调用 clear(),防止内存泄漏。
*/
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> CONTEXT =
new InheritableThreadLocal<>();
public static void set(DataSourceType type) {
CONTEXT.set(type);
}
public static DataSourceType get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}4.3 实现动态路由 DataSource
package com.example.datasource;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
/**
* 动态路由数据源,继承 AbstractRoutingDataSource。
* Spring 在每次获取连接时,调用 determineCurrentLookupKey() 决定用哪个数据源。
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
DataSourceType type = DataSourceContextHolder.get();
// 未设置则返回 null,AbstractRoutingDataSource 会使用 defaultTargetDataSource
return type != null ? type : DataSourceType.PRIMARY;
}
}4.4 自定义注解
package com.example.datasource;
import java.lang.annotation.*;
/**
* 数据源切换注解,加在 Service 方法上,指定使用哪个数据源。
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DS {
DataSourceType value() default DataSourceType.PRIMARY;
}4.5 AOP 切面自动切换
package com.example.datasource;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* 数据源切换切面。
* @Order(1) 保证在事务切面之前执行(事务切面通常 @Order(Integer.MAX_VALUE)),
* 必须先切换数据源,再开启事务,否则事务绑定到了错误的数据源。
*/
@Aspect
@Component
@Order(1)
public class DataSourceAspect {
private static final Logger log = LoggerFactory.getLogger(DataSourceAspect.class);
@Around("@annotation(com.example.datasource.DS) || @within(com.example.datasource.DS)")
public Object switchDataSource(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取方法上的 @DS 注解
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DS ds = method.getAnnotation(DS.class);
if (ds == null) {
// 方法上没有,查 Class 上的注解
ds = joinPoint.getTarget().getClass().getAnnotation(DS.class);
}
DataSourceType previous = DataSourceContextHolder.get();
if (ds != null) {
DataSourceContextHolder.set(ds.value());
log.debug("[DS] 切换数据源: {} -> {}", previous, ds.value());
}
try {
return joinPoint.proceed();
} finally {
// 恢复之前的数据源(支持嵌套切换)
if (previous == null) {
DataSourceContextHolder.clear();
} else {
DataSourceContextHolder.set(previous);
}
log.debug("[DS] 恢复数据源: {}", previous);
}
}
}4.6 多数据源配置类
package com.example.config;
import com.example.datasource.DataSourceType;
import com.example.datasource.DynamicDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class MultiDataSourceConfig {
@Bean("primaryRawDataSource")
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryRawDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean("financeRawDataSource")
@ConfigurationProperties(prefix = "spring.datasource.finance")
public DataSource financeRawDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean("thirdPartyRawDataSource")
@ConfigurationProperties(prefix = "spring.datasource.third-party")
public DataSource thirdPartyRawDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
/**
* 注册动态数据源,设置各数据源与 key 的映射关系,以及默认数据源。
*/
@Bean
@Primary
public DataSource dynamicDataSource(
@Qualifier("primaryRawDataSource") DataSource primary,
@Qualifier("financeRawDataSource") DataSource finance,
@Qualifier("thirdPartyRawDataSource") DataSource thirdParty) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DataSourceType.PRIMARY, primary);
targetDataSources.put(DataSourceType.FINANCE, finance);
targetDataSources.put(DataSourceType.THIRD_PARTY, thirdParty);
DynamicDataSource dynamicDataSource = new DynamicDataSource();
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(primary); // 默认主库
return dynamicDataSource;
}
}4.7 使用示例
package com.example.service;
import com.example.datasource.DS;
import com.example.datasource.DataSourceType;
import com.example.mapper.primary.OrderMapper;
import com.example.mapper.finance.BillMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final BillMapper billMapper;
public OrderService(OrderMapper orderMapper, BillMapper billMapper) {
this.orderMapper = orderMapper;
this.billMapper = billMapper;
}
/**
* 默认使用主库(不加 @DS 注解就是主库)。
*/
@Transactional("primaryTransactionManager")
public void createOrder(OrderDTO dto) {
orderMapper.insert(dto);
}
/**
* 切换到财务库查询。
*/
@DS(DataSourceType.FINANCE)
public BillDTO getBill(Long billId) {
return billMapper.selectById(billId);
}
/**
* 查询第三方库数据。
*/
@DS(DataSourceType.THIRD_PARTY)
public PartnerDataDTO getPartnerData(String code) {
// 调用 third_party 库的 Mapper
return null; // 示意
}
}五、踩坑实录
坑1:@Transactional 与数据源切换顺序问题
现象:加了 @DS 和 @Transactional 的方法,数据库写入到了错误的库。
原因:Spring 的事务切面默认 Order 是 Integer.MAX_VALUE(最低优先级),但如果用默认配置,切面执行顺序不确定。如果事务切面先执行,它会先绑定连接(用默认数据源),等 AOP 数据源切面再切换时,连接已经绑定了,切换不生效。
解法:给数据源切面加 @Order(1),事务切面加 @Order(2),确保先切换数据源,再开始事务。这个坑我也踩过,调试了将近两个小时。
坑2:多数据源与 Spring Boot 健康检查冲突
现象:Spring Boot Actuator 健康检查时,只检查了默认数据源,其他库连接故障检测不到。
原因:DataSourceHealthIndicator 默认只检查 primary DataSource。
解法:为每个数据源注册独立的 HealthIndicator,或者引入 AbstractRoutingDataSource 的自定义健康检查逻辑。
坑3:ThreadLocal 在异步方法中丢失数据源上下文
现象:Service A 用 @DS(FINANCE) 切换到财务库,在方法内部调用了一个 @Async 方法 Service B,Service B 里的数据库操作用了默认主库,而不是财务库。
原因:@Async 方法在新线程中执行,ThreadLocal 里的数据源上下文不会传递到新线程(InheritableThreadLocal 只在父子线程关系时传递,线程池复用场景下不可靠)。
解法:异步方法里显式加 @DS(FINANCE) 注解,明确声明数据源,不依赖上下文传递。这是最稳妥的做法。
六、跨库事务的处理原则
多数据源场景下,跨库事务是个难题。我的原则是:不要在一个 @Transactional 里跨两个数据库,这样做需要引入分布式事务(XA 或 Seata),成本极高。
正确的做法是:
- 业务代码按库拆分,每个库的操作有各自的事务边界
- 跨库的数据一致性通过最终一致性保证(消息队列 + 补偿机制)
- 实在需要强一致性,评估是否可以把两个库合并,或者通过 Seata AT 模式
