在Spring Boot项目里做多数据源,尤其是同时连接MySQL和SQL Server,听起来是个老话题,网上教程一抓一大把。但真正落地的时候,你会发现那些教程几乎都停留在"能跑通"的层面——两个数据源都能查到数据,就宣布大功告成了。等你把MyBatis-Plus、事务、分页、连接池、缓存这些东西全塞进去,问题才会一个一个冒出来。
这篇文章我想换个写法,不讲那些配置两个DataSource、两个SqlSessionFactory的重复内容,而是从项目经理的角度聊一聊:在SpringBoot+MyBatisPlus的项目里接入MySQL和SQL Server双数据源时,你最可能在什么时候翻车,翻车之后怎么从日志和现象倒推根因,以及哪些决策能让你的代码在半年后依然容易维护。
1. 为什么"两个DataSource"方案在MyBatis-Plus下会越来越难受
很多人的第一反应是,多数据源嘛,定义两个DataSource,生成两个SqlSessionFactory,然后Mapper分包绑定,各用各的。这个方案在最简单的场景下确实没问题:比如MySQL管用户,SQL Server管报表,两边业务完全隔离。
但只要你用了MyBatis-Plus,迟早会遇到四个尴尬场面:
第一,MyBatis-Plus的BaseMapper方法是在SqlSessionFactory的全局配置里定义好的。如果你给两个SqlSessionFactory各自扫描不同包下的Mapper,确实能用,但是一旦出现"同一个Mapper接口既要在MySQL上跑,又要在SQL Server上跑"的需求,这套分包方案就直接裂开。你得把同一个Mapper复制成两份,改个包名,让人非常难受。
第二,代码里到处都是重复的Mapper接口。比如sys_user这个实体,MySQL和SQL Server各有一张同名字段表,你为了使用MyBatis-Plus的CRUD方法,就得建两个UserMapper,一个查到MySQL,另一个查到SQLServer。这还不算完,如果业务上需要联合查询、分页统计,你还要处理两套Mapper返回类型不一致的问题。
第三,事务管理器的归属会变得非常微妙。Spring Boot默认的事务管理器只管主数据源。你要是在Service层加了@Transactional,它默认绑定到主数据源,如果你这个方法的内部操作了另一个数据源,事务就只覆盖了部分操作。这个问题在你用分包方案时尤其隐蔽,因为你压根感觉不到。
第四,MyBatis-Plus的分页插件是多数据源场景下的重灾区。分页插件需要绑定数据库方言(Dialect),你在两个SqlSessionFactory都注册了同样的PaginationInnerInterceptor,但方言却只能配一个。配了MySQL的,SQL Server分页就报错;配了SQL Server的,MySQL分页页面直接乱套。
我并不是说分包方案完全不能做,而是说这套方案把"多数据源"的复杂度转嫁到了代码结构上,短期内看不出问题,一旦业务模块需要跨库读写,或者需要复用Mapper能力,维护成本就会指数级上升。所以我自己在实际项目中,更倾向于引入一个抽象层来做动态数据源路由,而不是物理隔离两个SqlSessionFactory。这也是我下面要展开的内容——一个在MyBatis-Plus生态里已经非常成熟的方案:基于AbstractRoutingDataSource的动态数据源。
2. 动态数据源路由:用@DS注解替代两个SqlSessionFactory的拆包
谈到Spring Boot多数据源,大多数人第一个想到的方案可能是维护两个SqlSessionFactory。但我在实际项目中用下来,这个方案在MyBatis-Plus场景下确实不省心。
先说明白我的结论:在一个同时使用MyBatis-Plus的项目里,我更倾向于用动态数据源路由,而不是物理隔离两个SqlSessionFactory。为什么这么说,我下面详细展开。
先说为什么要用动态数据源。多数据源的本质,是让应用在运行过程中,根据不同的业务请求,动态地切换到不同的数据库实例。手动定义两个DataSource再分别注册到不同SqlSessionFactory,本质上还是静态隔离——代码里就必须分清楚哪个Mapper属于哪个Factory。一旦出现同一个Mapper需要跨库操作的场景,比如你有一个UserMapper,既想查MySQL的用户表,又想查SqlServer里的用户备份表,静态隔离的方案就非常别扭,要么复制Mapper,要么写通用SQL,绕来绕去。
动态数据源则是在运行时,基于一个路由键(比如@DS注解里的值),从一组DataSource中挑选一个当前可用的连接。MyBatis-Plus生态里有一款知名开源组件,核心就是利用Spring的AbstractRoutingDataSource来实现这个路由逻辑,在Service层或Mapper层标注一个注解,整个方法的数据库连接都走这个数据源。
我搭过的最小可行结构是这样的:一个DataSourceRouter管理多个真实数据源,其中一个是默认数据源,通常是MySQL主库;另外注册一个SqlServer数据源。业务代码里用注解切库。
spring: datasource: dynamic: primary: master strict: false datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mysql_main_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root sqlserver: driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver url: jdbc:sqlserver://localhost:1433;DatabaseName=sqlserver_db;encrypt=false username: sa password: sqlserver_pass依赖方面,直接用这个组件的starter就行:
<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>4.3.0</version> </dependency>然后业务Service方法只需要标注:
@DS("sqlserver") public List<ReportData> queryReportData() { return reportDataMapper.selectList(null); }调用方不需要关心SqlSessionFactory怎么绑定,只需要意识到当前方法在操作哪个库。这套方案最直接的收益是:你的Mapper可以复用,同一个Mapper方法在不同Service方法里可以路由到不同数据源。这在"从A库读数据,写进B库"这类典型场景里,效率极高。
3. 路由规则里最容易踩的坑:@DS注解的作用域与优先覆盖规则
用动态数据源组件有个核心概念必须搞清楚:@DS注解在什么位置生效。
这个注解在组件内部是基于切面实现的。切面拦截的是标注了@DS的方法调用,在进入这个方法时,把数据源名称绑定到当前线程;方法执行完,再把绑定清掉。这里有几个规则是我实际踩过坑才真正理解的:
- @DS标注在一个Service方法上,那么这个方法内所有数据库操作,都走该数据源。
- @DS标注在一个Mapper接口方法上,那么该方法被执行时,只对该方法生效。
- 如果Service方法标注了@DS,它内部又调用了另一个标注了不同@DS的Service方法,那么内层方法会覆盖外层方法的数据源,执行完毕后重置。
- 如果方法没有标注@DS,默认走primary数据源。
这个覆盖规则非常关键。我在实际项目中碰到过一次:外层Service方法加了一个@DS("mysql"),想统一走MySQL,然后内层调用了一个定时统计方法,那个方法身上还残留着@DS("sqlserver")的注解。结果内层方法一执行,数据源就切到了SQL Server,等统计完回来,外层后续代码继续操作SQL Server,整个接口直接报错。当时的日志看起来像是数据源配置有问题,排查了半天才发现是内层@DS覆盖了外层。
后来我定了两条团队规范:第一,Service方法之间的调用,如果外层已经指定了数据源,内层原则上不得再标@DS,除非内层是独立业务且确确实实要切库;第二,如果确实需要在同一个业务方法里先查MySQL再查SQL Server,就把这两个操作拆成两个独立的Service方法,分别标注@DS,然后在外部用一个不带数据源注解的编排方法去调用它们。这样做的原因是:@DS本质上是给当前线程的一次"临时路由",你让它在方法间传话,传着传着就会掉进覆盖规则的陷阱里。
还有一点容易被忽略:@DS标在Mapper接口上虽然是允许的,但我个人建议不要滥用。比如上百个Mapper方法每个都标一遍@DS,那代码看起来就非常喧嚣。更合理的方式是,在Service层做路由决策,Mapper层尽量保持对数据源无感。
4. 事务与多数据源的边界:@Transactional管不了跨库事务
说完路由,必然要聊事务。多数据源项目里,对事务的错误理解是性能问题的隐藏根源,更严重的情况下会造成数据不一致。
事务管理器只能管一个数据源的事务。MyBatis-Plus默认的事务管理器是基于某个数据源创建的。你用@Transactional标注一个Service方法,这个方法内的所有操作,会在同一个事务里执行,但前提是这些操作全都发生在同一个数据源上。如果你在一个事务方法里,先查MySQL再写SQL Server,那么事务只对其中一个库生效,另一个库的操作是独立提交的。
举一个典型的失败案例。我当时在做订单同步,业务逻辑是:先从MySQL订单库查出待同步的订单列表,然后一条条写入SQL Server的报表库。我在Service方法上加了一个@Transactional,心想任意一步失败都能回滚,还特意用了try-catch。结果测试的时候发现,MySQL查询的事务其实没有生效,而SQL Server的写入如果中间失败了,MySQL这边已经完成的操作死活回滚不掉。排查下来才明白:这个Service方法没有加@DS,它默认走的是MySQL,但@Transactional绑定的连接来自主数据源。表面上看起来一切正常,实际上事务边界只覆盖了MySQL那一段;写入SQL Server用的是另一条连接,根本不属于这个事务。
所以我在项目里定了几个死规矩:
- 一个Service方法,只对一个数据源做写操作。如果需要"读MySQL,写SQL Server",就拆成两个方法,分别控制事务。
- 方法上面的@DS一定要和事务边界匹配。你想让哪个数据源参与事务,就必须让路由在事务开启前落到那个数据源上。
- 不要指望@Transactional能帮你管理跨库一致性。真正需要跨库事务的场景,我建议引入分布式事务方案,或者用简单的"本地消息表+补偿任务"来兜底。
很多人会把"多数据源"和"跨库事务"混为一谈,觉得多数据源方案里应该自带分布式事务,其实不是。多数据源只是解决了"连接哪台数据库"的问题,事务的一致性边界仍然需要自己设计清楚。
5. 从"全在一个库里"迁移到"分库数据源":三个值得提前做的梳理
如果你不是新项目从零开始搭建多数据源,而是像我之前那样,从"所有表都在MySQL"逐步迁移到"MySQL + SQL Server各管一部分业务",那有几个梳理工作越早做越好。
第一步,做数据源映射清单。不需要多复杂,一张表格即可:
| 业务模块 | 数据源 | 数据库类型 | 核心表 | | 用户体系 | master | MySQL | sys_user, sys_role, sys_menu | | 订单中心 | master | MySQL | order_info, order_detail | | 报表中心 | sqlserver | SQL Server | report_data, report_config |
这张表的作用,不只是给开发人员看,更重要的是能帮你一眼看出哪些模块还依赖"默认数据源"这个隐式路由。
第二步,梳理现有代码里所有Mapper的归属。MyBatis-Plus的Mapper扫描默认是全局的,你给某个Mapper加@DS,只是影响它执行时的连接指向;如果不加,默认走primary。很多人在迁移初期会犯一个错误:只给新的Mapper加@DS("sqlserver"),老Mapper全都靠"默认走MySQL"这个隐式规则。表面看没问题,时间久了,一旦你把primary切换成另一个库,或者某个同事新建了一个Mapper忘记加@DS,数据就会悄然写到错误的地方。更稳妥的做法是:从一开始就显式标注所有Mapper,哪怕是走MySQL的也标上@DS("master")。这样代码的意图非常清晰,也避免了隐式依赖。
第三步,处理MyBatis-Plus的自动填充和分页插件。自动填充比如create_time、update_time这种字段,本身跟着Mapper走,数据源切换后照常生效。分页插件则要特别注意:dynamic-datasource组件虽然默认会为多数据源注册分页插件,但如果你没有在初始化时设置正确的数据库类型方言,分页SQL可能生成错误。比如SQL Server用的是OFFSET FETCH NEXT,MySQL用的是LIMIT,这两种方言在同一个项目里同时存在时,分页插件必须能感知当前数据源的数据库类型,自动选择对应方言。这一点如果你用的是旧版组件或自己拼的拦截器,很容易翻车,我会在后面的验证环节详细说。
6. 联调与验证:如何确认路由真的切到了正确的库
多数据源配置完成后,最重要的一件事是验证"当前线程路由到的数据库到底是不是你脑子里想的那一台。"这一步如果省了,后面所有基于这个连接的逻辑都可能是建立在错误的假设上。
我的验证方式分三层。
第一层,打数据库产品信息。我写了一个简单的Controller,分别调两个标注不同@DS的Service方法,每个方法里执行一条返回数据库名称或产品信息的SQL,把结果打到日志或页面上。比如MySQL就执行SELECT DATABASE(),SQLServer执行SELECT DB_NAME()。这样一看就知道路由到哪边了。
第二层,查实际业务数据。拿SQL Server里一条已知主键的数据,通过Mapper去查询,确认返回的字段值确实是SQL Server里的值,而不是MySQL里同名主键的值。这一步看着笨,其实非常必要,尤其当两边库里都有一张结构类似的业务表时,最容易出现"代码没报错但数据查错库"的问题。
第三层,看组件日志。dynamic-datasource在处理路由时会打印切换日志,比如"dynamic-datasource switch to sqlserver"之类,把日志级别调到DEBUG,整个请求链路中你就能看出切了几次库、每次切到哪。这一招在排查"为什么我明明标了@DS却还是查到了MySQL"这类问题时特别好使。
注意:验证路由时,不要只依赖启动日志。Spring Boot启动时可能会打印一堆数据源初始化信息,但那些并不能代表运行时每个方法的实际路由。必须在真实调用链路上做验证,比如写一个Test接口或单元测试。
我吃过最大的亏就是:启动日志显示两个数据源都初始化成功了,我以为万事大吉,结果实际请求全部落在默认库上,SQL Server那边一直没人访问。后来才发现,是某个Service方法漏标了@DS,而调用它的上层方法又没有数据源路由,层层传递下来,最后连接的还是master。这种静默失败,比直接报错难查得多。
7. 分页和批量操作为什么总在多数据源下炸锅
分页和批量,是MyBatis-Plus使用频率最高的两个功能,但在多数据源场景下,它们也是最容易翻车的两个点。
先说分页。MyBatis-Plus的分页插件在单数据源下非常好用,分页方言要么硬编码在配置里,要么通过数据库连接元数据自动判断。但多数据源就不一样了,MySQL和SQL Server的分页方言完全不一样。dynamic-datasource组件虽然做了方言适配,但你一定要确保你的版本是支持多方言自动切换的,不要自己在MybatisPlusInterceptor里写死DbType.MYSQL。否则你从MySQL切到SQL Server再执行分页查询,很可能会拿到一个拼接了LIMIT的SQL语句,然后SQL Server直接报语法错误。
这里多说一句,MyBatis-Plus的分页插件原理是改写SQL,生成带分页参数的count语句和limit语句。它改写的时候必须知道目标数据库是哪种,才能生成对应的分页语法。如果你用dynamic-datasource组件,它在实际执行时会接管DataSource的选择,所以分页插件的方言判断也要跟着动态走。
再谈批量。MyBatis-Plus的saveBatch和updateBatchById这类方法,底层通过SqlSession的批量执行模式来优化性能。但批量方法本身是绑定到某一个SqlSessionFactory的,而SqlSessionFactory又绑定到某个数据源。在多数据源模式下,如果你的批量操作跨了两个数据源,组件通常会把涉及多个数据源的操作拆开来处理,或者要求你先切好数据源再执行批量。我遇到的情况是,一次批量插入涉及几千条数据,一部分要进MySQL,一部分要进SQLServer,如果我没有在Service层把两个数据源拆成两个方法分别处理,而是在同一个方法里用循环调saveBatch,性能会很差而且容易报连接异常。
我的建议是,批量操作必须拆到数据源级别。一个方法只处理一个数据源的批量写入。如果业务上需要同时写两个库,就先分组成两个列表,各自走对应的@DS方法。不要试图在一个方法里来回切数据源做批量,组件不一定支持,即使支持也很容易把事务搅浑。
8. 同项目双数据库的运维注意事项:连接池、驱动、时区
多数据源上线后,运维层面的复杂度也会跟着翻倍。我在实际项目里总结出几个特别要注意的点。
第一个是连接池参数。每个数据源都会创建独立的连接池,如果你的项目同时连接MySQL和SQL Server,而两个连接池都配置了非常大的max-active,比如各50个连接,那在高峰期多个服务实例都在跑的情况下,数据库端很容易被打满。我的经验是:核心数据源(比如MySQL主库)可以配置大一点,连接池上限30-50;次要数据源(比如只做报表查询的SQL Server)尽量小,连接池上限10-15就够。因为报表查询通常并发不高,连接池开大了纯粹浪费。
第二个是驱动版本。多数据源项目最容易出现"系统环境不同导致驱动不兼容"的问题。MySQL的驱动和SQL Server的驱动必须分别引入,不要图省事混用。SQL Server的驱动是com.microsoft.sqlserver:mssql-jdbc,版本号要注意匹配你的JDK版本。我遇到过一台开发机JDK 17连不上SQL Server 2016,换了驱动版本才解决。多数据源里排查这种问题要费不少劲,因为日志里可能同时夹杂MySQL和SQL Server两类错误。
第三个是时区问题。MySQL连接串上通常有serverTimezone=Asia/Shanghai参数,SQL Server则有自己的datetime类型处理。如果你用同一个Java Date对象向两个库写入时间,由于两个数据库处理时区的逻辑不同,最终存进表里的时间可能不一致。再查询出来时,你可能会发现两边数据对不上。这个问题的根因不在代码,而在于两个数据库的时区配置和驱动行为。我现在的做法是,统一在应用层用UTC或固定的Asia/Shanghai时区生成时间字符串,再写入数据库,避免依赖数据库自带的时区转换。虽然不完美,但至少能保证行为一致。
9. 压测时最容易翻车的性能细节
当路由、事务、运维都理清了,最后一道关是性能。多数据源项目的性能问题,往往不是单库性能,而是"切换"带来的额外开销连接池争抢以及缓存错乱。
第一个细节是MyBatis-Plus的二级缓存。MyBatis-Plus默认是不开二级缓存的,但有些人会为了性能开启。二级缓存没有数据源概念,它绑定在Mapper namespace上。如果你的Mapper在多数据源下共用,比如同一个UserMapper既查MySQL又查SQLServer,二级缓存就麻烦了:第一次查MySQL的数据被缓存了,第二次你去查SQLServer相同key的数据,可能会直接命中MySQL的缓存。这就是典型的缓存串库。我的建议是,多数据源项目不要开MyBatis二级缓存,或者只在明确单数据源的Mapper上局部开启。图省事开全局二级缓存,等于给数据准确性埋雷。
第二个细节是动态数据源切换的耗时。路由本身很快,本质上是一次Map查找,但如果你在切库前后做了很多额外操作,比如打日志、打印栈帧、给每次切换做统计,那在高并发下这些开销就会放大。dynamic-datasource组件本身性能不错,真正影响性能的往往是你在业务代码里频繁手动切换。我之前写过一段代码,在一个循环里反复调用切库方法,因为每次循环都要查不同库,结果发现性能极差。后来改成一次循环只连一个库,把所有SQL编译好再切库执行,性能才上来。核心思路是:尽量延长单数据源会话时间,减少不必要的数据源切换次数。
第三个细节是连接池获取连接的等待时长。多数据源项目如果SQL Server的响应变慢,线程在获取SQL Server连接时可能会等待很久,而这个时候这些线程并没有释放它持有的MySQL连接,于是MySQL连接池也被拖死。这类连环故障在多数据源下特别典型。我后来给非核心数据源设置了较短的获取连接超时时间,比如3秒,一旦SQL Server拿不到连接就快速失败,让上层业务走降级逻辑,而不是所有线程都卡在等待连接上。
还有一个细节是流量高峰期的连接池耗尽问题。多数据源场景下,如果你有一个核心请求需要先查MySQL再查SQLServer,这个请求会占用两个连接池各一个连接。假设两个连接池都是20个连接,理论上实际承载的并发请求只有20。很多人在压测时只看单个连接池的QPS,漏算了"单请求跨库占用多连接"带来的并发上限下降。
10. 常见异常与排错清单
最后,我整理一份多数据源项目里高频出现的问题清单。这些问题都是我在实际开发中真实碰到过的,对应解法也都验证过。
| 现象 | 大概率原因 | 排查要点 | | 启动报"Failed to configure a DataSource" | 多数据源配置未生效或依赖缺失 | 确认是否引入了dynamic-datasource-spring-boot-starter,检查yml配置是否写对 | | 报错"invalid bound statement (not found)" | Mapper接口和XML映射位置不对 | 多数据源下要特别注意Mapper XML路径和MapperScan扫描范围 | | @DS切换不生效,始终查询默认库 | 切面未注册或方法被内部调用绕过代理 | 确认@DS标注在public方法上,且是通过Spring代理调用的 | | 跨库查询时连接池耗尽 | 跨库操作占用多个连接,且等待时间过长 | 调大连接池上限或缩短获取连接超时时间 | | 二级缓存命中错误数据 | 多数据源共用Mapper且开启了二级缓存 | 关闭二级缓存,或局部禁用 | | 分页方言不对,SQL Server报OFFSET语法错误 | 分页插件数据库方言未动态识别 | 检查MybatisPlusInterceptor的DbType配置 | | 批量保存报connection closed | 多数据源切换后SqlSession未正确归还连接 | 确保批量操作不跨数据源,或手动管理SqlSession | | 时间字段相差若干小时 | 驱动时区与数据库时区不一致 | 统一连接串serverTimezone,或应用层统一用UTC |
这些内容基本能够覆盖一个SpringBoot+MyBatisPlus项目接入MySQL和SQL Server多数据源时的大多数问题。多数据源本身不算难起步,难的是你在理解了路由机制之后,仍然要对事务边界和连接生命周期保持敬畏。我始终认为,多数据源不是一种值得炫耀的技术方案,而是一个存在必要性时的妥协手段。如果你能控制住代码中数据源的显式边界,并且把跨库操作限制在极少数场景里,这个方案用起来会很顺手。