我第一次把 SpringBoot 跑多数据源,是在一个制造业项目里接报表系统时。订单实时数据在 MySQL,供应商档案在集团老平台的 SqlServer,两边业务还要经常联查。当时领导轻描淡写说了句“加个数据源就行”,我实际折腾了好几天才把路由、事务、分页这些坑填平。今天这篇就围绕 SpringBoot 连接多数据源(MySQL + SqlServer)这套方案做个完整复盘,配合 MyBatisPlus 做查询和分页测试,把配置过程、踩过的坑、排查思路全部写清楚。新手可以照着配置直接复现,老手可以重点看事务、分页、SqlServer 连接这些容易翻车的地方。
1. 项目背景:什么情况下非要多数据源不可
1.1 典型的双库业务场景
我接过的项目中,多数据源的诉求基本集中在三类场景。第一类是业务拆库,比如用户中心和订单中心拆成两个 MySQL 库,或者把实时数据放在 MySQL、历史归档放到 SqlServer。第二类是读写分离,主库负责写入,从库负责查询,减少单库压力。第三类是系统间数据整合,比如 SpringBoot 服务作为数据中台,需要同时拉取 MySQL、SqlServer 甚至 Oracle 的数据统一对外提供接口。本次项目其实就是第三类的简化版:MySQL 里存用户积分和行为数据,SqlServer 里存老系统同步过来的用户详细档案,两边通过用户 ID 关联,查询时要同时访问两个库。
这类需求听起来不复杂,但落到 SpringBoot 工程里就没有那么顺了。因为 SpringBoot 默认把数据源配置收敛得特别死,你配置一个spring.datasource.url,它就自动帮你创建好唯一的数据源,帮你自动配置好 JdbcTemplate、事务管理器、MyBatis 的 SqlSessionFactory。问题在于“唯一”两个字——当你试图在 yml 里写两套 url,或者手动声明两个 DataSource Bean 时,自动配置经常跳出来捣乱,报Failed to configure a DataSource之类的错误。
1.2 为什么 SpringBoot 原生配置搞不定多数据源
很多初学者会想:那我不就是写两个DataSource的 Bean,分别注入到不同的 Mapper 里不就行了?理论上是,但实际上你把两个DataSourceBean 丢给 Spring 容器时,容器不知道谁才是“默认”的那个,自动配置里后续很多依赖DataSource的组件会因歧义启动失败。你需要在每个使用点加@Primary、@Qualifier,还要手动配置多个SqlSessionFactory和多个SqlSessionTemplate,再把不同 Mapper 分包放好,指定各自的扫描路径。这套手工操作视频教程不少,但工程复杂度陡增,尤其是事务和分页一掺和进来,代码很快就失控了。
我自己最早也是踩的手工方案,项目前期确实能跑,但后来加新功能时总在“这个 Mapper 走哪个 SqlSessionFactory”“这个事务绑定到哪个数据源”上反复纠结。后来我彻底切到了注解驱动的动态数据源方案,核心是用@DS注解在方法或类上直接声明数据源,切换逻辑交给 AOP 切面处理,这才算把多数据源这件事真正简化了。这也是我写这篇笔记的出发点:配置简单、代码侵入少、后续维护成本低。
2. 方案选型与核心原理:动态数据源是怎么工作的
2.1 三种实现多数据源的方案对比
先看一个方案对比表,方便你做技术选型时心里有数:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 手动配置多个 SqlSessionFactory | 手写多个 DataSource、多个 SqlSessionFactory、多个 Mapper 扫描包 | 灵活,无额外依赖 | 代码量大,事务和分页配置复杂,易踩坑 |
| JTA 分布式事务方案 | Spring + Atomikos / Narayana,统一管理多个数据源事务 | 能实现跨库强一致事务 | 依赖较重,启动慢,性能开销大 |
| 注解驱动动态数据源 | baomidou 的dynamic-datasource-spring-boot-starter,AOP 拦方法切换连接 | 配置轻量,代码无侵入,与 MyBatisPlus 生态契合 | 跨库事务仍需额外组件搭配,强一致场景要做权衡 |
本次项目选用动态数据源方案,核心考虑点有两个。第一,大部分业务场景对跨库事务的要求并没有那么高,尤其是“查一个库再查另一个库拼装数据”这种场景,本身就不需要强一致事务;第二,团队里维护项目的成员水平参差不齐,注解切换的方式最容易被接受,新人看一眼@DS("sqlserver")就明白当前方法连的是哪个库。
2.2 动态数据源插件的核心原理简析
动态数据源的底层原理其实不神秘,它就是基于 Spring 的AbstractRoutingDataSource做了一层封装。AbstractRoutingDataSource本身是个路由数据源,它持有一组真实的 DataSource,通过determineCurrentLookupKey()方法返回一个 key,然后从 Map 里拿到对应的真实连接。动态数据源插件做的就是两件事:一是把这个 key 的管理从手动变成了自动,二是通过 AOP 在带有@DS注解的方法执行前,把 key 塞进当前线程的 ThreadLocal,方法执行完再清掉,保证不同线程之间互不干扰。
所以它的核心链路是:请求进入带@DS的 Service 方法,AOP 切面捕获注解值,写入 ThreadLocal,然后 MyBatis 执行 SQL 前从数据源路由中取连接,路由根据 ThreadLocal 里的值判断是 MySQL 还是 SqlServer,取出对应连接执行 SQL。方法结束后,切面在 finally 块里清空 ThreadLocal,避免线程复用导致的数据源串线。理解了这个机制,后面遇到“数据源切不过去”“连接串库”之类的问题,你就能快速定位到是注解没生效还是 ThreadLocal 没清理干净。
3. 环境准备与工程配置:一步步把连接配起来
3.1 依赖引入与版本踩坑提醒
本次项目是 SpringBoot 2.7 + MyBatisPlus 3.5.3,动态数据源用的 3.5.1。在pom.xml里核心依赖如下:
<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.5.1</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <dependency> <groupId>com.microsoft.sqlserver</groupId> <artifactId>mssql-jdbc</artifactId> <version>9.4.1.jre8</version> </dependency>版本这里有个很实际的坑,我实测下来必须提醒一句:动态数据源 starter 的大版本必须跟 SpringBoot 主版本匹配。3.5.1 这个版本对 SpringBoot 2.x 很稳定,但如果你工程是 SpringBoot 3.x,最好把动态数据源升到 3.5.2 以上,否则启动时可能出现 Jackson 序列化器、依赖包版本冲突之类的怪问题。另外很多人不知道 MyBatisPlus 分页插件在 3.5.x 之后 API 变了,老项目里常见的PaginationInterceptor已经不能用,要用新的MybatisPlusInterceptor,这个放到第四章再细说。
3.2 数据库准备:MySQL 和 SqlServer 各建一张测试表
为了完整演示多数据源查询和分页,我在两个库里建了结构完全不同的表,这样更能体现实际业务里的“差异化”。MySQL 里建用户行为表:
CREATE DATABASE db_master DEFAULT CHARACTER SET utf8mb4; USE db_master; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, age INT, score DECIMAL(10, 2), email VARCHAR(100), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO sys_user(username, age, score, email) VALUES ('zhangsan', 25, 95.50, 'zhangsan@example.com'), ('lisi', 30, 88.00, 'lisi@example.com'), ('wangwu', 28, 76.20, 'wangwu@example.com');SqlServer 里建用户档案表:
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'db_slave') BEGIN CREATE DATABASE db_slave; END; GO USE db_slave; GO CREATE TABLE sys_user_info ( id INT PRIMARY KEY IDENTITY(1, 1), user_code VARCHAR(20) NOT NULL, real_name NVARCHAR(50) NOT NULL, dept_name NVARCHAR(100), card_no VARCHAR(20), salary DECIMAL(10, 2), record_time DATETIME DEFAULT GETDATE() ); GO INSERT INTO sys_user_info(user_code, real_name, dept_name, card_no, salary) VALUES ('zhangsan', N'张三', N'技术部', '0101', 15000), ('lisi', N'李四', N'市场部', '0102', 13500), ('wangwu', N'王五', N'财务部', '0103', 18000);这里有个细节:SqlServer 表字段我故意用了user_code而不是username,就是为了演示多数据源下两个库的表结构可以完全不一致,你可以按各自的业务习惯建表,代码层面通过@DS切换数据源后再用对应的 Mapper 去查,互不影响。
3.3 核心配置文件解析
application.yml是多数据源配置的重头戏,直接决定了动态数据源能不能正常启动。以下是我项目里验证过的配置:
spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/db_master?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123456 driver-class-name: com.mysql.cj.jdbc.Driver sqlserver: url: jdbc:sqlserver://localhost:1433;DatabaseName=db_slave;encrypt=true;trustServerCertificate=true username: sa password: Sa123456 driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver druid: initial-size: 5 max-active: 20 min-idle: 5 validation-query: SELECT 1配置里primary: master说明默认数据源是master,也就是说没有加@DS注解的方法,默认走 MySQL。strict: false表示如果找不到对应的数据源 key,不会抛异常而是退回默认数据源。这两个参数建议保持现在这个设置,尤其是strict在联调阶段设为 false 能省不少事,万一某条链路写错了 key,系统不会直接挂,最多是拿到默认数据源。
我个人的习惯是把 MySQL 设为默认数据源,因为大部分业务读写都在 MySQL,SqlServer 更多是作为只读的伴生数据源。如果你的项目反过来,大部分操作在 SqlServer,那把primary改成sqlserver即可。另外注意连接池参数我这里用到了 Druid,需要在 pom 里加druid-spring-boot-starter,如果不想用连接池,直接去掉dynamic.druid段也行,会落到 HikariCP 默认参数上。
4. 实操落地:MyBatisPlus 多数据源访问完整流程
4.1 实体类与 Mapper 的书写方式
数据源配好后,代码层面的编写跟普通单数据源没有太大区别,唯一多出来的就是@DS注解。MySQL 这边的实体类:
@Data @TableName("sys_user") public class SysUser { @TableId(type = IdType.AUTO) private Long id; private String username; private Integer age; private BigDecimal score; private String email; @TableField("created_at") private LocalDateTime createdAt; }SqlServer 那边的实体类:
@Data @TableName("sys_user_info") public class SysUserInfo { @TableId(type = IdType.AUTO) private Integer id; private String userCode; private String realName; private String deptName; private String cardNo; private BigDecimal salary; private LocalDateTime recordTime; }Mapper 层还是最朴素的写法,不需要额外指定数据源,因为切面会直接拦截@DS,所以 Mapper 自己保持纯净即可:
public interface SysUserMapper extends BaseMapper<SysUser> { } public interface SysUserInfoMapper extends BaseMapper<SysUserInfo> { }实体类和 Mapper 这里要提醒一个容易踩的细节:多数据源场景下,不同库的表字段命名风格经常不一样,比如 MySQL 是下划线created_at,SqlServer 有可能是驼峰recordTime。MyBatisPlus 默认开启了map-underscore-to-camel-case,如果表字段是大驼峰或纯大写,建议在实体字段上用@TableField显式指明确切列名,别完全依赖自动映射,否则查询出来某些字段一直为 null,排查半天才发现是方言映射的锅。
4.2 @DS 注解的三种切换姿势
@DS注解可以标注在类上,也可以标注在方法上。我实测下来,三种写法对应三种不同的业务节奏。第一种是类级别固定,如果某个 Service 整体只服务 SqlServer,直接在类上声明:
@Service @DS("sqlserver") public class SysUserInfoService { @Autowired private SysUserInfoMapper sysUserInfoMapper; public List<SysUserInfo> listAllInfo() { return sysUserInfoMapper.selectList(null); } }第二种是方法级别按需切换,同一个 Service 里既有 MySQL 逻辑又有 SqlServer 逻辑,通过方法上的注解实现局部切换:
@Service public class UserDataService { @Autowired private SysUserMapper sysUserMapper; @Autowired private SysUserInfoMapper sysUserInfoMapper; @DS("master") public List<SysUser> listMasterUsers() { return sysUserMapper.selectList(null); } @DS("sqlserver") public List<SysUserInfo> listSlaveUsers() { return sysUserInfoMapper.selectList(null); } }第三种是默认路由不写注解,方法直接从配置文件里的primary数据源取连接,这里不再赘述。注解的优先级规则是:方法上的@DS优先于类上的@DS,类上没有就用全局默认。我建议除非整个 Service 极其纯粹,否则尽量使用方法级注解,因为类级注解容易让后来接手的人误以为全类都可以用某个数据源,结果新增一个方法没注意类上的@DS就被带偏了。
4.3 跨库查询与分页测试
我这次做的最核心测试,就是在一个业务接口里先查 MySQL 的用户行为数据,再查 SqlServer 的用户档案,然后把两边数据按username关联起来返回给前端,实测下来路由切换非常顺畅。核心代码如下:
@Service public class UserFacadeService { @Autowired private UserDataService userDataService; public List<Map<String, Object>> combineUserData() { List<SysUser> masterUsers = userDataService.listMasterUsers(); List<SysUserInfo> slaveUsers = userDataService.listSlaveUsers(); Map<String, SysUserInfo> infoMap = slaveUsers.stream() .collect(Collectors.toMap(SysUserInfo::getUserCode, Function.identity())); return masterUsers.stream().map(user -> { Map<String, Object> result = new HashMap<>(); result.put("username", user.getUsername()); result.put("score", user.getScore()); SysUserInfo info = infoMap.get(user.getUsername()); if (info != null) { result.put("realName", info.getRealName()); result.put("deptName", info.getDeptName()); } return result; }).collect(Collectors.toList()); } }这段代码核心在于:两个数据源查询分别落在不同 Service 方法上,每个方法都有明确的@DS注解,AOP 切面在调用进入方法时完成数据源切换,方法结束清场,所以外部看就是一个普通 Service 在调用另一个 Service,内部却跨越了两个数据库,不会串线。
分页测试这边,MyBatisPlus 3.x 需要在配置类里显式声明分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页代码如下:
public IPage<SysUser> pageMasterUsers(int pageNum, int pageSize) { Page<SysUser> page = new Page<>(pageNum, pageSize); return sysUserMapper.selectPage(page, null); }这里有一个多数据源场景下特有的坑:PaginationInnerInterceptor在构造时需要指定DbType。如果固定写成DbType.MYSQL,那么到 SqlServer 库去分页时,生成的方言还是 MySQL 的LIMIT写法,SqlServer 直接语法报错。反过来固定DbType.SQL_SERVER,MySQL 这边又用不了。如果你跟我一样同时要分页两个异构数据库,我的解决方案是不要把分页操作混在一个 Mapper 里,而是把 MySQL 分页和 SqlServer 分页拆到各自的 Service 方法里,分别用各自的环境上下文去处理。如果你对 MyBatisPlus 的自动识别机制比较了解,也可以考虑不显式传DbType让它自动推断,但实测在动态数据源下偶尔会识别成默认主库的类型,不够稳定,所以还是拆开最靠谱。
另外我再强调一个高频翻车点:很多人做完分页发现selectPage返回的records是空的,但 total 有值,十有八九是分页插件没生效。3.x 里如果你还在配置类放PaginationInterceptor这种旧写法,SpringBoot 只会在日志里给个过时警告,插件实际不拦截 SQL,分页自然就失效了。再一个就是网上有人为了防全表扫描在分页拦截器里加了setMaxLimit(500),结果后续业务一查超过 500 条就莫名被截断,还以为系统有 bug。这个参数一定要知道是谁加的、为什么加,不然排查起来非常误导。
5. 常见问题与排查技巧实录
5.1 多数据源高频问题速查表
我把这段时间实操中遇到的高频问题整理成一个速查表,大家对照着看基本能解决 80% 的启动和运行异常:
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
启动报Failed to configure a DataSource | 动态数据源配置没读到,或 yml 缩进错误导致dynamic.datasource没生效 | 检查 yml 缩进,确认spring.datasource.dynamic前缀绝对正确 |
所有 SQL 都走同一个库,@DS没反应 | 注解加错了位置,比如加到了 Mapper 接口上但没有对应切面扫描路径;或者@DS注解被类上同名注解覆盖 | 确认 AOP 切面扫描到该 Service,方法级注解优先于类级 |
运行时提示找不到数据源 key,比如Cannot find datasource: slave | yml 里数据源名称和@DS里的值不一致 | 检查名称拼写是否完全一致,注意大小写 |
| 多线程异步任务里数据源切错 | @Async与@DS同时用时,ThreadLocal 在子线程里丢失了 | 异步方法内部手动指定数据源,或者确保切面在真正执行的线程里生效 |
| Json 序列化 LocalDateTime 报错 | 没配 Jackson 时间序列化规则 | 配置spring.jackson.date-format,或加jackson-datatype-jsr310依赖 |
| MyBatisPlus 分页无效 | 分页插件没注册,或者用了旧版PaginationInterceptor | 改用MybatisPlusInterceptor+PaginationInnerInterceptor |
| 分页跨库方言报错 | DbType固定成单一类型 | 分页按库拆分,或确保同一 Mapper 只在同一类型数据库上分页 |
5.2 事务与数据源切换的相爱相杀
多数据源场景下最隐蔽的坑就是事务和数据源切换打架。我刚开始跑的时候,在一个@Transactional方法里尝试切换数据源,代码如下:
@Transactional public void testWrongTransaction() { sysUserMapper.insert(user); // 默认走 master 库 sysUserInfoMapper.insert(info); // 想切到 sqlserver }运行结果很诡异:第二次插入操作没有报错,但数据根本没有进 SqlServer。原因是 Spring 的声明式事务一旦开启,事务管理器会绑定一个数据源连接,并把这个连接放到当前线程的事务资源里。后续即使 AOP 切面把 ThreadLocal 里的数据源 key 改了,MyBatis 拿到的还是事务开始时的那个连接,导致@DS切换失效。
我的处理建议分三种情况。如果两项操作不需要强一致事务,就把它们拆到两个不带@Transactional的 Service 方法里,由上层编排调用,每条链路各自保证原子性。如果确实需要跨库事务,那就得引入 Seata 或可靠消息之类的分布式事务方案,这属于另一个话题了,本次不展开。如果只是单库事务,把@Transactional加在具体那个库对应的 Service 方法上就好,不要在跨库编排的方法上乱加事务。简单记住一句话:@Transactional会锁定连接,锁定了就别指望@DS翻盘。
5.3 SqlServer 连接与 SQL 的特殊坑
SqlServer 和 MySQL 虽然是老牌数据库,但使用习惯差异很大,我第一次连的时候踩了好几个坑。首先是驱动选择,老项目里很多人还在用sqljdbc4这个历史包袱,新项目我建议直接用mssql-jdbc,Maven 坐标是com.microsoft.sqlserver:mssql-jdbc:9.4.1.jre8。JDBC 连接串写法也不一样,端口是 1433,数据库名要用DatabaseName参数:
jdbc:sqlserver://localhost:1433;DatabaseName=db_slave;encrypt=true;trustServerCertificate=true如果忘了加encrypt=true和trustServerCertificate=true,新版驱动和 SqlServer 2019+ 之间经常会报 SSL 连接错误。另外有人用 SQL Server 实例名连接时踩过坑,比如jdbc:sqlserver://localhost\\SQLEXPRESS;DatabaseName=xxx这类写法在驱动里解析容易出问题,建议服务端固定端口后用 IP + 端口连,省掉实例名的解析麻烦。
连接没问题之后,SQL 方言的坑也不少。热词里提到的“sqlserver 字符串转数字”我实际也遇见过,比如用户传过来的字符串是'123.45',你直接CAST('123.45' AS INT)会报错,因为 SqlServer 不允许隐式把带小数点的字符串转成整数,必须先转成DECIMAL(10, 2)再处理:
SELECT CAST(CAST('123.45' AS DECIMAL(10, 2)) AS INT);还有“sqlserver 多行合并成一行”,MySQL 里有GROUP_CONCAT,SqlServer 里则要用FOR XML PATH('')或STRING_AGG(2017+)。这些细节看起来跟多数据源无关,但你在写跨库兼容的业务代码时,SQL 方言差异会直接影响漏数据还是报错,尤其是从 MySQL 迁移过来的同学最容易在这上面栽跟头。
5.4 连接池配置与监控优化
多数据源项目里,连接池配置往往被忽视。我见过不少项目直接把max-active写 100,结果两个库加起来连接数爆炸,数据库直接被拖垮。动态数据源 + Druid 场景下,我建议按库分别设置合理的连接池大小,通常单库 5 到 20 就够了,热点业务库可以适当调高。下面这段是我后来优化过的:
spring: datasource: dynamic: druid: initial-size: 5 max-active: 20 min-idle: 5 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000validation-query我特意写成了SELECT 1,MySQL 和 SqlServer 都认这个写法,如果写成SELECT 1 FROM DUAL那只有 Oracle 认,SqlServer 直接报错。Druid 监控页也可以一并开启,方便看两个数据源的连接使用率和慢 SQL:
spring: datasource: druid: stat-view-servlet: enabled: true url-pattern: /druid/* login-username: admin login-password: admin123开启之后访问/druid就能看到每个数据源的活跃连接数、执行 SQL 次数、慢查询统计,对排查多数据源下奇奇怪怪的连接问题帮助很大。我建议上线前至少观察几天监控页,确认两个库的连接分配曲线正常,再逐步调整连接池参数。
最后再分享一个经验:多数据源项目里,命名规范真的很重要。数据源 key 不要用中文也不要带空格,统一小写英文,master、slave1、sqlserver这种一眼能看懂就很好。项目维护周期一长,代码里到处是@DS("aaa")这种不解释就没人懂的 key,排查问题会让你崩溃的。如果后续你还要扩展 MinIO 之类的中间件到 SpringBoot,或者做读写分离、多租户隔离,动态数据源这套思路都能继续复用,只需要在这个基础上叠加对应的策略即可。