接手过一个老项目,用户数据和订单数据分别落在两个数据库里,业务上却要在一个接口里同时查两边。当时第一反应是改造成微服务,但项目规模不大、上线窗口又紧,最后决定在单体应用里直接配多数据源,让 Spring Boot 整合 MyBatis 同时连接两个库。这个方案折腾下来踩了不少坑,也把多数据源从配置到跨库操作这条路摸透了。如果你也在做类似的改造,这篇应该能帮你少走弯路。
1. 先弄清楚为什么需要"跨库操作":单数据源搞不定的业务场景
1.1 典型场景:拆库之后的老系统如何过渡
很多项目并不是一开始就设计成多库的,而是业务发展到一定程度后被迫拆库。最常见的是把用户中心、订单中心、支付流水这类高并发、高隔离需求的数据拆到不同数据库,甚至不同实例上。拆完之后问题来了:老的业务方法可能同时需要查用户表和订单表,比如“查某个用户最近三个月的订单列表”,这个SQL天然需要跨两个库。
有些团队会直接上微服务,但微服务引入了网络调用、分布式事务、链路追踪等一系列复杂度。对于中小团队或过渡期系统,在多数据源的单体应用里先把查询逻辑跑通,往往是性价比更高的选择。Spring Boot 的多数据源配置就是为这种场景准备的。
1.2 跨库操作的三条技术路线
我实际调研过三种方案,各有适用场景:
第一条路:数据库层面的跨库查询。比如给订单库建一个指向用户库的视图,或者在 SQL 里直接写db_user.t_user JOIN db_order.t_order。前提是两个库在同一个 MySQL 实例上,或者用 FEDERATED 引擎做远程表映射。这条路配置简单,但它把两个库的耦合留在了数据库层,一旦有跨实例、跨机房的需求就玩不转,而且 FEDERATED 引擎性能、稳定性都有隐患,我不推荐在生产环境用。
第二条路:应用层编码实现,即本篇文章的核心。在 Spring Boot 中配置两个 DataSource,分别对应两个库,让不同的 Mapper 走不同的数据源。查询时在 Service 层先查库A,再查库B,在内存中做数据组装。这个方式灵活、直观,是绝大多数业务系统实际采用的方案。
第三条路:引入 ShardingSphere 之类的中间件做数据分片。它可以理解成在应用和数据库之间加了一层路由,查询时自动感知表分布在哪个库。适合真正的分库分表场景,但对中小项目来说,中间件的引入和维护成本偏高,属于“杀鸡用牛刀”。
1.3 方案选型的原则
我的选型逻辑很简单:如果只是两三个库、几个 Mapper 需要跨库操作,用多数据源配置就够了;如果涉及几十张表的分片和读写分离,再考虑引入中间件。多数据源方案的核心优势是透明、可控——每个 Mapper 明确知道自己在连哪个库,SQL 怎么写完全由你说了算,出了性能问题也容易定位。
2. 动手前的关键认知:多数据源不是“配置一个Bean”那么简单
2.1 多数据源配置涉及的四个核心组件
很多人以为在application.yml里写两组 connection 就是多数据源了,实际上真正要配的是四样东西:DataSource、SqlSessionFactory、事务管理器(TransactionManager)、Mapper 扫描器。
- DataSource:数据源,负责真正创建数据库连接,连接哪个库由它决定。
- SqlSessionFactory:MyBatis 的会话工厂,每个数据源需要独立的 SqlSessionFactory,因为里面绑定了对应的 DataSource 和 Mapper XML 文件位置。
- TransactionManager:事务管理器,没有它
@Transactional就不知道控制哪个数据源的事务。 - Mapper 扫描器:通过
@MapperScan指定 Mapper 接口的扫描路径,必须让不同数据源的 Mapper 落在不同的包路径下,否则两个 SqlSessionFactory 会争抢同一个 Mapper。
2.2 分包设计:多数据源方案里最容易被忽视的一环
Spring Boot 整合多数据源的难点不在配置本身,而在分包设计。你必须在项目结构上把 Mapper 接口和 XML 文件按数据源划分清楚。举个例子,主数据源对应com.project.module.user.mapper,次数据源对应com.project.module.order.mapper,两个包的 Mapper 分别由各自的 SqlSessionFactory 扫描。
业务 Service 层调用时,只要注入对应的 Mapper 接口,MyBatis 会自动路由到正确的数据源。这个设计的好处是 Mapper 层的代码感知不到“多数据源”这件事,它只知道自己在操作一张表,路由逻辑完全隔离在配置层。
2.3 关于事务的预判:你的第一反应可能是错的
多数据源下最容易想当然的是事务。单数据源时@Transactional可以直接控制读写事务,但有了两个数据源之后,@Transactional默认只会路由到主数据源的事务管理器。如果你在次数据源的 Mapper 上期望事务生效,却不显式指定事务管理器,事务会静默失效——这是线上数据不一致的老大难问题,后文会有专门章节分析。
3. 从零到一的完整配置过程:双数据源落地的每一步
3.1 环境准备与依赖引入
我的示例环境是 Spring Boot 2.7.x + MyBatis Spring Boot Starter 2.3.x + MySQL 8.0,连接池使用 HikariCP(Spring Boot 2.x 默认自带)。pom.xml里核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>注意这里没有额外引入 Druid,HikariCP 已经足够优秀,而且默认配置就好用。如果你团队有监控平台需要 Druid 的监控数据,可以替换,但连接池的配置项要跟着改。
3.2 application.yml 的数据源配置
在两个库的配置项上,我推荐使用自定义前缀spring.datasource.primary和spring.datasource.secondary,而不是默认的spring.datasource.url。原因很简单:默认前缀会被 Spring Boot 自动装配机制捕获,而你一旦自定义了 DataSource Bean,自动装配就会出现冲突。
spring: datasource: primary: jdbc-url: jdbc:mysql://10.0.1.10:3306/db_user?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: db_user_rw password: xxxxx driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 secondary: jdbc-url: jdbc:mysql://10.0.1.11:3306/db_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: db_order_rw password: xxxxx driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 3 connection-timeout: 30000这里有一个关键点:自定义数据源的配置项必须是jdbc-url而不是url。因为 HikariCP 的HikariDataSource是通过setJdbcUrl方法接收地址的,如果配置成url并直接绑定属性,启动时会因找不到 setter 报错。这个细节坑了不少人。
3.3 主数据源配置类:必须标记 @Primary
主数据源配置类的作用是生成主 DataSource、主 SqlSessionFactory、主事务管理器,并扫描主库对应的 Mapper 包。
@Configuration @MapperScan( basePackages = "com.project.module.user.mapper", sqlSessionFactoryRef = "primarySqlSessionFactory" ) public class PrimaryDataSourceConfig { @Bean(name = "primaryDataSource") @Primary @ConfigurationProperties(prefix = "spring.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "primarySqlSessionFactory") @Primary public SqlSessionFactory primarySqlSessionFactory( @Qualifier("primaryDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/user/*.xml")); return sessionFactory.getObject(); } @Bean(name = "primaryTransactionManager") @Primary public DataSourceTransactionManager primaryTransactionManager( @Qualifier("primaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }@Primary这个注解特别重要。Spring 容器里现在有两个 DataSource、两个 SqlSessionFactory、两个 TransactionManager,如果没有@Primary指定默认实例,所有按类型注入的地方都会因为“找到多个候选 Bean”而启动失败。
3.4 次数据源配置类:注意与主数据源的差异
次数据源配置类在结构上和主数据源几乎一样,差异在于:不加@Primary、扫描另一个 Mapper 包、Bean 名称全部换成secondary*。
@Configuration @MapperScan( basePackages = "com.project.module.order.mapper", sqlSessionFactoryRef = "secondarySqlSessionFactory" ) public class SecondaryDataSourceConfig { @Bean(name = "secondaryDataSource") @ConfigurationProperties(prefix = "spring.datasource.secondary") public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "secondarySqlSessionFactory") public SqlSessionFactory secondarySqlSessionFactory( @Qualifier("secondaryDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/order/*.xml")); return sessionFactory.getObject(); } @Bean(name = "secondaryTransactionManager") public DataSourceTransactionManager secondaryTransactionManager( @Qualifier("secondaryDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }最后,主启动类上需要去掉 Spring Boot 默认的数据源自动配置,避免它和我们的自定义配置打架:
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }3.5 跨库查询的 Service 层写法
配置完成后,跨库查询的代码其实很朴素,核心思路是“分库分查、内存组装”。比如查用户及其最近订单:
@Service public class UserOrderService { private final UserMapper userMapper; private final OrderMapper orderMapper; public UserOrderService(UserMapper userMapper, OrderMapper orderMapper) { this.userMapper = userMapper; this.orderMapper = orderMapper; } public UserOrderVO getUserOrder(Long userId) { // 查询用户库 UserDO user = userMapper.selectById(userId); if (user == null) { return null; } // 查询订单库 List<OrderDO> orders = orderMapper.selectRecentByUserId(userId, 90); return new UserOrderVO(user, orders); } }在这个 Service 里,UserMapper走主库,OrderMapper走次库,调用方完全感知不到数据源切换。跨库查询的复杂逻辑都被包在了配置层。
4. 配置完成后最容易踩的坑:我实际遇到的五个问题
4.1 事务静默失效:@Transactional 到底管的是哪个库
当时我把订单写入放在次数据源上,Service 方法加了@Transactional注解,本地测试一切正常。但后来发现,当订单写入中途抛异常时,前面的写操作居然没有回滚。排查逻辑链是这样的:
- 先确认异常确实抛出且被事务感知,没有问题。
- 断点进
@Transactional的拦截器,发现它拿到的是primaryTransactionManager。 - 次数据源的写操作根本不在主事务的管理范围内,自然无法回滚。
根因是 Spring 的事务管理默认按类型注入唯一的事务管理器,因为主数据源配置类打了@Primary,所以默认拿到的是主事务管理器。
解决方案很直接,在需要次数据源事务的方法上显式指定事务管理器:
@Transactional(transactionManager = "secondaryTransactionManager") public void createOrder(OrderDO order) { orderMapper.insert(order); }如果一次业务操作同时涉及两个库的写入,单靠本地事务是没法保证原子性的,这就涉及后面要说的分布式事务问题。记住一句话:跨库写操作,不要指望@Transactional包住一切。
4.2 Mapper 扫描冲突导致的启动失败
刚把两个 SqlSessionFactory 配置好时,应用启动直接报错,提示某个 Mapper 接口同时被多个 SqlSessionFactory 扫描。原因是两个@MapperScan的basePackages有交集,或者其中一个包路径写得太宽,比如直接写了com.project.mapper,把两个库的 Mapper 都扫进去了。
这个问题的排查相对容易,启动日志里会明确打印是哪个接口注册冲突。解决方法是严格划分 Mapper 包路径,确保主次数据源的扫描范围互不包含。我后来养成的习惯是 Mapper 包名直接带上库名对应的模块名,比如user.mapper和order.mapper,从根上避免相似路径带来的误扫。
4.3 连接池耗尽:次数据源的池太小
项目上线后,订单查询接口偶发超时,看到日志里有大量的HikariPool-1 connection is not available报错。HikariPool-1对应的就是我们配置的次数据源。当时次库的maximum-pool-size设置的是 5,但订单接口的 QPS 上升后,5 个连接很快被占满,新建连接的等待时间又长,接口就堵死了。
后来把次库连接池调大到 20,并加了连接超时时间的监控告警,问题解决。多数据源的项目里,每个库的流量模型不一样,连接池参数应该单独设置。用户库可能读多写多,订单库可能批量查询频繁,要分别压测后取值,不要图省事用同一个参数。
4.4 分布式事务的迷思:两个库的写入不可能靠一个注解搞定
有同事试图在跨库写入的 Service 方法上只加一个@Transactional,期望它能同时回滚两个库的数据。这是对 Spring 事务机制的最大误解。一个事务管理器只能绑定一个数据源,它管理的连接都来自同一个库。两个库之间的事务协调,必须借助 XA 两阶段提交、TCC、本地消息表等分布式事务方案,或者干脆把两个写操作拆成可以容忍最终一致性的流程。
4.5 MyBatis 二级缓存的数据错乱隐患
我某次启用了 MyBatis 的二级缓存,缓存 key 默认是 Mapper 的命名空间,两个数据源如果扫描到同名 Mapper 接口,缓存会相互污染。虽然分包设计后很少出现完全同名的 Mapper,但 XML 文件里的namespace如果写重了,第二个 SqlSessionFactory 初始化时就会因为 namespace 冲突而启动失败。我的建议是,多数据源环境下默认不要开启二级缓存,一级缓存够用了。非要使用缓存,请用独立的缓存组件(比如 Redis),别依赖 MyBatis 自带的二级缓存。
5. 跨库操作的数据一致性方案:从设计到容错
5.1 先想办法避免跨库写入
做技术方案之前先做业务方案。很多时候“跨库操作”只是查询需求,比如查用户的同时查订单,读操作不涉及事务,组装就行了。真正麻烦的是跨库写入,这种情况下我会先和业务方确认:能不能把两次写操作改造成异步的?能不能把其中一个库的数据冗余到另一个库(通过消息队列同步)?
举个例子,用户下单后要扣减用户积分,积分在用户库、订单在订单库。与其在下单事务里同步扣积分,不如下单成功后发一条本地消息,消费者再去更新用户库的积分。这样下单事务只落在订单库,积分更新允许延迟,最终一致即可。业务上的“痛点”在绝大多数场景下都能接受这种设计,关键是技术侧能给出清晰方案。
5.2 如果必须同步写:本地消息表是靠谱的落地方案
做不到异步时,我用的比较多的是本地消息表方案。核心思想是:跨库操作拆成两部分,第一部分在本地事务里把业务数据和消息数据一起写入;第二部分由一个定时任务轮询消息表,把需要同步的数据发往目标库。
比如订单库和客户库都需要更新某条客户信息,可以在订单库里建一张sync_message表,业务方法在订单库的本地事务里同时更新客户状态表(如果订单库有冗余客户字段)和插入一条待同步消息。定时任务查出消息,去更新客户库,成功后标记消息已完成。这个方法没有引入额外中间件,实现成本低,适合大多数中小项目。
5.3 不要盲目上 Seata 这类分布式事务框架
我见过不少团队一碰到跨库写就急着引入分布式事务框架。框架本身没问题,但增加了部署组件、全局锁竞争和网络开销,尤其在调用链很短、并发不低的场景下,反而拖慢主流程。我的实践原则是:能异步就异步,能最终一致就不追求强一致,实在需要强一致才考虑引入重量级方案。先问业务“客户看到数据延迟两秒能不能接受”,能接受就简化技术方案。
6. 稳定运行大半年后的经验复盘与建议
6.1 我的配置模板:直接复制可用
如果你要配双数据源,可以直接参考这个流程清单:
application.yml里用自定义前缀配置两个数据源,地址项用jdbc-url,不要用url。- 主启动类排除
DataSourceAutoConfiguration。 - 建两个配置类,分别定义 DataSource、SqlSessionFactory、TransactionManager;主配置类全部加
@Primary。 - Mapper 接口和 XML 文件严格按数据源分包,
@MapperScan的包路径精确到最小模块。 - 跨库写操作显式指定
transactionManager。 - 关闭 MyBatis 二级缓存。
这套模板我复制到过三个项目里,调整的只是包名、库名、连接池参数,结构完全不用改。稳定运行大半年没有出过数据源层面的故障。
6.2 监控与排查建议
多数据源有个隐性成本:故障面比单数据源大。我建议至少做三件事:
- 给每个数据源配置独立的连接池指标监控,至少盯住活跃连接数和等待连接数,哪个池被打满一目了然。
- 在关键 Service 方法埋点,记录“查主库耗时”和“查次库耗时”,跨库接口变慢时能快速定位是哪个库拖了后腿。
- 日志里打出实际使用的数据源名称。我用的是自定义拦截器,在 Mapper 执行前打印当前线程绑定的数据源,排查“查错库”类问题非常有效。
6.3 一句实在话
按我这些年配多数据源的体会,配置本身不算难,难点始终在事务边界和包结构设计上。先把这两个想清楚,写出来的配置才能经得住线上流量考验。如果你的项目也处在“一个接口要同时碰两个库”的尴尬期,希望这篇能帮你把方案快速落地。后面如果再碰到读写分离或者分库分表的需求,思路也是类似的——从配置层把数据源隔离干净,业务层始终只感知“一个 Mapper 管一张表”。