SpringBoot多数据源配置实战:MySQL与SqlServer动态切换与MyBatisPlus分页
我第一次把 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-starterAOP 拦方法切换连接配置轻量代码无侵入与 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 groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version3.5.1/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.30/version /dependency dependency groupIdcom.microsoft.sqlserver/groupId artifactIdmssql-jdbc/artifactId version9.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, zhangsanexample.com), (lisi, 30, 88.00, lisiexample.com), (wangwu, 28, 76.20, wangwuexample.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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root123456 driver-class-name: com.mysql.cj.jdbc.Driver sqlserver: url: jdbc:sqlserver://localhost:1433;DatabaseNamedb_slave;encrypttrue;trustServerCertificatetrue 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 设为默认数据源因为大部分业务读写都在 MySQLSqlServer 更多是作为只读的伴生数据源。如果你的项目反过来大部分操作在 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 BaseMapperSysUser { } public interface SysUserInfoMapper extends BaseMapperSysUserInfo { }实体类和 Mapper 这里要提醒一个容易踩的细节多数据源场景下不同库的表字段命名风格经常不一样比如 MySQL 是下划线created_atSqlServer 有可能是驼峰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 ListSysUserInfo listAllInfo() { return sysUserInfoMapper.selectList(null); } }第二种是方法级别按需切换同一个 Service 里既有 MySQL 逻辑又有 SqlServer 逻辑通过方法上的注解实现局部切换Service public class UserDataService { Autowired private SysUserMapper sysUserMapper; Autowired private SysUserInfoMapper sysUserInfoMapper; DS(master) public ListSysUser listMasterUsers() { return sysUserMapper.selectList(null); } DS(sqlserver) public ListSysUserInfo listSlaveUsers() { return sysUserInfoMapper.selectList(null); } }第三种是默认路由不写注解方法直接从配置文件里的primary数据源取连接这里不再赘述。注解的优先级规则是方法上的DS优先于类上的DS类上没有就用全局默认。我建议除非整个 Service 极其纯粹否则尽量使用方法级注解因为类级注解容易让后来接手的人误以为全类都可以用某个数据源结果新增一个方法没注意类上的DS就被带偏了。4.3 跨库查询与分页测试我这次做的最核心测试就是在一个业务接口里先查 MySQL 的用户行为数据再查 SqlServer 的用户档案然后把两边数据按username关联起来返回给前端实测下来路由切换非常顺畅。核心代码如下Service public class UserFacadeService { Autowired private UserDataService userDataService; public ListMapString, Object combineUserData() { ListSysUser masterUsers userDataService.listMasterUsers(); ListSysUserInfo slaveUsers userDataService.listSlaveUsers(); MapString, SysUserInfo infoMap slaveUsers.stream() .collect(Collectors.toMap(SysUserInfo::getUserCode, Function.identity())); return masterUsers.stream().map(user - { MapString, 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 IPageSysUser pageMasterUsers(int pageNum, int pageSize) { PageSysUser page new Page(pageNum, pageSize); return sysUserMapper.selectPage(page, null); }这里有一个多数据源场景下特有的坑PaginationInnerInterceptor在构造时需要指定DbType。如果固定写成DbType.MYSQL那么到 SqlServer 库去分页时生成的方言还是 MySQL 的LIMIT写法SqlServer 直接语法报错。反过来固定DbType.SQL_SERVERMySQL 这边又用不了。如果你跟我一样同时要分页两个异构数据库我的解决方案是不要把分页操作混在一个 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: slaveyml 里数据源名称和DS里的值不一致检查名称拼写是否完全一致注意大小写多线程异步任务里数据源切错Async与DS同时用时ThreadLocal 在子线程里丢失了异步方法内部手动指定数据源或者确保切面在真正执行的线程里生效Json 序列化 LocalDateTime 报错没配 Jackson 时间序列化规则配置spring.jackson.date-format或加jackson-datatype-jsr310依赖MyBatisPlus 分页无效分页插件没注册或者用了旧版PaginationInterceptor改用MybatisPlusInterceptorPaginationInnerInterceptor分页跨库方言报错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-jdbcMaven 坐标是com.microsoft.sqlserver:mssql-jdbc:9.4.1.jre8。JDBC 连接串写法也不一样端口是 1433数据库名要用DatabaseName参数jdbc:sqlserver://localhost:1433;DatabaseNamedb_slave;encrypttrue;trustServerCertificatetrue如果忘了加encrypttrue和trustServerCertificatetrue新版驱动和 SqlServer 2019 之间经常会报 SSL 连接错误。另外有人用 SQL Server 实例名连接时踩过坑比如jdbc:sqlserver://localhost\\SQLEXPRESS;DatabaseNamexxx这类写法在驱动里解析容易出问题建议服务端固定端口后用 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_CONCATSqlServer 里则要用FOR XML PATH()或STRING_AGG2017。这些细节看起来跟多数据源无关但你在写跨库兼容的业务代码时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 1MySQL 和 SqlServer 都认这个写法如果写成SELECT 1 FROM DUAL那只有 Oracle 认SqlServer 直接报错。Druid 监控页也可以一并开启方便看两个数据源的连接使用率和慢 SQLspring: 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或者做读写分离、多租户隔离动态数据源这套思路都能继续复用只需要在这个基础上叠加对应的策略即可。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →