☰
SpringBoot双数据源实战:MySQL与SQL Server配置、事务管理与避坑指南
2026/10/14 11:36:01 网站建设 项目流程

1. 为什么一个项目里会同时出现 MySQL 和 SQL Server

第一次遇到“双数据源”需求,是在一个后台管理系统里。业务主库用的是 MySQL,负责用户、权限、订单这些高频读写;但财务对账模块要对接一套历史遗留的 SQL Server 数据库,那张表从很多年前就在跑,字段命名还是拼音缩写,动不得。当时团队里有人提议“把 SQL Server 的数据同步到 MySQL 不就行了”,结果一评估,同步延迟、字段类型映射、增量识别全是坑,最后还是老老实实在同一个 SpringBoot 应用里配了双数据源。

这个场景其实很典型。双数据源不是炫技,而是现实妥协的产物。常见触发条件大概有这么几类:

  • 异构系统对接:老系统用 SQL Server,新系统用 MySQL,短期内无法统一。
  • 读写分离的变种:主库 MySQL 写,报表库 SQL Server 读,各取所长。
  • 多租户或分库:不同客户的数据物理隔离在不同类型的数据库里。
  • 数据迁移过渡期:新旧库并行运行,应用需要同时能访问两边。

关键词里提到的SpringBoot、双数据源、MySQL、SQL Server、动态数据源、多数据源配置,本质上都指向同一个核心问题:如何让一个 Spring 容器管理两套完全独立的 DataSource,并且让 MyBatis 或 JPA 知道什么时候该用哪一套。

这里有个认知误区要先破掉。很多人以为“配两个 DataSource Bean 就完事了”,实际上真正的难点在于三件事:

  1. 事务管理器不能共用。MySQL 的DataSourceTransactionManager和 SQL Server 的是两个独立实例,如果只配一个,跨库操作时事务会失效。
  2. MyBatis 的SqlSessionFactory要各建各的,否则 Mapper 扫描会串。
  3. @Transactional注解默认只认一个事务管理器,多数据源下必须显式指定。

我见过不少项目在这三步上翻车,表面能启动,一跑业务就报No qualifying bean of type 'javax.sql.DataSource'或者事务不回滚。下面我把整套配置拆开讲,包括我踩过的坑和最后稳定运行的方案。

提示:本文假设你用的是 SpringBoot 2.x + MyBatis + HikariCP 连接池,这是目前最主流的组合。如果你用 JPA,思路一样,只是把SqlSessionFactory换成EntityManagerFactory。

2. 依赖引入与版本选择的那些细节

2.1 两个驱动包必须同时存在

Maven 里引入依赖看起来简单,但版本冲突是第一个坑。MySQL 驱动和 SQL Server 驱动分属不同厂商,传递依赖里可能带进来不同版本的slf4j或netty,导致启动时报NoSuchMethodError。

我用的依赖组合如下:

<!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- SQL Server 驱动 --> <dependency> <groupId>com.microsoft.sqlserver</groupId> <artifactId>mssql-jdbc</artifactId> <version>12.4.2.jre8</version> </dependency> <!-- MyBatis SpringBoot Starter --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <!-- 连接池 --> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.0.1</version> </dependency>

这里有几个选择理由要说明白。

为什么 MySQL 驱动用 8.0.33 而不是最新的 9.x?因为 8.0.x 系列在 SpringBoot 2.x 生态里验证最充分,9.x 对 JDK 版本要求更高,而且部分老项目还在 JDK 8 上跑。8.0.33 是 8.0 分支的末期版本,bug 修复完整,兼容性最稳。

SQL Server 驱动为什么选jre8后缀?mssql-jdbc的版本号后面会跟jre8、jre11、jre17,分别对应不同 JDK 基线。选错了会在运行时抛UnsupportedClassVersionError。如果你项目是 JDK 8,就必须用jre8;JDK 11 用jre11。这个细节官方文档写得很隐蔽,我第一次就选错了。

