接手过一个老项目用户数据和订单数据分别落在两个数据库里业务上却要在一个接口里同时查两边。当时第一反应是改造成微服务但项目规模不大、上线窗口又紧最后决定在单体应用里直接配多数据源让 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数据源负责真正创建数据库连接连接哪个库由它决定。SqlSessionFactoryMyBatis 的会话工厂每个数据源需要独立的 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连接池使用 HikariCPSpring Boot 2.x 默认自带。pom.xml里核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意这里没有额外引入 DruidHikariCP 已经足够优秀而且默认配置就好用。如果你团队有监控平台需要 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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; } // 查询订单库 ListOrderDO 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 管一张表”。