Spring Boot 多数据源落地:AbstractRoutingDataSource + 注解切面(附源码)

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 连接池

要点就三句:

  1. 启动时把 N 套 Hikari 放进一个 Map<key, DataSource>,再包进路由源。
  2. 运行时 MyBatis 只有一个 dataSourcegetConnection() 时按 key 选池。
  3. 业务用注解声明 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-urlusernamepassword 等)。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();
        }
    }
}

实现上有两个可以改进的点:

  1. remove 在 list 已空时才 HOLDER.remove(),正常 pop 完最后一项会留下空 LinkedList,线程池线程上会一直挂着。
  2. 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();
        }
    }
}

两个会让人排查很久的细节:

  1. method.getAnnotation 看不到只打在接口上的注解 。CGLIB 拿到的是实现类 Method。所以注解应打在 *RepositoryImpl 上,不要只打在接口方法上。
  2. 解析失败被吞掉后请求仍走默认库。线上表现经常是「表不存在 / 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);
}

实际顺序往往是:

  1. 事务拦截器先执行,此时 key 多为 null → 用默认库拿连接;
  2. Mapper 切面再把 key 设成 StarRocks;
  3. insert 仍然走步骤 1 的连接。

约定:

  • 需要事务时,@Transactional@DataSource 放在同一个已经切好库的方法上;
  • 不要在外层先开事务,再在内层切库;
  • PostgreSQL / MySQL / StarRocks 之间不要幻想本地事务。

这也符合常见规范:事务里不要做远程调用;动态数据源的「换库」同样不要发生在事务已经绑定连接之后。

7. 可以直接照着做的约定

  1. 新查询:在 *RepositoryImpl 的类或方法上打 @DataSource,枚举值、配置 key 三对齐。
  2. 一个方法只打一个库;跨库拆两个方法,靠栈嵌套。
  3. 写本库用明确的写库枚举,不要赌默认库。
  4. 新增物理库:配置加一项 key,枚举加常量,业务打注解。不必改路由类。
  5. this.xxx() 自调用不会进切面;同类内切库请拆 Bean 或注入自己。
  6. 排查「表不存在」时,先看当前方法有没有注解、外层有没有提前开事务。

8. 这套方案适合什么、不适合什么

适合:以读为主、偶发写本库的报表 / 分析服务,愿意用注解换「少配几套 MyBatis」。

不适合:需要跨库强一致写入。那时应上分布式事务或把写入收敛到一个库,而不是在路由 DataSource 上叠本地事务。

核心就一句话:共享 MyBatis + 线程级路由 key,切库必须发生在取连接之前,并且必须走过 Spring 代理。


相关推荐
乱码三千1 小时前
使用ComfyUI+MinMax-H3音视频模型生成视频
前端·后端·github
Shinomiya1 小时前
最近在学进程调度和环境变量,记点心得
后端
分支预测失败1 小时前
RISC-V 核间中断 IPI 实战:从 MSIP 寄存器到 IMSIC 消息投递
linux·后端
LabVIEW开发1 小时前
LabVIEW 64位安装的位深陷阱:工具包、内存与工程兼容
数据库·labview·labview知识·labview功能·labview程序
寺中人1 小时前
MySQL 8.0 Windows 完整安装教程:环境配置、密码重置与常见报错排查
数据库·windows·mysql·环境搭建·mysql 安装
这个DBA有点耶1 小时前
同样48核配置TPS差1倍?高性价比数据库一体机的“软硬协同”才是分水岭
服务器·数据库·架构
用户850869276151 小时前
Rust系统编程在任务调度中的踩坑与优化之路
后端
这个DBA有点耶1 小时前
MySQL 8.0执行计划分析利器:EXPLAIN ANALYZE到底比EXPLAIN强在哪?
数据库·mysql·代码规范