尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot + MyBatis-Plus多数据源实战:MySQL与SQL Server避坑指南

Spring Boot + MyBatis-Plus多数据源实战:MySQL与SQL Server避坑指南 在Spring Boot项目里做多数据源尤其是同时连接MySQL和SQL Server听起来是个老话题网上教程一抓一大把。但真正落地的时候你会发现那些教程几乎都停留在能跑通的层面——两个数据源都能查到数据就宣布大功告成了。等你把MyBatis-Plus、事务、分页、连接池、缓存这些东西全塞进去问题才会一个一个冒出来。这篇文章我想换个写法不讲那些配置两个DataSource、两个SqlSessionFactory的重复内容而是从项目经理的角度聊一聊在SpringBootMyBatisPlus的项目里接入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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root sqlserver: driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver url: jdbc:sqlserver://localhost:1433;DatabaseNamesqlserver_db;encryptfalse username: sa password: sqlserver_pass依赖方面直接用这个组件的starter就行dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version4.3.0/version /dependency然后业务Service方法只需要标注DS(sqlserver) public ListReportData 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 NEXTMySQL用的是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连接串上通常有serverTimezoneAsia/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 |这些内容基本能够覆盖一个SpringBootMyBatisPlus项目接入MySQL和SQL Server多数据源时的大多数问题。多数据源本身不算难起步难的是你在理解了路由机制之后仍然要对事务边界和连接生命周期保持敬畏。我始终认为多数据源不是一种值得炫耀的技术方案而是一个存在必要性时的妥协手段。如果你能控制住代码中数据源的显式边界并且把跨库操作限制在极少数场景里这个方案用起来会很顺手。
返回列表