Spring Boot 多数据源落地:AbstractRoutingDataSource + 注解切面(附源码)
报表、分析类服务有个很常见的现实:业务库在 PostgreSQL,订单在 MySQL,宽表在 StarRocks,本服务还要往自己的库里写一点结果 。如果给每个库单独起一套 SqlSessionFactory,Mapper 扫描包、插件、类型处理器都要配多份,业务方法里还得记得注入「正确的那套 Mapper」。
更省事的做法是:整个服务仍然只有一个 DataSource Bean,真正连哪套库,由当前线程上的路由 key 决定。 这就是 Spring 的 AbstractRoutingDataSource。下面这套实现来自真实的报表服务,不是伪代码:配置注册、ThreadLocal 栈、@DataSource 切面、业务用法和事务坑都会贴源码。
连接串、账号在配置中心,文中只用示意项;包名按业务代码保留,方便对照。
1. 目标架构
bash
业务方法 @DataSource(PG)
│
▼
DataSourceAnnotationAspect // 进入 push,finally pop
│
▼
DynamicDataSourceContextHolder // ThreadLocal + 栈
│
▼
DynamicRoutingDataSource.determineCurrentLookupKey()
│
▼
对应的 Hikari 连接池
要点就三句:
- 启动时把 N 套 Hikari 放进一个
Map<key, DataSource>,再包进路由源。 - 运行时 MyBatis 只有一个
dataSource,getConnection()时按 key 选池。 - 业务用注解声明 key,不要在业务代码里手动
set/remove(嵌套调用除外,由切面维护栈)。
2. 启动注册:把多套库注册成一个 Bean
配置类只负责把注册器 import 进来:
java
@Configuration
@Import({DynamicDataSourceRegister.class})
@ComponentScan("com.jiuaoedu.report")
public class DynamicDataSourceConfiguration {
}
真正干活的是 ImportBeanDefinitionRegistrar。它在容器解析配置类时执行,比普通 @Bean 更早,可以把名为 dataSource 的路由源塞进容器,覆盖 Boot 自动配置的单数据源(需要允许 bean 覆盖)。
java
@Override
public void registerBeanDefinitions(AnnotationMetadata annotationMetadata,
BeanDefinitionRegistry beanDefinitionRegistry) {
List<Map> configs = binder.bind("spring.datasource.ds", Bindable.listOf(Map.class)).get();
Map<String, DataSource> targetDataSources = new HashMap<>(configs.size());
for (int i = 0; i < configs.size(); i++) {
Map config = configs.get(i);
Class<? extends DataSource> clazz = getDataSourceType((String) config.get("type"));
DataSource datasource = bind(clazz, config);
String key = config.get("key").toString();
targetDataSources.put(key, datasource);
}
GenericBeanDefinition define = new GenericBeanDefinition();
define.setBeanClass(DynamicRoutingDataSource.class);
MutablePropertyValues mpv = define.getPropertyValues();
String defaultDs = binder.bind("spring.datasource.ds.default", String.class).get();
mpv.add("defaultTargetDataSource", targetDataSources.get(defaultDs));
mpv.add("targetDataSources", targetDataSources);
beanDefinitionRegistry.registerBeanDefinition("dataSource", define);
}
配置中心示意:
properties
spring.datasource.ds.default=postgresql
spring.datasource.ds[0].key=postgresql
spring.datasource.ds[0].jdbc-url=jdbc:postgresql://host:5432/db
spring.datasource.ds[0].username=***
spring.datasource.ds[0].password=***
spring.datasource.ds[0].driver-class-name=org.postgresql.Driver
spring.datasource.ds[1].key=mysql_mall_read
# ...
type 为空时默认 Hikari,和 Spring Boot 保持一致:
java
private Class<? extends DataSource> getDataSourceType(String typeStr) {
try {
if (StringUtils.hasLength(typeStr)) {
return (Class<? extends DataSource>) Class.forName(typeStr);
}
return HikariDataSource.class;
} catch (Exception e) {
throw new IllegalArgumentException("can not resolve class with type: " + typeStr);
}
}
Map 会通过 Binder 绑到连接池对象上,配置项名要对上 Hikari 属性(jdbc-url、username、password 等)。bind(...).get() 在配置缺失时会直接让启动失败,这是预期行为:多数据源配不齐就不要半残着跑。
3. 运行时选库:只有一个方法
java
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceRouterKey();
}
}
AbstractRoutingDataSource 只在 getConnection() 时 调用它。key 为 null 就走 defaultTargetDataSource。所以:
- 没打注解 → 默认库;
- 事务一旦把连接绑进线程,后面再改 key 换不了连接。
上下文不用单个 String,而用栈,为的是嵌套切库 :外层 Repository 切到 StarRocks,内层再调一个 @DataSource(PG) 的方法,内层返回后外层 key 还在。
java
public class DynamicDataSourceContextHolder {
private static final ThreadLocal<LinkedList<String>> HOLDER = new ThreadLocal<>();
public static String getDataSourceRouterKey() {
LinkedList<String> linkedList = HOLDER.get();
if (Objects.isNull(linkedList)) {
return null;
}
if (!linkedList.isEmpty()) {
return linkedList.getFirst();
}
return null;
}
public static void setDataSourceRouterKey(String dataSourceRouterKey) {
LinkedList<String> linkedList = HOLDER.get();
if (Objects.isNull(linkedList)) {
HOLDER.set(new LinkedList<>());
}
HOLDER.get().addFirst(dataSourceRouterKey);
}
public static void removeDataSourceRouterKey() {
LinkedList<String> linkedList = HOLDER.get();
if (!linkedList.isEmpty()) {
linkedList.removeFirst();
} else {
HOLDER.remove();
}
}
}
实现上有两个可以改进的点:
remove在 list 已空时才HOLDER.remove(),正常 pop 完最后一项会留下空LinkedList,线程池线程上会一直挂着。HOLDER.get()为 null 时直接isEmpty()会 NPE。切面finally无条件 pop 时可能踩到。
更稳妥的 pop 是:判空、弹栈、空了就 remove()。
4. 业务声明:注解、枚举、切面
注解可以打在类或方法上:
java
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
DataSourceEnum value();
}
枚举里的字符串必须和配置里每条 key 完全一致:
java
public enum DataSourceEnum {
PG("postgresql"),
MYSQL_MALL_READ("mysql_mall_read"),
POSTGRESQL_WRITE("postgresql_write"),
STAR_ROCKS_EDUCATION_BUSINESS("star_rocks_education_business"),
STAR_ROCKS_REPORT_EDUCATION_BUSINESS("star_rocks_report_education_business"),
STAR_ROCKS_PG_CRM("star_rocks_pg_service_crm"),
MYSQL_REPORT_FORM("mysql_report_form"),
PG_SERVICE_INTELLIGENCE_CENTER("postgresql_service_intelligence_center");
private final String ds;
DataSourceEnum(String ds) {
this.ds = ds;
}
public String getDs() {
return ds;
}
}
(原实现里枚举带了 setDs,公开分享时建议删掉:枚举 key 不应运行时被改。)
切面:方法注解优先于类注解;finally 里一定 pop。
java
@Aspect
@Component
public class DataSourceAnnotationAspect {
@Pointcut("@annotation(com.jiuaoedu.report.config.mybatis.DataSource) || @within(com.jiuaoedu.report.config.mybatis.DataSource)")
private void cut() {
}
@Around("cut()")
public Object apiDs(ProceedingJoinPoint joinPoint) throws Throwable {
try {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
DataSource annotation = method.getAnnotation(DataSource.class);
if (Objects.isNull(annotation)) {
Class<?> declaringType = methodSignature.getDeclaringType();
annotation = declaringType.getAnnotation(DataSource.class);
}
if (Objects.nonNull(annotation) && StringUtils.isNotBlank(annotation.value().getDs())) {
DynamicDataSourceContextHolder.setDataSourceRouterKey(annotation.value().getDs());
}
} catch (Exception e) {
logger.info(e.getMessage());
}
try {
return joinPoint.proceed();
} finally {
DynamicDataSourceContextHolder.removeDataSourceRouterKey();
}
}
}
两个会让人排查很久的细节:
method.getAnnotation看不到只打在接口上的注解 。CGLIB 拿到的是实现类Method。所以注解应打在*RepositoryImpl上,不要只打在接口方法上。- 解析失败被吞掉后请求仍走默认库。线上表现经常是「表不存在 / SQL 方言不对」,而不是「切库失败」。
5. 三种业务写法
5.1 整类一个库(最稳)
订单只读走 MySQL,类上打一次即可,Mapper 不必再标:
java
@Component
@DataSource(DataSourceEnum.MYSQL_MALL_READ)
public class MysqlOrderRepositoryImpl implements MysqlOrderRepository {
@Resource
private MysqlOrderMapper orderMapper;
@Override
public Order selectByOrderNo(String orderNo) {
return orderMapper.selectByOrderNo(orderNo);
}
}
AOP 拦的是 Spring 组件,不是 MyBatis 的 JDK 代理,这是推荐写法。
5.2 同一实现类按方法切库
同一个 Mapper 里既有 StarRocks SQL,也有 PG SQL,全靠调用方法上的 key:
java
@Override
@DataSource(DataSourceEnum.STAR_ROCKS_EDUCATION_BUSINESS)
public List<Mall> queryByRange(RangeQueryMallIn in) {
return mallMapper.queryByRange(in);
}
@Override
@DataSource(DataSourceEnum.STAR_ROCKS_PG_CRM)
public List<MallDistanceDto> operatorMallBusinessIds(OperatorBusinessDistanceQuery query) {
return mallMapper.selectByDistance(query);
}
@Override
@DataSource(DataSourceEnum.PG)
public List<MallDetail> queryPgByIds(List<Long> mallIds) {
return mallMapper.queryPgByIds(mallIds);
}
同一类里如果某个方法漏标,就会静默打到默认库。这是最常见的隐患。
5.3 标在 Mapper 接口上(能跑,但脆)
java
@Mapper
@DataSource(DataSourceEnum.STAR_ROCKS_REPORT_EDUCATION_BUSINESS)
public interface LaborEfficiencyMapper {
List<LaborEfficiencyRow> queryWeekRows(@Param("periods") List<String> periods);
}
MyBatis Mapper 是 JDK 代理,@within 是否稳定命中取决于 Spring 对代理类型的匹配。新代码优先标在 Repository 实现类上。
6. 和 @Transactional 叠在一起为什么会切错库
路由只在取连接时看 ThreadLocal。Spring 事务会在方法入口用 DataSourceUtils 先拿到连接并绑到线程。之后切面再改 key,MyBatis 仍用已绑定的那条连接。
反例:Service 上开事务,Mapper 上才切库。
java
@Service
public class ConfigServiceImpl implements ConfigService {
@Override
@Transactional
public void insertConfig(Config config) {
configMapper.insert(config);
}
}
@Mapper
@DataSource(DataSourceEnum.STAR_ROCKS_REPORT_EDUCATION_BUSINESS)
public interface ConfigMapper {
void insert(Config config);
}
实际顺序往往是:
- 事务拦截器先执行,此时 key 多为
null→ 用默认库拿连接; - Mapper 切面再把 key 设成 StarRocks;
insert仍然走步骤 1 的连接。
约定:
- 需要事务时,
@Transactional和@DataSource放在同一个已经切好库的方法上; - 不要在外层先开事务,再在内层切库;
- PostgreSQL / MySQL / StarRocks 之间不要幻想本地事务。
这也符合常见规范:事务里不要做远程调用;动态数据源的「换库」同样不要发生在事务已经绑定连接之后。
7. 可以直接照着做的约定
- 新查询:在
*RepositoryImpl的类或方法上打@DataSource,枚举值、配置key三对齐。 - 一个方法只打一个库;跨库拆两个方法,靠栈嵌套。
- 写本库用明确的写库枚举,不要赌默认库。
- 新增物理库:配置加一项
key,枚举加常量,业务打注解。不必改路由类。 this.xxx()自调用不会进切面;同类内切库请拆 Bean 或注入自己。- 排查「表不存在」时,先看当前方法有没有注解、外层有没有提前开事务。
8. 这套方案适合什么、不适合什么
适合:以读为主、偶发写本库的报表 / 分析服务,愿意用注解换「少配几套 MyBatis」。
不适合:需要跨库强一致写入。那时应上分布式事务或把写入收敛到一个库,而不是在路由 DataSource 上叠本地事务。
核心就一句话:共享 MyBatis + 线程级路由 key,切库必须发生在取连接之前,并且必须走过 Spring 代理。