最近在做一个报表聚合项目,核心需求很明确:同一个SpringBoot应用,既要读MySQL里的用户主数据,又要读SqlServer里的订单历史。老实说,这种“异构双数据源”的场景在面试题里被讲烂了,但真到落地时,配置、切库、MyBatisPlus分页、方言差异,任意一环出问题都够你折腾一晚上。
我这次从零开始把MySQL和SqlServer两个数据源完整接进了SpringBoot项目,用MyBatisPlus做了单表CRUD和分页测试,过程中踩了不少坑,也把一些网上讲得模棱两可的地方重新验证了一遍。如果你正打算给老系统加第二数据源,或者要在一个新项目里直接面对多库,这篇文章应该能帮你少走至少一晚的弯路。
1. 先搞清楚技术选型:多数据源不是“多配一个DataSource”那么简单
1.1 什么样的场景才需要上多数据源
很多同学看到“多数据源”三个字,第一反应是“把多个MySQL库连进一个SpringBoot项目”。其实这是两码事。
我按实际需求把常见场景分成了三类:
- 读写分离:主库写、从库读,数据源是同一个业务库的副本。这种场景更推荐用ShardingSphere或MyCat这种中间件,因为需要在SQL级别做读写路由,靠手工切数据源容易漏。
- 业务库分库分表:同一个业务系统把数据拆到多个库实例,通常连的都是同一种数据库。这种也可以叫多数据源,但真正落地时一般会伴随分片规则。
- 异构数据库汇聚:比如我这个项目,MySQL里是会员主数据,SqlServer里是第三方老系统的订单数据。两边库的特性、SQL语法、事务模型完全不同,这就是典型的“异构多数据源”。
第三种场景才是本文讨论的重点。它有一个核心特点:你无法用一条SQL把两个库的数据join出来。所有关联逻辑只能在应用层做,或者通过中间表同步。
1.2 三种主流实现方案,我为什么选了dynamic-datasource
在SpringBoot里实现多数据源,市面上有几种常见路线,我做了个对比:
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| 手动配置多个SqlSessionFactory | 每个数据源创建独立的MyBatis环境 | 隔离彻底,可控性最强 | 配置量大,mapper和事务都要手工绑定 |
| 继承AbstractRoutingDataSource | 运行时按key动态切换数据源 | 轻量,适合简单场景 | 事务、连接池、方言规则都要自己写 |
| dynamic-datasource-spring-boot-starter | 基于@DS注解动态切换 | 开箱即用,兼容MyBatisPlus | 需要额外引入依赖 |
如果只是两个库、各几个Mapper,手写AbstractRoutingDataSource确实不难。但当你同时引入MyBatisPlus、需要分页、需要事务切库、还要考虑连接池隔离时,手工方案的代码量会迅速膨胀。
我用的是Baomidou的dynamic-datasource-spring-boot-starter,原因有三个:
- 它原生兼容MyBatisPlus,分页插件、条件构造器都不需要额外适配。
- @DS注解可以直接标注在Mapper或Service上,比自己在代码里调
DataSourceContextHolder清晰得多。 - 它对连接池做了隔离,每个数据源独立初始化,互不影响。
当然,如果你们的架构要求一个数据源对应一套Mapper,且绝不允许任何形式的自动切换,那就别用这个方案,老老实实写多个SqlSessionFactory。
2. 工程搭建与依赖版本:真正折磨人的是版本兼容
2.1 版本怎么选,直接决定你后面要不要返工
这个项目的技术栈我定了:SpringBoot 2.7.18 + MyBatisPlus 3.5.3.1 + dynamic-datasource 3.5.2。
为什么不用SpringBoot 3.x?因为我接手的老项目里还有一堆基于javax的依赖,整体升级成本太高。如果你是新项目,用SpringBoot 3.x没毛病,但要注意:dynamic-datasource必须用3.6.0以上的版本,低版本在SpringBoot 3下会扫描不到自动配置。
依赖清单如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.microsoft.sqlserver</groupId> <artifactId>mssql-jdbc</artifactId> <version>12.4.2.jre8</version> <scope>runtime</scope> </dependency>关于MySQL驱动,我要多说一句。SpringBoot 2.7的依赖管理里默认还是mysql-connector-java,但我这里显式用了mysql-connector-j,这是Oracle官方从8.0.31开始启用的新坐标,两者底层是同一个东西,只写坐标名不冲突就行。
2.2 SpringBoot 3.x的jakarta坑,提前避雷
如果你非要在SpringBoot 3.x上做多数据源,务必检查这三处:
dynamic-datasource版本 >= 3.6.0,且它内部用的是jakarta.annotation而不是javax.annotation。- MyBatisPlus 3.5.3.1支持SpringBoot 3,但它要求JDK17+,别用JDK8硬撑。
- 自定义拦截器、过滤器时,
javax.servlet要全部替换成jakarta.servlet。
我在测试机上用SpringBoot 3.2试过一次,用的还是3.5.2的dynamic-datasource,启动直接报ClassNotFoundException: javax.annotation.Resource。查了半天发现是这个库的自动配置类里用了javax.annotation.Resource,版本一换就翻车。
2.3 测试表结构:数据库两边都准备好
我在MySQL里建了一张用户表,在SqlServer里建了一张订单表,结构尽量简单,方便后面测试:
MySQL侧:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO sys_user (name, age) VALUES ('张三', 25), ('李四', 30), ('王五', 28);SqlServer侧:
CREATE TABLE order_info ( id BIGINT IDENTITY(1,1) PRIMARY KEY, order_no VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL, user_id BIGINT NOT NULL, order_time DATETIME2 DEFAULT GETDATE() ); INSERT INTO order_info (order_no, amount, user_id) VALUES ('SO2024001', 199.00, 1), ('SO2024002', 299.00, 2), ('SO2024003', 499.50, 1);这里有个小设计:order_info里的user_id对应MySQL里sys_user的id。后面我在应用层做“用户+订单”聚合查询时,就可以演示多数据源组合了。
3. 双数据源落地方案:@DS注解切换MySQL和SqlServer
3.1 application.yml 配置详解
dynamic-datasource的配置核心在spring.datasource.dynamic下。我贴出我调通的完整配置:
spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver sqlserver: url: jdbc:sqlserver://localhost:1433;DatabaseName=demo_sqlserver;encrypt=false;trustServerCertificate=true username: sa password: Sa123456 driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver几个参数的用途说一下:
primary: master:默认数据源是master,也就是MySQL。不带@DS注解的Mapper全部走它。strict: false:允许找不到对应数据源时使用默认数据源,而不是直接报错。开发环境建议false,生产环境建议true,避免因为拼错数据源key导致悄悄读写主库。- SqlServer连接串里的
encrypt=false和trustServerCertificate=true:微软高版本JDBC驱动默认启用加密连接,如果不关掉,连低版本SqlServer会报证书相关错误。我在SqlServer 2016上实测,不加这两个参数直接启动失败。
3.2 创建Mapper时怎么指定数据源
最基础的做法是在Mapper接口上加@DS:
@DS("sqlserver") public interface OrderInfoMapper extends BaseMapper<OrderInfo> { } @DS("master") public interface SysUserMapper extends BaseMapper<SysUser> { }@DS注解的默认值就是primary数据源,所以master那个其实可以不写。但我的习惯是所有Mapper都显式标注,这样代码review的时候,看一眼接口就能知道它连的是哪个库,不用再去翻配置。
@DS支持类级和方法级标注,方法上的注解优先级高于类上的。比如你有一个OrderMapper,大多数方法走SqlServer,但有一个统计方法是查MySQL里的映射表,就可以在方法上单独加@DS("master")。
3.3 一个关键原则:不要在同类内部调用时使用@DS
这个坑我踩过,必须单独拿出来说。
假设你有一个OrderService,它的方法A标注了@DS("sqlserver"),然后方法A内部调用同一个类的方法B,方法B上也标注了@DS("master")。你会以为B走MySQL,但实际上B还是SqlServer。
原因很简单:Spring的@DS是基于AOP代理实现的,只有通过外部调用进入方法时,切面才会生效。同一个类内部方法之间的调用走的是this引用,绕过了代理,注解自然不触发。
我初期调试时就因为这个问题,一直以为配置错了,结果SqlServer日志里出现了MySQL的SQL,吓得我以为数据源串了。
解决办法有三个:
- 把需要切换数据源的方法拆到不同的Service类中。
- 注入自己的代理对象,用
AopContext.currentProxy()去调用,但要求开启exposeProxy。 - 在Mapper层直接指定数据源,而不是在Service层切换,因为Mapper本身就是代理对象,绕过不了。
我后来统一改成了第3种,所有数据源切换都发生在Mapper接口上,Service层只关注业务逻辑,简单又不容易错。
3.4 MyBatisPlus配置类与分页插件注册
写配置类时注意,不需要为每个数据源单独创建SqlSessionFactory,dynamic-datasource会自动处理。你只需要管MyBatisPlus的全局配置。
@Configuration @MapperScan("com.example.demodb.mapper") public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }注意:这里的@MapperScan扫描的是所有Mapper接口,不管它属于哪个数据源。这也是dynamic-datasource方案对比多SqlSessionFactory方案最爽的地方,一个扫描路径就全部搞定了。
但我在PaginationInnerInterceptor里写死DbType.MYSQL,后面就埋下了一个分页问题,这个在下一章详细说。
3.5 用单元测试验证数据源切换
配置完后,我写了一个测试类,验证两个数据源是否真正独立工作:
@SpringBootTest class MultiDataSourceTest { @Resource private SysUserMapper sysUserMapper; @Resource private OrderInfoMapper orderInfoMapper; @Test void testSwitchDataSource() { SysUser user = sysUserMapper.selectById(1L); System.out.println("MySQL查询结果: " + user.getName()); OrderInfo order = orderInfoMapper.selectById(1L); System.out.println("SqlServer查询结果: " + order.getOrderNo()); } }实测输出:
MySQL查询结果: 张三 SqlServer查询结果: SO2024001两个Mapper各自命中各自的数据源,说明@DS生效了。但如果你没有打开SqlServer的SQL日志,可能还看不到“真的切过去了”,最好的验证方式是把连接池监控打开,或者在两个库里分别建一张同名的test_tag表,各塞不同数据。切换后查出来的内容不同,就说明切源成功。
4. MyBatisPlus分页在多数据源下的真实表现:一个方言问题引发的翻车现场
4.1 分页插件不生效?先确认你是否手动注册了Interceptor
MyBatisPlus的版本路线图里有一个非常坑的变动:3.4.0之后,分页插件不再自动生效,必须手动注册MybatisPlusInterceptor。
我见过很多老项目用的还是3.1、3.2时代的写法,在配置里new一个PaginationInterceptor完事。升级后这段配置直接失效,Page对象传进去,查出来的还是全表数据,但没有任何报错,特别容易让人误以为插件没问题。
如果你发现selectPage查询返回的records是全量数据,第一件事就去检查两个地方:
- 是否注册了
MybatisPlusInterceptor这个Bean。 - 是否真的把
PaginationInnerInterceptor加进了Interceptor列表。
4.2 方言写死成MYSQL,SqlServer分页直接裂开
我的第一个分页bug就出在DbType.MYSQL上。当时写完分页测试,MySQL用户表分页一切正常,但SqlServer订单表一执行,直接报SQL语法错误。
日志里的SQL长这样:
SELECT id, order_no, amount, user_id, order_time FROM order_info LIMIT 1,10SqlServer怎么可能认识LIMIT?很明显,分页插件生成SQL时用的还是MySQL方言。
这就暴露了MybatisPlusInterceptor全局单例模式在多数据源下的矛盾:一个Interceptor对应一个方言,但你有两个数据库。网上很多教程都没说清楚这一点,只会告诉你注册分页插件,然后设置DbType.MYSQL,结果SqlServer就翻车。
解决思路有三种:
- 升级到支持多数据源自动识别方言的版本,我印象里dynamic-datasource的后续维护版本有处理这个场景,但不同版本行为不一致,需要自己验证。
- 在SqlServer那套Mapper执行分页时不走MyBatisPlus的分页插件,自己写分页SQL。
- 用两个
SqlSessionFactory,各自配各自方言的Interceptor,但这样又回到了多SqlSessionFactory的复杂度。
我最终在保持project兼容性的前提下,把SqlServer侧的高频分页查询改成了自定义SQL,在XML里手动写OFFSET/FETCH,绕开了全局Interceptor的方言限制。MySQL侧继续用MyBatisPlus分页。如果你不想改SQL,也可以重点看官方文档里关于多数据源分页的最新推荐做法。
4.3 “单页500条限制”:排查链路还原
我调好的测试环境里,有一个页面用户列表最多只能查出500条。现象很诡异:总记录数明明有3万条,但怎么翻页都只有500条数据在循环。
排查链路如下:
- 第一步,打印MyBatisPlus生成的SQL,发现SQL里确实带
LIMIT 500。 - 第二步,检查Page参数,发现
current和size都传了,但最终还是500。 - 第三步,全局搜索
setMaxLimit,果然在配置类里找到了pagination.setMaxLimit(500L)。
这个setMaxLimit是分页插件的最大单页限制,一旦size超过500就会强制截断到500。从安全角度看,它确实能防止前端传一个超大size把数据库压垮。但从业务角度看,如果你在没沟通的情况下继承了老项目,这500条限制会变成莫名其妙的“分页Bug”。
排查这种问题最简单的方式就是看SQL。不要盯着Java代码猜,把mybatis-plus.configuration.log-impl配置成StdOutImpl,直接看控制台的SQL,一切真相大白。
4.4 SqlServer分页语法:OFFSET/FETCH与老版本方言
既然SqlServer不分页插件了,我把自己改写的分页SQL也分享一下。SqlServer 2012及以上版本支持标准的OFFSET...FETCH NEXT语法:
SELECT id, order_no, amount, user_id, order_time FROM order_info ORDER BY id OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;这个语法本质和LIMIT offset, size一样,OFFSET表示跳过多少行,FETCH NEXT表示取多少行。但要注意:使用OFFSET/FETCH时,必须带上ORDER BY,否则SqlServer不允许这么写。
我看到热搜词里有“sqlserver offset后再top 20查到的是什么”,顺手解释一下这个疑问。执行顺序是先OFFSET再FETCH,如果你写成TOP 20 ... OFFSET 100 ROWS,TOP是在OFFSET栅格化之前作用的,查到的并不是“从第101行开始的20条”。两者组合时要先想清楚执行顺序,不然结果会非常迷惑。
如果你的SqlServer还是2008 R2,不支持OFFSET/FETCH,MyBatisPlus有另一个方言枚举DbType.SQL_SERVER_2005,走的是ROW_NUMBER() OVER写法。业务库里现在还在用2008老版本的,我建议尽快升级,不只是分页写法的问题,性能和兼容性的差异也很大。
5. 异构库SQL差异:跨库查询、字符串转数字和写法兼容
5.1 MySQL和SqlServer无法跨库join,那怎么办
多数据源最常见的一个业务需求:按用户在MySQL里的ID,查SqlServer里这个用户的订单。很多新手的直觉是写一条SQL:
SELECT * FROM order_info o JOIN sys_user u ON o.user_id = u.id这条SQL在两个库都不可能执行成功,因为order_info在SqlServer里根本看不到MySQL那边的sys_user。异构多数据源在数据库层面是隔离的,不存在的“跨库join”选项。
实际落地我用了三种手段,按成本排序:
- 应用层组装:先用MySQL的userMapper查用户列表,再用SqlServer的orderMapper按userIds查订单,最后在Java里做关联。适合单次查询数据量可控的场景,我最常用。
- 中间同步表:把一方数据定时同步到另一方库的本地表,实现单库join。适合数据量小、时效性要求不高的场景,比如每日同步用户表到SqlServer分析库。
- 链接服务器:在SqlServer里配置MySQL为Linked Server,用
OPENQUERY访问。但跨数据库链接在性能、事务一致性上风险都不小,我只在临时查数时才用,不放进生产链路。
做这类需求时,接口设计上一定要给两个Mapper的查询留好“按ID集合批量查”的能力,否则应用层组装会退化成N+1查询,性能很难看。
5.2 SqlServer字符串转数字:CAST还是CONVERT
热搜词里有两个高频问题:sqlserver 字符串转数字和sqlserver 多行合并成一行。这两个我在这次项目里都碰到了,正好一起说。
把字符串转成数字,你可以在SqlServer写:
SELECT CAST('123.45' AS DECIMAL(10,2)); SELECT CONVERT(DECIMAL(10,2), '123.45');两者功能基本一致,区别主要在于风格。CONVERT额外支持日期格式化这类带style参数的转换,CAST是SQL标准语法。
真正要警惕的是字符串列转数字时发生隐式转换。比如你在where条件里这么写:
WHERE order_no = 12345order_no如果是varchar类型,SqlServer会尝试把列里的每个值都转成数字,一旦有个非数字字符,整个查询直接报“Conversion failed”,而且这种写法会让order_no上的索引失效。我处理老系统对接数据时,最常干的事就是把前端传来的一长串“订单编号”先用参数化的TRY_CONVERT过滤掉脏数据:
SELECT * FROM order_info WHERE TRY_CONVERT(BIGINT, order_no) IS NOT NULL;TRY_CAST和TRY_CONVERT在转换失败时返回NULL而不是报错,是排查脏数据的利器。
5.3 SqlServer多行合并一行,和MySQL的GROUP_CONCAT对不上
两个库在字符串聚合上的语法差异,可以说是老生常谈。MySQL直接拼:
SELECT user_id, GROUP_CONCAT(order_no ORDER BY order_time SEPARATOR ',') FROM order_info GROUP BY user_id;SqlServer没有GROUP_CONCAT,经典做法是STUFF+FOR XML PATH:
SELECT user_id, STUFF(( SELECT ',' + order_no FROM order_info o2 WHERE o2.user_id = o1.user_id ORDER BY o2.order_time FOR XML PATH('') ), 1, 1, '') AS order_nos FROM order_info o1 GROUP BY user_id;这段SQL里FOR XML PATH('')把多行拼接成一串,前面的,+是老写法,STUFF函数再用第一个字符的,`,去替换成空字符串,达到去掉前导逗号的效果。
这种语法差异在多数据源项目里特别烦人,因为你没法写一套SQL通吃两边。我的建议是:把异构SQL差异封装在Mapper的XML文件里,并且XML文件名加上数据库后缀,比如OrderInfoMapper.xml和OrderInfoSqlServerMapper.xml,通过数据源切换加载不同statement。当然这个方案只适合差异点较少的情况,差异多的话还是建议用中间表统一口径。
6. 事务、连接池、缓存:多数据源的隐性雷区
6.1 @Transactional在多数据源下并没有“全局事务”能力
这是很多人最想当然的地方。在一个Service方法上标了@Transactional,然后先更MySQL再更SqlServer,遇到异常时期待全部回滚。
实际情况是:Spring默认事务管理器只管理唯一的数据源。dynamic-datasource虽然能让@DS正确切换数据源,但@Transactional不会自动扩展成分布式事务。
如果你两个库都要写,且必须保持一致,可选路线是:
- 用
Seata这类分布式事务框架,引入AT、TCC模式,代价是中间件部署和维护成本。 - 把其中一个库的写入改成消息队列异步落库,用最终一致性替代强一致。
- 严格避免在同一个方法里跨库写,所有跨库写入都拆成独立的本地事务步骤。
我在这次项目里处理的都是只读查询,所以没背这个包袱。如果你的需求涉及跨库写,务必在架构评审阶段就把事务方案定下来,别等上线了再补。
6.2 连接池隔离与URL参数:两个容易忽略的细节
dynamic-datasource会给每个数据源创建一个独立的HikariCP连接池。这意味着,如果你配置了20个数据源,就有20组连接池参数。我调优时习惯给每个池单独设置max-pool-size,防止一个慢查询源把系统总连接数占满。
比如配置可以这样增强:
dynamic: hikari: max-pool-size: 10 min-idle: 2 datasource: master: ... sqlserver: hikari: max-pool-size: 5另一个细节是URL参数。MySQL的serverTimezone=Asia/Shanghai不配,8.x驱动在部分环境下会报时区错误。SqlServer的encrypt=false不配,2016以下版本会握手失败。这些都是在启动日志里看半天才能定位的低级问题,配置前先确认好。
6.3 异步线程里切数据源,@DS经常“假失效”
我在多数据源基础上做并发统计时,发现了一个非常隐蔽的坑:在异步线程里调用@DS标注的方法,数据源切换有时不生效。
原因在于dynamic-datasource的数据源上下文是通过DynamicDataSourceContextHolder里的ThreadLocal保存的。主线程push进去的数据源key,子线程拿不到。如果你用ThreadPoolTaskExecutor提交任务,任务里直接调Mapper,可能走到默认数据源都不自知。
正确做法是写一个Runnable包装类,在提交任务时把当前数据源key传进去,执行前手动push,执行完在finally里poll。代码不复杂,但必须写对。我有一次并发查订单和用户,就是因为这个问题,SqlServer的数据串到了MySQL,查出来的结果还是“看起来正常”的,要不是后来仔细比对行数,这个Bug可能要存活很久。
6.4 MyBatis二级缓存:多数据源下最容易制造脏数据的配置
最后一个提醒跟MyBatisPlus无关,但很多项目会中招。
如果你在实体类上开了二级缓存,比如@CacheNamespace,那么MyBatis会按照Mapper命名空间缓存查询结果。多数据源场景下,如果同一个Mapper接口在不同数据源上执行查询,缓存里的结果可能从上一次的数据源来,下一次查另一个数据源时直接命中缓存,导致数据串源式脏读。
我的处理很简单:多数据源项目一律关掉二级缓存,只有一级缓存够用。稳定性和一致性比那点性能收益重要得多。
最后再分享一个我自己常干的小事
多数据源配置好之后,强烈建议在连接串或连接参数里带上“身份标识”。比如MySQL的URL加上connectionAttributes=applicationName:reportService,SqlServer的登录名按应用区分,这样DBA在数据库端看会话列表时,一眼就能看出哪个连接来自哪个服务。
我之前负责的一个老系统,三个微服务连同一个MySQL实例,连接池被打满时DBA过来问“这是哪个应用的连接”,结果谁也没法立刻确认,只能一个个查。后来把所有连接串统一加了应用标识,类似问题排查就变得非常快。多数据源项目里这种可观测性细节,比多写几行业务代码重要得多。