HikariCP 要不要显式引入?SpringBoot 2.x 默认已经带了 HikariCP,但版本可能偏旧。显式引入 5.0.1 是为了用上connectionTimeout和maximumPoolSize的细粒度控制。注意 HikariCP 5.x 要求 JDK 8+,和上面的驱动基线一致。

2.2 配置文件里两个数据源的命名隔离

application.yml里最容易犯的错是键名重复。SpringBoot 的自动配置默认只认spring.datasource,如果你写两个spring.datasource会直接覆盖。正确做法是自定义前缀:

spring: datasource: mysql: jdbc-url: jdbc:mysql://127.0.0.1:3306/main_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: mysql_pwd driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 sqlserver: jdbc-url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=finance_db;encrypt=false;trustServerCertificate=true username: sa password: sqlserver_pwd driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000

注意几个关键点。

MySQL 的 URL 必须带serverTimezone,否则 8.0 驱动会报时区错误。useSSL=false在本地开发环境可以关,生产环境建议开并配证书。

SQL Server 的 URL 用分号分隔参数,不是&。encrypt=false和trustServerCertificate=true是本地自签证书场景下的组合,生产环境要换成正式证书配置。DatabaseName指定默认库,避免每次查询都写全限定名。

连接池大小要区别对待。MySQL 是主库,读写频繁,给 20 个连接;SQL Server 只做对账查询,给 10 个足够。max-lifetime设 1800000 毫秒(30 分钟)是为了避开数据库端的空闲连接回收,MySQL 默认wait_timeout是 8 小时,但中间可能有防火墙掐连接,30 分钟主动重建更稳。

注意:jdbc-url这个键名是 HikariCP 专用的,如果你用url会报jdbcUrl is required with driverClassName。这是从DataSourceProperties切换到手动配置时最容易忽略的差异。

3. 手动装配两个 DataSource 的完整代码

3.1 主数据源配置类

SpringBoot 的自动配置在多数据源场景下会失效,必须手动建@Configuration类。先看 MySQL 主库:

