SpringBoot多数据源配置实战:MySQL与Doris整合全解析 📅 发布时间:2026/9/12 8:36:32 👁 浏览次数: 先交代一下背景。手上这个数据平台项目最核心的需求就是“交易走 MySQL分析走 Doris”。业务订单、用户资料这些高并发、强一致性的数据落在 MySQL报表、漏斗、留存这类大规模聚合查询全部丢给 Doris。SpringBoot 作为统一服务层必须在同一个应用里把这两套数据源都接进来让上层接口根据场景自动切换。配置多数据源本身不难难的是切换边界、事务处理和连接池隔离这些细节。这篇文章把我这次 SpringBoot 配置 MySql 和 Doris 多数据源的完整过程整理出来从方案选型到每一步代码实现再到实际踩过的坑全部摊开讲。内容适合正在做数据平台、报表分析系统或者准备把分析型查询从 MySQL 剥离到 Doris 的 Java 后端同学参考看完可以直接抄作业。1. 为什么要在SpringBoot里同时接MySQL和Doris1.1 MySQL是OLTPDoris是OLAP天生不是一类东西MySQL 强在事务和行级操作单条数据的增删改查非常稳但在几十亿行的大表上做多表 join、group by、聚合统计性能和资源消耗都会很难看。很多业务初期用 MySQL 硬扛报表等到数据量上来一个复杂的统计 SQL 跑几十秒甚至几分钟业务方直接来骂人。Doris 是分布式分析型数据库列式存储、MPP 架构、向量化执行引擎专为 OLAP 场景设计。它对大表扫描、高基数聚合、多表关联这类查询非常友好配合 Rollup 表、物化视图和分区分桶机制百万行和几十亿行的查询延迟可以做到一个量级。Doris 还兼容 MySQL 协议意味着后端可以用现成的 MySQL 驱动连上去这点是它比很多数仓产品更好落地的原因。1.2 我这次的业务场景订单库在MySQL数仓在Doris项目里有一个报表模块需求包括每日订单统计、用户留存、渠道漏斗、Top 商品排行。这些口径要关联订单表、用户表、商品表、渠道表数据量过了千万级之后直接在业务 MySQL 上跑会拖垮在线交易。业务侧也知道不能把所有东西都塞进 MySQL于是团队并行维护了一套 Doris 数仓。数仓里的表通过同步任务从 MySQL 拉取做了宽表加工报表模块直接查 Doris 就是秒级返回。但问题来了——SpringBoot 服务只有一个底层却要访问两套数据库。总不能为报表单独起一个服务那样维护成本太高所以第一版方案就是在同一个 SpringBoot 工程里配置 MySQL 和 Doris 两个数据源通过注解或方法级别切换让一个服务同时提供在线事务接口和数据分析接口。1.3 多数据源到底难在哪说白了SpringBoot 单数据源太“傻瓜”了自动配置把数据源、SqlSessionFactory、事务管理器全都替你准备好。一旦出现第二个数据源自动配置就失效需要全部手动接管。核心难点有三个数据源切换时机一个请求进来什么时候用 MySQL、什么时候用 Doris必须要有一个明确的路由规则而且切换动作得对上层透明。事务边界Spring 的事务管理器和数据源是绑定的如果同一个方法里既查 MySQL 又查 Doris还要保持事务处理不好会出现连接串库、切换失效的诡异问题。连接池隔离MySQL 和 Doris 的负载特征完全不同事务型查询要控制连接数分析型查询要允许大查询。两个库不能共用一套连接池参数否则一个慢查询打满连接另一个库也跟着遭殃。2. 多数据源方案选型从0手写还是直接上框架2.1 主流方案横向对比多数据源方案业内主流有三种我这里先用表格快速对比一下再讲我为什么选中间那套。方案核心思路优点缺点分包 多套SqlSessionFactory每个数据源独立配置一套MyBatis环境Mapper接口按包名隔离隔离彻底事务边界清晰数据源之间不会互相干扰配置量大每个数据源都要配一遍SqlSessionFactory和事务管理器新增数据源成本高AbstractRoutingDataSource 注解AOP用一个路由数据源作为统一入口方法执行前通过ThreadLocal设置路由keyAbstractRoutingDataSource根据key返回目标数据源代码量少切换逻辑集中在切面里新增数据源只需扩展枚举和配置事务和切换的边界处理要小心否则会出现连接串库或切换失效dynamic-datasource框架基于AbstractRoutingDataSource封装的成熟框架通过DS注解切换内置多数据源事务方案使用简单注解即用社区活跃支持spel表达式引入额外依赖部分场景需要理解框架底层的动态代理逻辑对框架不熟的人踩坑后排查成本高实际商用项目里第三种方案上手最快这也是目前最主流的选择。但这次我特意选了第二种手写路由原因很直接——搭数据平台不是一次性的后面可能还要接 ClickHouse、StarRocks 甚至更多数据源。把路由原理吃透以后再接任何数据库都只是加配置和加枚举的事。2.2 为什么选AbstractRoutingDataSource而不是直接上框架先说结论如果你时间紧、项目急着上线直接用 dynamic-datasource别犹豫它足够稳定。但我自己的习惯是像多数据源这种“项目骨架级”的东西第一版一定要自己写一遍。原因很简单多数据源的问题往往不在“切数据源”本身而在于事务、连接、MyBatis 执行链路这三者之间的时序关系。直接用框架配置两分钟搞定遇到问题却很痛苦。手写一遍之后你对每条链路都清清楚楚再切回框架反而很轻松。Spring 的 AbstractRoutingDataSource 本质上就是一个 DataSource 的代理它本身不管理真实连接只在 getConnection() 的时候根据一个 lookup key从一组 targetDataSources 里选一个真实数据源返回连接。我们只需要做三件事把所有真实数据源注册到 targetDataSources 里实现 determineCurrentLookupKey() 方法返回当前线程应该走哪个数据源用一个 ThreadLocal 存放当前线程的路由 key配合 AOP 切面在方法前后设置和清理。这套东西在 Spring 生态里非常通用很多框架的底层就是这么干的。2.3 核心原理AbstractRoutingDataSource如何决定走哪个库看源码最直接。AbstractRoutingDataSource 的 getConnection 实现是这样的逻辑public Connection getConnection() throws SQLException { return determineTargetDataSource().getConnection(); } protected DataSource determineTargetDataSource() { Object lookupKey determineCurrentLookupKey(); DataSource dataSource this.targetDataSources.get(lookupKey); if (dataSource null) { throw new IllegalStateException(Cannot determine target DataSource for lookup key [ lookupKey ]); } return dataSource; }也就是说每次拿连接时它都会调一次 determineCurrentLookupKey() 来动态决定返回哪个数据源。这就是“路由”二字的来源。我们要做的是让 determineCurrentLookupKey() 返回当前线程对应的 keykey 的维护方式就是 ThreadLocal。用生活类比来说AbstractRoutingDataSource 就像一个总机接线员每个来电getConnection都问一句“你找哪个部门”我们负责在拨号前把部门代号写到工单上ThreadLocal接线员照着工单转接。只要工单在请求结束后被及时销毁就不会影响到下一个来电。3. 核心细节解析依赖、连接配置和连接池3.1 pom依赖到底要加哪些很多人第一步就被依赖搞懵了特别是 Doris容易去搜“Doris驱动”其实完全不用。因为 Doris 对外提供 MySQL 协议兼容端口所以驱动直接复用 MySQL 的 JDBC 驱动即可。dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency如果你用的是 Spring Boot 3.x建议直接把 mybatis-spring-boot-starter 升级到 3.0.3 以上不然一些包名和自动配置类对不上。连接池这块 Spring Boot 默认加载 HikariCP这个够了不需要额外引 Druid。3.2 双数据源yml配置实践这是我调整后的配置直接贴出来重点是两个数据源要分开前缀并且使用 HikariCP 时连接地址必须写成 jdbc-url不能写成 url。这是 HikariCP 的一个特殊要求很多人栽在这里。spring: datasource: mysql: jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalserewriteBatchedStatementstrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: pool-name: MysqlHikariPool minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 doris: jdbc-url: jdbc:mysql://doris-fe-host:9030/analytics_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: pool-name: DorisHikariPool minimum-idle: 2 maximum-pool-size: 10 connection-timeout: 60000 idle-timeout: 600000注意 doris 的数据库名 analytics_db 要写成 Doris 侧已经建好的库。Doris 的 FE 查询端口默认是 9030HTTP 端口是 8030这两个不要搞混。如果你把 8030 当成 JDBC 端口去连绝对连不上。3.3 连接池参数别照抄Doris和MySQL差别很大很多教程给一份配置就完事了没人告诉你为什么要这么调。这里我重点说下连接池参数差异。MySQL 是写入和事务型负载连接数太少会拖慢交易接口所以 max-pool-size 要相对大一点。但也不能无限大HikariCP 对 max-pool-size 的建议里有一个经验公式单机建议按((core_count * 2) effective_spindle_count)来估算虽然不需要严格遵守但至少别拍脑袋配个 100反而会造成大量线程切换开销。Doris 是分析型负载一条查询可能跑几秒甚至几十秒连接池如果配太多同时并发的大查询会把 BE 节点 CPU 打满。所以我建议 Doris 的 max-pool-size 控制在 10 以内并且 connection-timeout 要放大因为 Doris 在高负载时建立连接可能比较慢。如果接口经常报 Connection is not available, request timed out多半是池太小或者慢查询把连接占了。4. 实操过程与核心环节实现4.1 第1步数据源配置类与路由数据源这里用一个配置类把两个真实数据源注册成 Spring Bean再把路由数据源也注册进去。路由数据源是我们的一个核心自定义类继承 AbstractRoutingDataSource。Configuration public class DataSourceConfig { Bean(name mysqlDataSource) ConfigurationProperties(prefix spring.datasource.mysql) public DataSource mysqlDataSource() { return DataSourceBuilder.create().build(); } Bean(name dorisDataSource) ConfigurationProperties(prefix spring.datasource.doris) public DataSource dorisDataSource() { return DataSourceBuilder.create().build(); } Bean(name routingDataSource) public DataSource routingDataSource( Qualifier(mysqlDataSource) DataSource mysqlDataSource, Qualifier(dorisDataSource) DataSource dorisDataSource) { MapObject, Object targetDataSources new HashMap(4); targetDataSources.put(DataSourceType.MYSQL, mysqlDataSource); targetDataSources.put(DataSourceType.DORIS, dorisDataSource); DataSourceRouter router new DataSourceRouter(); router.setDefaultTargetDataSource(mysqlDataSource); router.setTargetDataSources(targetDataSources); return router; } }这里有一个非常重要的细节router.setDefaultTargetDataSource(mysqlDataSource)设置了默认数据源。这意味着如果某个方法没有指定走 Doris它会默认走 MySQL这样能避免切换遗漏导致全部查询打到 Doris 上。4.2 第2步ThreadLocal上下文切换工具路由数据源怎么知道当前线程要使用哪个 key答案就是 ThreadLocal。ThreadLocal 的特点就是每个线程都有自己独立的一份变量副本线程之间不互相干扰刚好符合一个请求一条线程的 Web 模型。public class DataSourceContextHolder { private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); public static void setDataSource(String dataSourceType) { CONTEXT_HOLDER.set(dataSourceType); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clearDataSource() { CONTEXT_HOLDER.remove(); } }这里我要强调一个习惯问题用 ThreadLocal 一定要记得清理。在 AOP 切面里用 try-finally 保证执行后执行 remove()。如果漏了 remove在高并发线程复用的容器里下一次请求可能会读到上一次线程遗留的路由 key出现查错库的严重事故。然后是实现路由数据源类public class DataSourceRouter extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }你可以看到真正需要重写的方法就这么一个。Spring 在 getConnection() 时调用它拿到 key 去路由目标数据源。这里还需要定义一个常量类把数据源的 key 管理起来不建议直接用字符串散落在业务代码里。public class DataSourceType { public static final String MYSQL MYSQL; public static final String DORIS DORIS; }4.3 第3步注解和AOP切面现在我们有了分类用的 key接下来定义一个注解标注在 Service 或 Mapper 方法上表示该方法走哪个数据源。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DataSource { String value() default DataSourceType.MYSQL; }然后写一个切面在执行目标方法前把注解里的 value 丢进 ThreadLocal方法执行完再清理。Aspect Component public class DataSourceAspect { Around(annotation(dataSource)) public Object around(ProceedingJoinPoint joinPoint, DataSource dataSource) throws Throwable { DataSourceContextHolder.setDataSource(dataSource.value()); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clearDataSource(); } } }有了这个切面业务代码就非常舒服了。比如报表服务里需要查 Doris直接在方法上打一个 DataSource(DataSourceType.DORIS) 就能切换业务代码不需要感知底层数据源的存在。4.4 第4步MyBatis的SqlSessionFactory绑定这部分是整个配置里最容易出问题的因为 Spring Boot 的自动配置默认只认一个数据源。一旦我们自己声明了路由数据源就必须手动告诉 MyBatis“我的 Mapper 用的是这个路由数据源不是任何一个具体的数据源。”Configuration MapperScan(basePackages com.example.project.mapper, sqlSessionFactoryRef sqlSessionFactory) public class MyBatisConfig { Bean(name sqlSessionFactory) public SqlSessionFactory sqlSessionFactory( Qualifier(routingDataSource) DataSource routingDataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(routingDataSource); // 这里配置Mapper XML文件的位置 bean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath*:mapper/**/*.xml)); // 驼峰映射等全局配置 org.apache.ibatis.session.Configuration config new org.apache.ibatis.session.Configuration(); config.setMapUnderscoreToCamelCase(true); bean.setConfiguration(config); return bean.getObject(); } }注意 MapperScan 里的 sqlSessionFactoryRef 必须和上面的 Bean 名称一致否则 Spring 创建 Mapper 代理时找不到 SqlSessionFactory。当你把 SqlSessionFactory 绑定到 routingDataSource 之后所有 Mapper 执行 SQL 时走的都是路由数据源具体走哪个库就看调用这个方法时 ThreadLocal 里放的是什么 key。有人可能会问“既然 Mapper 走路由数据源那 Mapper 里写的 SQL 如果涉及两张在不同库的表怎么办”多数据源场景下单条 SQL 只能在一个数据源里执行跨库 join 是不支持的。通常的做法是在 Doris 侧提前把宽表加工好应用层只做单库查询业务如果需要合并结果就在服务层做二次组装。4.5 第5步服务层怎么用Mapper怎么落现在我给你展示一个完整的调用链路。比如我有一个订单统计接口需要查 Doris 里的日订单汇总宽表服务层代码如下Service public class ReportService { Resource private ReportMapper reportMapper; DataSource(DataSourceType.DORIS) public ListDailyOrderStatVO getDailyOrderStat(String startDate, String endDate) { return reportMapper.selectDailyOrderStat(startDate, endDate); } }对应的 Mapper 接口public interface ReportMapper { ListDailyOrderStatVO selectDailyOrderStat(Param(startDate) String startDate, Param(endDate) String endDate); }对应的 XML 里写 Doris 的查询 SQL 就行。Doris 语法和 MySQL 高度兼容常规的 select、where、group by 写法直接复制即可。而如果是在线交易接口比如订单服务它不需要打 DataSource 注解默认走 MySQL。Service public class OrderService { Resource private OrderMapper orderMapper; public OrderVO queryOrderById(Long orderId) { return orderMapper.selectById(orderId); } }这样整个服务就形成了“默认走 MySQL、注解切 Doris”的格局。代码可读性高新增一个 Doris 查询接口只需要给方法加一个注解。5. 常见问题与排查技巧实录5.1 问题速查表症状可能原因解决办法接口报了 Cannot determine target DataSource数据源路由key为空或写错检查方法是否加了DataSource注解检查注解value是否在DataSourceType中有定义明明加了DataSource(DORIS)查询却走了MySQLAOP切面没生效或方法内部自调用确认切面是否被Spring扫描确认调用方式是否通过代理对象调用检查方法是否被同类方法this调用事务方法里切换数据源失效事务和数据源切换的时序问题不要在开启事务后才切换数据源Doris查询方法避免加TransactionalDoris查询报连接超时Doris连接池太小或慢查询占满连接调大connection-timeout限制Doris池大小定位慢查询并优化SQLSQL语法报错好像是MySQL的语法Doris部分语法和MySQL有差异检查Doris官方文档特别是update、delete、变量设置等语法差异5.2 坑1AOP自调用导致注解失效这是一个很经典的 Spring AOP 坑。AOP 是基于代理对象的如果你在同一个类的内部直接调用另一个方法比如 this.getDailyOrderStat()它调用的是 this 对象的方法不是代理对象的方法切面逻辑根本不会执行DataSource 注解当然不生效。我第一次在 Service 里调用同类的另一个方法时就踩了。当时写了一个聚合方法里面调用了好几个带 DataSource 注解的私有方法结果数据源全部走了默认 MySQLDoris 查询全部报“表不存在”。排查了半天才意识到是自调用问题。解决办法有三种把需要切换数据源的查询方法拆到另一个 Service Bean 里通过注入的代理对象调用在同一个类中注入自身Autowired 或 Resource 注入 ApplicationContext 后 getBean用 AspectJ 模式编译期织入但一般没必要。我自己偏好第一种拆类最干净也符合单一职责。5.3 坑2Transactional和DataSource混用切换失效这是多数据源配置里最容易踩的深坑。Spring 的事务管理是在进入方法时通过事务管理器获取数据库连接并且这个连接会绑定到当前线程。如果你在一个方法上同时标了 Transactional 和 DataSource(DORIS)执行顺序大概率是事务先开启数据源切换在后或者事务管理器已经绑定了默认数据源的连接后面再切数据源根本来不及。我项目里就出现过一次在订单服务里加了一个 Transactional 方法里面调用 Doris 的查询。结果 Doris 没被切换过去所有查询都打到 MySQL 上还报了表不存在的错误。解决思路Doris 查询基本都是只读操作完全没必要加事务MySQL 的写方法保持 Transactional 不设数据源直接走默认如果一个方法真的要跨数据源读写建议拆成两个方法通过编程式事务来控制不要把事务边界和数据源切换混在一个方法里。5.4 坑3Doris慢查询从这几个方向排查Doris 查询虽然快但也不是万灵药SQL 写得烂照样慢。我在优化过一个慢查询之后把常规排查思路整理成了下面几条先看是否命中分区裁剪Doris 建表通常会指定分区列查询条件里如果没有带上分区列它会全表扫描所有分区。一定要确认 SQL 的 where 条件能被裁剪到具体分区。再看是否走了 Rollup 或物化视图如果一张明细表有 Rollup 表但你的 SQL 里查询的字段和过滤条件不匹配 Rollup 的粒度优化器可能还是会选择明细表。Doris 的 EXPLAIN 可以看到最终执行计划建议多花两分钟看一遍。避免大表 join 引发的 shuffleDoris 是分布式架构两张分布方式不一致的大表 join 时会产生数据重分布网络开销很大。尽量把维度表建成复制表或者把明细表加工成宽表。别在 Doris 上做高频点查Doris 的强项是聚合扫描不是单行主键查询。单条记录的实时查询请走 MySQL或者用 Redis 做缓存不要让 Doris 承受不该它承受的负载。5.5 坑4Doris数据版本积压记一次查询性能下降的排查这里说一个比较典型的场景和“手动触发合并”这个需求有关。Doris 底层存储和 ClickHouse 类似数据写入后会生成新的版本文件后台会定期做 compaction 把版本合并掉。如果你的同步任务写入频率很高比如 FlinkSQL 每几分钟就往 Doris 表里灌一批数据版本文件数量会增长很快查询时合并文件的开销变大性能会明显恶化。我遇到的一次线上问题就是这样的某张报表宽表通过 FlinkSQL 从 MySQL 同步同步任务每 5 分钟跑一次跑了一段时间后查询延迟从 500ms 涨到 5s。查了一圈BE 日志里 compaction 分数很高tablet 版本数远超合理范围。处理方式分几步降低写入频率把 FlinkSQL 的 checkpoint 频率和写入批次适当调大减少小文件产生调整 Doris 的 compaction 线程数让后台合并更积极如果积压特别严重可以在维护窗口期对部分大表做一次手动 compaction但操作前先确认当前版本是否支持并且要在低峰期执行。我个人的建议是先把写入端频率降下来这往往能解决 80% 的版本积压问题。手动合并只是兜底手段不能常态化依赖。5.6 从MySQL同步Doris的几条扩展思路多数据源配置好了之后MySQL 到 Doris 的数据同步就成了下一个绕不开的问题。这里简单说几条实际项目中常用的方案给大家一个方向。最直接的是用 FlinkSQL CDC。Flink 的 MySQL CDC connector 可以直接监听 binlog把增删改流式写入 Doris。需要注意目标表最好建为 Unique 模型并指定唯一键否则主键重复数据会一直堆积后面查询就全是重复记录。Doris 生态里也有 CCR 能力可以实现集群间的数据复制。不过业务侧用 Flink 的占多数因为链路更灵活可以顺带做字段映射和清洗。如果是 T1 离线同步也可以直接用 DataX 或 Doris 自带的 Stream Load 定时导入取决于你的时效性要求。最后分享两个小技巧这次从零把 SpringBoot 配 MySQL 和 Doris 双数据源跑通整体过程不算复杂但有几个心得值得留个备忘。第一个技巧多数据源项目里我强烈建议把 Mapper 接口按库分包比如 mapper/mysql、mapper/doris。虽然我用的是 AOP 注解方式同一个 SqlSessionFactory 能处理所有 Mapper但分包之后代码意图非常清晰以后如果因为负载隔离要拆成两套 SqlSessionFactory甚至拆成两个服务改造成本都极低。第二个技巧Doris 查询接口的响应时间一定要单独监控。Doris 是独立集群它和 MySQL 的故障域完全隔离不代表服务就稳定了。我们线上就发生过 Doris BE 节点压力过大导致查询全部超时的情况因为报表接口 RT 直接飙升连带了整个服务的线程池被打满。所以数据源层面的隔离最终一定要落到线程池、连接池和监控告警的全面隔离上。对我个人而言多数据源这种配置类的需求值得自己动手写一次的原因在于直接给你一份配置你只能会用自己把 AbstractRoutingDataSource 和 ThreadLocal 这套路由链路走一遍以后再遇到任何“多数据源切换不生效”的问题你都能快速定位到是切面问题、事务问题还是连接池问题。这也是为什么我每次做这类基础架构调整都会刻意多花一点时间去把底层的逻辑想清楚。