
1. 项目背景与多数据源需求拆解1.1 为什么一个项目会同时连上 MySQL 和 SqlServer在实际业务里一套服务对接多个数据库的需求比想象中要普遍。我这次遇到的项目就是一个典型的传统行业改造场景老系统跑在 SqlServer 上沉淀了十多年的历史数据包括订单流水、人员档案、流程审批记录这些数据不能说丢就丢也不可能短期内做完整迁移。而新开发的业务模块比如用户画像、消息推送、数据分析报表则更适合放到 MySQL 里毕竟这套技术栈更轻量生态也更活跃团队维护起来成本更低。项目要求很直白同一个 SpringBoot 服务里既要能查 SqlServer 里的历史数据又要能读写 MySQL 里的新业务数据还得保证切换过程对业务代码尽可能透明。翻译成技术语言就是要在 MyBatisPlus 这套 ORM 框架下实现多数据源接入和动态切换。这里先说明一个容易混淆的概念。多数据源和读写分离是两回事。读写分离是同一个数据库的主从副本结构完全一致只是分担读写的压力。多数据源则是多个独立的数据库实例结构可能完全不同甚至数据库类型都不一样——MySQL 和 SqlServer 在 SQL 语法、分页方式、数据类型上都有差异。本次项目属于后者难点在于不是简单配两个连接池就完事而是要让上层业务代码在几乎无感知的情况下完成数据源的切换。1.2 方案选型对比分包、AOP切面还是动态路由多数据源的实现方案业界主流有几种我简单梳理一下方便大家对照自己的场景选型。第一种叫分包方式也是最古老的做法。按照包名区分 dao 层接口比如com.example.mapper.mysql下的走 MySQL 数据源com.example.mapper.sqlserver下的走 SqlServer 数据源。每个数据源配置独立的SqlSessionFactory各管各的包互不干扰。优点是实现简单、逻辑清晰问题是有多少数据源就要配置多少套 MyBatis 环境代码一多维护成本就上来了而且一旦出现跨库业务就得写多套 Mapper 去拼装数据。第二种是 AOP 自定义注解的方式。核心思路是定义一个DataSource注解标注在 Service 或 Mapper 方法上通过切面在方法执行前动态切换数据源执行完再切回来。这种方式灵活度高可以做到按方法粒度控制数据源而且改造量相对小只要在需要切换的方法上加个注解就行。第三种是动态路由方式基于 Spring 提供的AbstractRoutingDataSource抽象类。它内部维护一个目标数据源的 Map通过determineCurrentLookupKey()方法动态决定当前线程使用哪个数据源。配合 ThreadLocal 存储数据源标识就能实现运行时的自由切换。我最终选的是方案二和方案三的结合基于AbstractRoutingDataSource做底层路由再封装自定义注解和 AOP 切面做上层控制。这样既能享受动态切换的灵活性又能通过注解让业务代码保持整洁。这也是目前大多数互联网项目的通行做法如果你看过一些开源的多数据源框架比如dynamic-datasource-spring-boot-starter底层思路其实是一样的。1.3 项目环境与版本信息做之前先把版本定下来避免后面掉进版本兼容的坑里。我这次用的环境是组件版本JDK1.8SpringBoot2.3.7.RELEASEMyBatisPlus3.4.3.4MySQL5.7SqlServer2012Maven3.6.3这里提醒一句SpringBoot 版本别追太高。我见过有同事直接用 SpringBoot 3.x结果发现javax变成了jakarta很多老依赖都跑不起来。如果项目里还有历史代码建议先用 2.x 的稳定版本把功能跑通。2. 静态双数据源配置从零开始接入 MySQL 和 SqlServer2.1 Maven 依赖引入的正确姿势先把基础依赖配好。除了 SpringBoot 和 MyBatisPlus 的常规依赖之外还需要把两个数据库的驱动都引进来。这里有个容易踩的坑MySQL 驱动的 groupId 和 artifactId 在不同版本之间有变化mysql:mysql-connector-java是老坐标com.mysql:mysql-connector-j是新坐标别搞混了。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.4.3.4/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency dependency groupIdcom.microsoft.sqlserver/groupId artifactIdmssql-jdbc/artifactId version9.4.1.jre8/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencySqlServer 驱动的版本选择有个细节。mssql-jdbc的版本号后缀jre8表示支持 Java 8如果你用的 JDK 是 1.8选带这个后缀的版本最稳妥。如果项目用 JDK 11 以上可以选不带后缀或带jre11的版本。2.2 application.yml 配置两个数据源的参数化配置依赖配好后就是写配置。我习惯把数据源信息放在application.yml里动态配置这样不同环境之间切换只需要改配置文件不用动代码。spring: datasource: mysql: driver-class-name: com.mysql.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/business_db?useUnicodetruecharacterEncodingutf-8useSSLfalse username: root password: root123 sqlserver: driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver jdbc-url: jdbc:sqlserver://192.168.1.100:1433;DatabaseNamehistory_db username: sa password: Sa123456注意这里的 key 是jdbc-url不是url。如果用urlSpringBoot 的自动配置会尝试把它当作默认数据源来处理导致多数据源场景下的配置错乱。这是我见过很多人踩过的坑写的时候一定要看清。另外有个小细节每套数据源的driver-class-name必须明确指定不要依赖 SpringBoot 自动推断。MySQL 8.0 以上版本驱动类名变成了com.mysql.cj.jdbc.Driver如果你用的是 8.x 驱动却配了旧类名启动时必报错。2.3 数据源配置类手动装配 SqlSessionFactory多数据源场景下需要手动创建数据源和SqlSessionFactory绕开 SpringBoot 的自动配置。这里以 MySQL 数据源为例Configuration public class DataSourceConfig { Bean(name mysqlDataSource) ConfigurationProperties(prefix spring.datasource.mysql) public DataSource mysqlDataSource() { return DataSourceBuilder.create().build(); } Bean(name sqlServerDataSource) ConfigurationProperties(prefix spring.datasource.sqlserver) public DataSource sqlServerDataSource() { return DataSourceBuilder.create().build(); } Bean(name dynamicDataSource) public DataSource dynamicDataSource() { DynamicDataSource dynamicDataSource new DynamicDataSource(); MapObject, Object dataSourceMap new HashMap(4); dataSourceMap.put(mysql, mysqlDataSource()); dataSourceMap.put(sqlserver, sqlServerDataSource()); dynamicDataSource.setTargetDataSources(dataSourceMap); dynamicDataSource.setDefaultTargetDataSource(mysqlDataSource()); return dynamicDataSource; } Bean(name sqlSessionFactory) public SqlSessionFactory sqlSessionFactory(Qualifier(dynamicDataSource) DataSource dynamicDataSource) throws Exception { MybatisSqlSessionFactoryBean sessionFactory new MybatisSqlSessionFactoryBean(); sessionFactory.setDataSource(dynamicDataSource); sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath*:mapper/**/*.xml)); return sessionFactory.getObject(); } }这里有几个关键点。第一MybatisSqlSessionFactoryBean用的是 MyBatisPlus 自带的类不是 mybatis 原生的SqlSessionFactoryBean这样才能让 MyBatisPlus 的分页插件、逻辑删除等功能生效。第二mapperLocations用classpath*通配符这样多个数据源的 Mapper XML 文件可以放在不同子目录下避免路径冲突。第三dynamicDataSource作为唯一的数据源注入到 MyBatis 里所以最终所有 Mapper 操作的入口都是动态数据源由它在内部做转发。3. 动态数据源实现核心类与 AOP 自动切换3.1 AbstractRoutingDataSourceSpring 预留的扩展点AbstractRoutingDataSource是 Spring 提供的一个抽象类它的核心作用就是做一个路由分发。你可以把它理解成一个中转站它自己不是一个真正意义上的数据库连接池而是维护着一组目标数据源每次获取连接的时候通过抽象方法determineCurrentLookupKey()来决定应该返回哪个真实数据源的连接。实现起来很简单public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceKey(); } }关键是DataSourceContextHolder通常用 ThreadLocal 实现public class DataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setDataSourceKey(String dataSourceKey) { CONTEXT.set(dataSourceKey); } public static String getDataSourceKey() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }为什么必须用 ThreadLocal因为 Spring 的DataSourceUtils在获取连接的时候会先看当前事务上下文里是否已有连接如果有就直接复用。ThreadLocal 能保证同一线程内数据源切换在当前线程内是隔离的不会影响其他线程。但这也引出一个隐患——线程池复用问题后面我会在踩坑部分详细说。3.2 自定义注解 DataSource光有路由还不够得让业务代码能方便地指定数据源。我定义了一个注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DataSource { String value() default mysql; }这个注解可以标注在 Service 方法上也可以标注在类上。标注在类上表示整个类的所有方法默认走某个数据源标注在方法上则覆盖类级别的配置。一个是粗粒度一个是细粒度两者结合使用起来非常灵活。3.3 AOP 切面方法执行前自动切换核心切面逻辑如下Aspect Component Order(1) public class DataSourceAspect { Pointcut(annotation(com.example.annotation.DataSource) || within(com.example.annotation.DataSource)) public void dataSourcePointCut() {} Before(dataSourcePointCut()) public void doBefore(JoinPoint joinPoint) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); DataSource dataSource method.getAnnotation(DataSource.class); if (dataSource null) { dataSource joinPoint.getTarget().getClass().getAnnotation(DataSource.class); } if (dataSource ! null) { DataSourceContextHolder.setDataSourceKey(dataSource.value()); } } After(dataSourcePointCut()) public void doAfter(JoinPoint joinPoint) { DataSourceContextHolder.clear(); } }切面的Order(1)很重要。如果项目里同时有事务切面必须保证数据源切面的优先级高于事务切面。原因在于Spring 的事务管理在开启事务时就要确定使用哪个数据源如果事务切面先执行它拿到的还是默认数据源后面的切换就白做了。切面逻辑里有个细节within是用来匹配类级别注解的。我的切点同时写了annotation和within这样能兼顾方法级和类级的注解配置避免在使用类级注解时切面不生效的问题。4. 实操踩坑实录MyBatisPlus 配合多数据源的高频问题4.1 分页插件配置问题单页 500 条限制的真相先说说 MyBatisPlus 分页的事。很多人在网上搜到MyBatisPlus 单页 500 条限制其实这并不是框架硬编码的限制而是分页插件配置不到位导致的。老版本的 MyBatisPlus 分页插件默认有limit保护不配置的话某些版本会限制单页查询条数。正确配置方式是在 MyBatisPlus 的配置类里添加分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); paginationInnerInterceptor.setMaxLimit(500L); paginationInnerInterceptor.setOverflow(false); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }这里setMaxLimit(500L)就是网上说的单页 500 限制的源头。如果不希望限制单页大小可以把这个值设大或者不设置。但我要提醒一句这个限制其实是好心防止有人写LIMIT 1000000这种查询把数据库打挂。生产环境建议保留只是结合实际业务把阈值调到一个合理的值。另外一个更隐蔽的问题是在多数据源场景下MySQL 和 SqlServer 的分页语法不一样。MySQL 用LIMIT ? OFFSETSqlServer 2012 以上用OFFSET ? ROWS FETCH NEXT ? ROWS ONLY。如果只给 MyBatisPlus 配置了 MySQL 方言的分页插件切到 SqlServer 后分页逻辑就会出错。好在 MyBatisPlus 的PaginationInnerInterceptor是支持多方言的它会根据当前数据源的类型自动选择合适的方言。但这要求你的多个数据源必须都注册到同一个 MyBatis 环境里而不能拆成多个SqlSessionFactory否则多方言识别就失效了。4.2 关键字冲突表名和字段名的那些坑SqlServer 有一些保留关键字是 MySQL 里可以随便用的比如order、user、group、index等。我在项目里就碰到过一张 SqlServer 的历史表字段名叫index在 MySQL 里这个字段名完全合法在 SqlServer 里必须加方括号才能查。MyBatisPlus 提供了全局的关键字自动转义开关mybatis-plus: global-config: db-config: column-format: %s但这个配置只对 MySQL 的反引号有效。如果同时操作 SqlServer反引号就不认了。更稳妥的做法是在实体类的TableField注解上手动指定别名TableField(index) private Integer index;如果是 SqlServer需要写成TableField([index]) private Integer index;这种问题防不胜防尤其是接手老数据库的时候。我的建议是先用工具连上 SqlServer把涉及的表结构和字段整体浏览一遍建一个关键字清单写 Mapper 的时候对照着处理别等到运行时报错了再回头一个个查。4.3 事务与数据源切换的冲突这个坑非常典型。场景是这样的有一个方法需要先查 SqlServer 的数据再做业务处理最后写 MySQL。一开始我直接在方法上加了Transactional然后通过 AOP 切数据源结果发现第二个操作还是走的老数据源。原因一句话说清楚事务开启时数据源就已经固定了。Transactional是在方法进入前就通过事务管理器获取了连接后续的数据源切换只是在DataSourceContextHolder里改了 ThreadLocal 的值但事务上下文里持有的连接还是老数据源的那个切不过去。解决办法有这么几种第一种业务允许的情况下把跨数据源的操作拆成两个方法分别加事务通过调用另一个 Service 的方法来完成。这是最省事的办法。第二种使用分布式事务方案比如 Seata、Atomikos但引入成本较高小项目没必要。第三种也是最常用的用编程式事务替代声明式事务Autowired private TransactionTemplate transactionTemplate; public void crossDataSourceOperation() { DataSourceContextHolder.setDataSourceKey(sqlserver); Object result queryFromSqlServer(); DataSourceContextHolder.clear(); transactionTemplate.execute(status - { DataSourceContextHolder.setDataSourceKey(mysql); try { insertToMysql(result); return true; } catch (Exception e) { status.setRollbackOnly(); throw e; } finally { DataSourceContextHolder.clear(); } }); }这样每个数据源的操作在自己的事务模板里管理互不干扰。但跨数据源的数据一致性就只能靠业务补偿来保证了这也是多数据源方案天然的限制。4.4 连接池配置多个数据源各自为战连接池这块也容易出问题。默认情况下SpringBoot 2.x 会使用 HikariCP 作为连接池。多数据源时每个数据源应该有自己的连接池配置避免共用一个连接池导致连接耗尽。spring: datasource: mysql: hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 sqlserver: hikari: minimum-idle: 3 maximum-pool-size: 10 connection-timeout: 15000SqlServer 的连接池大小不用配得跟 MySQL 一样大。因为 SqlServer 在老系统里主要用于查询历史数据并发量相对较低配小一点可以节省资源。同理超时时间也可以设置短一些避免 SqlServer 响应慢的时候拖垮应用线程。4.5 SqlServer 排序规则冲突问题网上有个热词是sqlserver cannot resolve the collation conflict翻译过来就是排序规则冲突。这个问题出现在 SqlServer 的临时表关联查询时如果两个表的排序规则比如Chinese_PRC_CI_AS和SQL_Latin1_General_CP1_CI_AS不一致关联查询会报错。在多数据源场景下如果我们从 MySQL 同步数据到 SqlServer再临时建表去关联历史表很容易触发这个问题。解决办法是建临时表时显式指定排序规则CREATE TABLE #temp_table ( user_name NVARCHAR(50) COLLATE Chinese_PRC_CI_AS )或者在关联时用COLLATE强制指定SELECT * FROM table_a a INNER JOIN table_b b ON a.name b.name COLLATE Chinese_PRC_CI_AS这个坑可能隐藏得比较深我遇到过因为排序规则不一致导致的慢查询排查了很久才发现是隐式转换导致索引失效。4.6 SqlServer 字符串转数字多数据源下的数据类型兼容SqlServer 里把字符串转数字常用CAST或CONVERTSELECT CAST(123 AS INT) SELECT CONVERT(INT, 123)MySQL 则用CAST(123 AS SIGNED)或者直接123 0。如果项目里有共用 Mapper XML不同数据库的转换语法就得分别写不能混用。我的做法是把这类数据库相关的 SQL 拆到各自的 Mapper 里通过数据源切换来保证执行正确。另外要注意 SqlServer 的CAST转换失败会直接报错不像 MySQL 在某些模式下会有宽松处理。生产环境如果遇到脏数据字符串里有空格或特殊字符转换就会炸。建议先用ISNUMERIC判断一下SELECT CASE WHEN ISNUMERIC(column_name) 1 THEN CAST(column_name AS INT) ELSE 0 END FROM table_name5. 多数据源方案验证与优化建议5.1 单元测试怎么写才靠谱数据源切换这种逻辑一定要写测试验证不能等上线了再看效果。我用 SpringBootTest 写了几组测试用例覆盖不同场景的切换逻辑。SpringBootTest RunWith(SpringRunner.class) public class DataSourceSwitchTest { Autowired private UserService userService; Test public void testQueryMysqlUser() { User user userService.getUserFromMysql(1L); Assert.assertNotNull(user); } Test public void testQuerySqlServerUser() { User user userService.getUserFromSqlServer(100L); Assert.assertNotNull(user); } Test public void testSwitchBetweenDataSources() { User mysqlUser userService.getUserFromMysql(1L); User sqlServerUser userService.getUserFromSqlServer(100L); Assert.assertNotNull(mysqlUser); Assert.assertNotNull(sqlServerUser); } }注意测试方法的执行顺序。JUnit 默认的方法执行顺序是不固定的如果测试类里多个方法都在切换数据源ThreadLocal 里的值可能在方法结束后由After清理一般不会互相影响。但如果某些测试并发执行异常可能会出现数据源串了的情况。稳妥起见每个测试方法都从默认状态开始不依赖前一个方法的执行结果。5.2 多数据源下的性能优化多数据源不只是配置问题性能同样要关注。几个实测经验第一连接池参数要分别调优。MySQL 作为主库读写频繁最大连接数可以设置得高一些SqlServer 作为辅助库查询量大但并发不高连接数可以少一些。第二跨数据源的循环查询要避免。比如先查 SqlServer 拿到 1000 条记录然后再循环查 MySQL 补充信息这种写法性能极差。正确做法是把 SqlServer 查出来的 ID 集合一次性传给 MySQL用IN查询批量处理。第三如果查询的数据量比较大利用好索引。SqlServer 老表的索引往往不够优化必要时可以创建覆盖索引来加速常用查询。但 DDL 操作要谨慎避免高峰期执行。5.3 从 MyBatis 到 MyBatisPlus 的兼容保障项目里既有 MyBatis 的 XML 写法也有 MyBatisPlus 的 Wrapper 写法。多数据源下这两种写法都需要保证切换正常。我踩过的一个坑是在某个自定义 SQL 里用了${ew.customSqlSegment}结果 MyBatisPlus 的多表查询没有自动带上数据源切换逻辑导致部分数据查不到。这个问题的根源在于MyBatisPlus 的 Wrapper 条件构造器在生成 SQL 时对IN类型的条件会有数量限制的优化逻辑。对于超过 1000 条的IN查询MyBatisPlus 会做拆分处理但这个拆分后的 SQL 执行顺序可能与数据源切换顺序不一致。解决方案是手动把IN查询拆分为多个小批次避免单条 SQL 过大。另外如果要在 MyBatisPlus 中使用自定义 SQL 并拼接 Wrapper建议用${ew.customSqlSegment}时注意转义问题尤其是字段名包含关键字时要在注解或 XML 里确认最终 SQL 的正确性。6. 常见问题速查表与排查技巧6.1 问题汇总症状可能原因解决办法启动报DataSource循环依赖多个数据源的 Bean 互相引用用Primary指定主数据源或在配置类中通过Qualifier明确注入切换数据源不生效AOP 切面没有拦截到方法确认自定义注解是否标在 Service 方法上Order(1)是否配置事务方法内切换数据源失败事务开启时连接已锁定拆分方法或使用编程式事务SqlServer 查询报错字段名是保留字用方括号括起来MyBatisPlus 分页在 SqlServer 下失效方言识别失败PaginationInnerInterceptor注册后确认多数据源共享同一个SqlSessionFactory连接池报Connection is not available连接泄露或配置过小检查连接池配置用SELECT * FROM sys.dm_exec_connections之类的语句排查6.2 排查思路与调试技巧多数据源问题排查第一步永远是确认当前线程的数据源是什么。我一般在DataSourceAspect里加一行日志log.info(switch datasource to{}, dataSource.value());然后观察日志输出是否与预期一致。如果日志显示已切换但查询还是走的老库那问题大概率出在事务上如果日志都没打印那就是 AOP 切面没起作用需要检查注解位置和 Spring 扫描路径。第二个技巧是打开 HikariCP 的连接获取日志通过监控连接池的活动情况来判断数据源是否正确。在application.yml里配置logging: level: com.zaxxer.hikari: DEBUG这样可以看到每次获取连接来自哪个数据源、连接池的活跃数是多少对定位连接泄露和切换异常都很有帮助。第三个技巧是查看 MyBatis 的执行日志。MyBatisPlus 提供了 SQL 输出日志可以配置输出 SQL 内容确认执行的 SQL 语句是否符合当前数据源的方言。如果 SQL 语法是 MySQL 的但执行到了 SqlServer 上基本可以断定是数据源切换出了问题。6.3 后续扩展动态加数据源、分布式事务如果项目后续要接入更多数据源比如再加一个 PostgreSQL只需要在DataSourceConfig中添加一个新的数据源 Bean然后在dynamicDataSource中把它put进targetDataSources即可。但要注意如果数据源是运行时动态增加的比如多租户场景需要调用AbstractRoutingDataSource的afterPropertiesSet()方法来刷新配置。分布式事务是个更深的话题。如果业务上真的需要跨库的强一致性比如一个操作既要写 MySQL 又要写 SqlServer多数据源方案已经解决不了问题了得引入分布式事务中间件。我的建议是尽早评估业务场景如果只是简单的查询和异步补偿多数据源方案完全够用如果要做强一致的跨库写操作提前设计好技术选型不要等写完了再返工。我在实际项目中的体会是多数据源本身并不复杂复杂的永远是业务边界和异常处理。动手之前先想清楚哪个库是主、哪个库是辅在代码层面规定好数据源的使用规范比什么花哨的框架都重要。最后再分享一个平时容易忽略的小细节如果你的项目在部署时切换了环境记得检查数据库连接串和账号权限我遇到过多次因为新环境 SqlServer 登录账号缺少VIEW SERVER STATE权限导致连接池初始化异常的案例这类问题一般不报错清楚排查起来比较费劲提前做好环境检查能省不少事。