@Configuration @MapperScan( basePackages = "com.example.project.mapper.mysql", sqlSessionFactoryRef = "mysqlSqlSessionFactory" ) public class MysqlDataSourceConfig { @Primary @Bean(name = "mysqlDataSource") @ConfigurationProperties(prefix = "spring.datasource.mysql") public DataSource mysqlDataSource() { return DataSourceBuilder.create().build(); } @Primary @Bean(name = "mysqlSqlSessionFactory") public SqlSessionFactory mysqlSqlSessionFactory( @Qualifier("mysqlDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/mysql/*.xml")); org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration(); config.setMapUnderscoreToCamelCase(true); config.setCacheEnabled(false); bean.setConfiguration(config); return bean.getObject(); } @Primary @Bean(name = "mysqlTransactionManager") public DataSourceTransactionManager mysqlTransactionManager( @Qualifier("mysqlDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

这段代码里有几个设计决策值得展开。

为什么用@Primary?当容器里存在多个同类型 Bean 时,@Primary标记的那个会被优先注入。这样即使某处忘了写@Qualifier,默认也会走 MySQL,降低出错概率。但注意,@Primary只解决“默认选谁”,不解决“事务管理器串用”的问题。

@MapperScan的sqlSessionFactoryRef必须指定。如果不写,MyBatis 会去找默认的SqlSessionFactory,多数据源下直接报No qualifying bean。basePackages也要按数据源分开,MySQL 的 Mapper 放mapper.mysql包,SQL Server 的放mapper.sqlserver包,物理隔离最省心。

mapUnderscoreToCamelCase手动开启。SpringBoot 的mybatis.configuration.map-underscore-to-camel-case配置在多数据源下不会自动生效,因为每个SqlSessionFactory是独立构建的。这个坑我踩过,表现为数据库字段user_name映射不到 Java 的userName,查出来全是 null。

cacheEnabled设 false。二级缓存跨数据源共享会出大问题,直接关掉最安全。如果确实需要缓存,用 Redis 这类外部缓存,别用 MyBatis 自带的。

3.2 从数据源配置类

SQL Server 的配置和 MySQL 几乎对称,但不能加@Primary:

@Configuration @MapperScan( basePackages = "com.example.project.mapper.sqlserver", sqlSessionFactoryRef = "sqlserverSqlSessionFactory" ) public class SqlServerDataSourceConfig { @Bean(name = "sqlserverDataSource") @ConfigurationProperties(prefix = "spring.datasource.sqlserver") public DataSource sqlserverDataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "sqlserverSqlSessionFactory") public SqlSessionFactory sqlserverSqlSessionFactory( @Qualifier("sqlserverDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/sqlserver/*.xml")); org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration(); config.setMapUnderscoreToCamelCase(true); config.setCacheEnabled(false); bean.setConfiguration(config); return bean.getObject(); } @Bean(name = "sqlserverTransactionManager") public DataSourceTransactionManager sqlserverTransactionManager( @Qualifier("sqlserverDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

两个配置类的结构完全一致,唯一区别是@Primary和 Bean 名称。这种对称设计的好处是排查问题时可以逐项对比,哪边少了注解一眼就能看出来。

@ConfigurationProperties(prefix = ...)的绑定原理:SpringBoot 会把spring.datasource.mysql下的jdbc-url、username、password等属性反射注入到DataSource实例。注意jdbc-url会映射到 HikariCP 的jdbcUrl属性,这是 SpringBoot 的宽松绑定规则在起作用。

提示:如果你的项目里还有第三、第四个数据源,照这个模式复制即可,但要注意每个SqlSessionFactory的mapperLocations路径必须唯一,否则 XML 会重复加载报Mapped Statements collection already contains value。

4. 事务管理:多数据源下最容易翻车的地方

4.1@Transactional不指定管理器会怎样

假设你在 Service 里写了这样一个方法:

@Transactional public void transferOrder(Long orderId) { mysqlOrderMapper.updateStatus(orderId, "PAID"); sqlserverFinanceMapper.insertRecord(orderId); }

看起来没问题,但运行时会发生什么?Spring 会去找一个PlatformTransactionManager类型的 Bean。容器里有两个(mysqlTransactionManager和sqlserverTransactionManager),虽然 MySQL 那个标了@Primary,但事务管理器不会自动按@Primary选,而是抛NoUniqueBeanDefinitionException。

正确写法必须显式指定:

@Transactional(transactionManager = "mysqlTransactionManager", rollbackFor = Exception.class) public void transferOrder(Long orderId) { mysqlOrderMapper.updateStatus(orderId, "PAID"); sqlserverFinanceMapper.insertRecord(orderId); }

但这里有个更深的问题:这个事务只覆盖 MySQL,SQL Server 的操作不在同一个事务里。如果 MySQL 更新成功、SQL Server 插入失败,MySQL 会回滚,但 SQL Server 那边可能已经提交了(取决于它的自动提交设置)。这就是分布式事务问题,单靠 Spring 的DataSourceTransactionManager解决不了。

4.2 跨库操作的三种务实方案

我在实际项目里试过三种方案,各有适用场景:

方案实现方式优点缺点适用场景
单库事务 + 补偿只对主库开事务,从库失败后手动补偿实现简单,无额外依赖补偿逻辑要自己写,可能漏从库操作可容忍最终一致
链式事务管理器自定义TransactionManager依次提交代码侵入小两阶段提交有性能损耗,异常处理复杂强一致要求不高的内部系统
消息队列最终一致主库操作后发消息,从库消费解耦彻底,可靠性高引入 MQ,架构变重跨系统、高并发场景

我最后选的是单库事务 + 补偿。原因很直接:财务对账不是实时业务,允许几分钟延迟。具体做法是 MySQL 事务提交后,往一张sync_task表插一条待同步记录,由定时任务扫描并写入 SQL Server,失败就重试。这样 MySQL 的事务边界清晰,SQL Server 的写入变成异步,两边解耦。

如果你确实需要强一致,可以考虑Atomikos或Bitronix这类 JTA 实现,但它们对 SQL Server 和 MySQL 的 XA 支持程度不一样,配置复杂度陡增。我的建议是:先问业务能不能接受最终一致,能接受就别上分布式事务。

4.3 事务不生效的排查清单

多数据源下事务不回滚,按这个顺序查:

  1. 方法是不是 public?@Transactional只对 public 方法生效,private 和 protected 直接忽略。
  2. 是不是自调用?同一个类里 A 方法调 B 方法,B 上的@Transactional不生效,因为没走代理。
  3. 异常类型对不对?默认只回滚RuntimeException和Error,受检异常要加rollbackFor = Exception.class。
  4. 事务管理器指定了吗?多数据源下必须写transactionManager = "xxx"。
  5. 数据源是不是被@Primary干扰了?检查@Qualifier有没有写对。

这五条我几乎每次带新人都要讲一遍,尤其是第 2 条,自调用问题在单数据源下也存在,但多数据源下因为事务管理器选错,表现更隐蔽。

5. 实际跑起来之后遇到的三个坑

5.1 SQL Server 的datetime2映射到 Java 的LocalDateTime报错

SQL Server 的datetime2类型精度到 100 纳秒,而 Java 的LocalDateTime只到纳秒,MyBatis 默认的LocalDateTimeTypeHandler在转换时会抛ConversionFailedException。解决办法是在实体类字段上加@DateTimeFormat或者在 MyBatis 配置里注册自定义 TypeHandler:

@MappedTypes(LocalDateTime.class) @MappedJdbcTypes(JdbcType.TIMESTAMP) public class SqlServerLocalDateTimeHandler extends BaseTypeHandler<LocalDateTime> { @Override public void setNonNullParameter(PreparedStatement ps, int i, LocalDateTime parameter, JdbcType jdbcType) throws SQLException { ps.setTimestamp(i, Timestamp.valueOf(parameter)); } @Override public LocalDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException { Timestamp ts = rs.getTimestamp(columnName); return ts == null ? null : ts.toLocalDateTime(); } // 省略另外两个 getNullableResult 重载 }

然后在SqlSessionFactory里注册:

config.getTypeHandlerRegistry().register(LocalDateTime.class, new SqlServerLocalDateTimeHandler());

这个坑的隐蔽之处在于:MySQL 那边同样的LocalDateTime字段完全正常,只有 SQL Server 报错,很容易误以为是数据源配置问题。

5.2 连接池耗尽导致接口超时

上线后第二天,监控报警说 SQL Server 的接口响应时间从 50ms 涨到 8s。查下来是连接池耗尽了。原因是 SQL Server 那边有个慢查询,单次执行 3 秒,10 个连接被占满后,后续请求全在排队。

调整方案:

  • maximum-pool-size从 10 提到 20,但这不是根本办法。
  • 给慢查询加索引,把 3 秒降到 200ms。
  • 设置connection-timeout为 3000ms,超时快速失败,避免线程堆积。
  • 加leak-detection-threshold: 60000,连接借出超过 60 秒没归还就打印堆栈,方便定位泄漏点。
hikari: maximum-pool-size: 20 connection-timeout: 3000 leak-detection-threshold: 60000

leak-detection-threshold这个参数强烈建议在测试环境开,生产环境视情况。它会在日志里打出“Connection leak detected”并附带调用栈,比盲猜高效得多。

5.3 两个数据源的@Transactional嵌套调用顺序问题

有个业务方法是这样的:先调 MySQL 的 Service 方法(带事务),再调 SQL Server 的 Service 方法(带事务)。两个方法各自有@Transactional,但外层没有事务。结果 MySQL 提交后,SQL Server 抛异常,MySQL 的数据已经落库了。

这是典型的事务传播行为问题。默认的Propagation.REQUIRED在无外层事务时各自开新事务,互不影响。要解决只有两个选择:要么外层统一开一个事务管理器(但跨库不支持),要么接受最终一致,用补偿机制。

我最后的做法是把 SQL Server 的写入改成异步,通过@Async加线程池执行,失败进重试队列。这样 MySQL 的事务快速提交,SQL Server 的失败不影响主流程。

6. 双数据源项目的目录结构与维护建议

6.1 推荐的包结构

com.example.project ├── config │ ├── MysqlDataSourceConfig.java │ └── SqlServerDataSourceConfig.java ├── mapper │ ├── mysql │ │ ├── OrderMapper.java │ │ └── UserMapper.java │ └── sqlserver │ └── FinanceMapper.java ├── entity │ ├── mysql │ └── sqlserver ├── service │ ├── mysql │ └── sqlserver └── resources └── mapper ├── mysql │ └── OrderMapper.xml └── sqlserver └── FinanceMapper.xml

按数据源分包的好处是物理隔离,@MapperScan的basePackages直接指向对应包,不会误扫。实体类也分开,避免字段混淆。Service 层可以按业务聚合,但内部调用时注意事务管理器的指定。

6.2 日常维护的三条经验

第一条:新增 Mapper 时先确认包路径。有次同事把 SQL Server 的 Mapper 放到了mapper.mysql包下,启动没报错,但查询时走了 MySQL 数据源,报“表不存在”。排查了半小时才发现是包放错了。后来我们在 CI 里加了一条检查:mapper.mysql包下的类不能引用sqlserver相关的实体。

第二条:SQL 脚本要标注数据源。项目里的schema.sql和data.sql如果混在一起,SpringBoot 启动时会用默认数据源执行,导致 SQL Server 的建表语句跑到 MySQL 上执行。我们的做法是分成schema-mysql.sql和schema-sqlserver.sql,手动执行,不依赖自动初始化。

第三条:监控要分开看。HikariCP 的指标(活跃连接数、等待线程数)按数据源分别上报。如果只监控一个,另一个出问题发现不了。我们在 Prometheus 里给两个数据源打了不同标签,告警阈值也分开设。

提示:如果你用的是 SpringBoot Actuator,/actuator/metrics/hikaricp.connections.active默认只显示一个数据源。需要自定义MeterRegistry并给每个DataSource加标签,才能区分。

7. 关于动态数据源的一点延伸

有些场景下,数据源不是固定的两个,而是根据租户或请求动态切换。这时候上面的静态配置就不够了,需要用到AbstractRoutingDataSource。它的原理是维护一个Map<Object, DataSource>,通过determineCurrentLookupKey()返回的 key 来决定用哪个数据源,key 通常存在ThreadLocal里。

但我要泼一盆冷水:动态数据源和事务管理器的配合非常麻烦。因为事务管理器在事务开启时就绑定了具体的数据源,中途切换会导致事务失效。如果确实需要动态切换,建议只在查询场景用,写操作还是走固定数据源。

我个人的经验是,能静态配置就别动态。双数据源已经够复杂了,再加动态切换,排查问题的难度是指数级上升。除非业务上真的需要多租户物理隔离,否则用静态双数据源加业务层路由就够了。

最后分享一个我常用的验证方法:配好之后,写一个CommandLineRunner,在启动时分别查两个库的当前时间,打印出来。如果两个时间都能拿到,说明数据源和 Mapper 扫描都正常。这个方法比看日志快得多,尤其适合在 CI 环境做冒烟测试。

@Component public class DataSourceHealthChecker implements CommandLineRunner { @Autowired @Qualifier("mysqlDataSource") private DataSource mysqlDataSource; @Autowired @Qualifier("sqlserverDataSource") private DataSource sqlserverDataSource; @Override public void run(String... args) throws Exception { try (Connection conn = mysqlDataSource.getConnection()) { System.out.println("MySQL connected: " + conn.getMetaData().getURL()); } try (Connection conn = sqlserverDataSource.getConnection()) { System.out.println("SQL Server connected: " + conn.getMetaData().getURL()); } } }

这段代码跑通,基本就成功了一大半。剩下的就是业务代码里注意事务管理器的指定,以及跨库操作的补偿逻辑。双数据源不难,难的是把边界情况想清楚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询