SpringBoot+Mybatis多数据源配置实战:读写分离与事务管理
1. 多数据源到底在解决什么问题做后端开发的朋友大概率都遇到过这样的场景项目一开始只有一个数据库所有业务读写都走它跑得好好的。突然有一天产品经理跑过来说咱们要接一个历史遗留系统的库或者运营那边要单独拉一个报表库出来又或者主库压力太大了需要做读写分离。这时候你打开项目一看所有Mapper都绑在同一个SqlSessionFactory上动一个地方牵一发动全身。这就是多数据源要解决的核心问题让不同的业务模块或不同的操作类型走不同的数据库连接。我见过不少团队在这个环节翻车。有人直接复制一套完整的Mybatis配置改个包名就完事结果两个数据源互相污染事务管理器打架启动就报错。也有人用AOP切面硬切切点写得太宽把不该切的也切过去了导致写操作跑到了从库上数据一致性直接崩掉。这篇文章要聊的就是怎么用SpringBoot配合Mybatis干净利落地把多数据源配好。我会把读写分离作为主要场景来展开因为这是最常见也最典型的用法。但思路是通用的你换成多个业务库、多个租户库逻辑完全一样。适合谁看已经用过SpringBoot和Mybatis但对多数据源配置还没摸清门道的开发者。如果你连Mybatis的基本配置都没写过建议先把单数据源跑通再来看这篇。读完你能得到什么一套可以直接抄的配置方案理解每个配置项为什么这么写知道哪些坑一定会踩以及怎么绕过去。2. 整体设计思路与方案选型2.1 为什么不用现成的多数据源中间件市面上有一些现成的多数据源组件比如动态数据源路由框架。它们确实能省事加个注解就切过去了。但我不推荐在读写分离这个场景下直接用原因有两个。第一读写分离的切换逻辑应该是确定的、可预期的。写操作走主库读操作走从库这个规则非常清晰。用中间件做动态路由反而引入了一层不确定性——万一路由规则配错了写请求跑到从库排查起来非常痛苦。第二中间件会掩盖底层细节。当你需要做一些精细控制的时候比如某个查询必须走主库因为刚写完立刻要读从库可能有延迟中间件提供的扩展点往往不够灵活。自己配的多数据源想怎么改就怎么改。当然如果你面对的是十几个数据源的复杂场景那确实需要考虑中间件。但读写分离这种两数据源的情况手写配置是最稳的。2.2 核心设计原则一个数据源一套独立配置多数据源配置最容易犯的错误就是让两个数据源共享某些组件。比如共用一个DataSource或者共用一个SqlSessionFactory。这样做的后果是你以为是两个数据源实际上底层还是同一个连接池切换根本没生效。正确的做法是每个数据源拥有完全独立的DataSource、SqlSessionFactory、SqlSessionTemplate和事务管理器。它们之间不共享任何实例通过包路径来区分各自的Mapper接口。打个比方这就像两个独立的厨房。主库厨房负责炒菜写从库厨房负责摆盘读。两个厨房各有自己的灶台、锅具、食材互不干扰。你不能让两个厨房共用一个灶台那样菜就串味了。2.3 读写分离的切换策略选择读写分离的切换方式主要有三种切换方式实现难度灵活性适用场景包路径区分低中读写Mapper天然分离的项目AOP注解切换中高需要细粒度控制的方法级切换方法名前缀匹配低低命名规范严格统一的团队我推荐包路径区分作为基础方案。把读操作的Mapper放在一个包下写操作的Mapper放在另一个包下各自绑定不同的数据源。这种方式最直观出问题最容易排查。如果项目里读写操作混在同一个Mapper里那就需要配合AOP注解来做方法级切换。但这种情况我建议先重构代码把读写Mapper拆开而不是用AOP去硬切。2.4 事务管理的边界问题多数据源场景下事务管理器必须和SqlSessionFactory绑定。也就是说主库的事务管理器只管主库的SqlSessionFactory从库的事务管理器只管从库的。这里有个关键点跨数据源的事务是不支持的。你不能在一个事务里同时写主库和读从库然后期望它们一起提交或回滚。这是分布式事务的范畴需要额外的框架支持。在实际操作中我的建议是写操作的事务只走主库读操作不开事务或者只开只读事务。这样边界清晰不会出现事务管理器找不到对应数据源的情况。3. 核心配置细节与实操要点3.1 依赖引入与版本选择先看依赖。SpringBoot项目里Mybatis的starter是必须的数据库驱动按你实际用的选。dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency版本选择上有个经验Mybatis starter的版本要和SpringBoot版本匹配。SpringBoot 2.7.x配Mybatis starter 2.3.xSpringBoot 3.x配Mybatis starter 3.x。版本不匹配最常见的表现是启动时报NoSuchMethodError找半天找不到原因。数据库驱动这块MySQL 8.x用com.mysql.cj.jdbc.Driver5.x用com.mysql.jdbc.Driver。这个细节很多人会忽略配错了启动直接报驱动加载失败。3.2 配置文件的结构设计配置文件我建议用application.yml结构清晰层级分明。核心配置如下spring: datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://主库地址:3306/数据库名?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: 主库用户名 password: 主库密码 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 slave: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://从库地址:3306/数据库名?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: 从库用户名 password: 从库密码 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000这里有几个关键点需要展开说。第一jdbc-url而不是url。当你在SpringBoot里手动创建DataSource Bean的时候必须用jdbc-url。用url的话HikariCP会报错说找不到这个属性。这是因为SpringBoot的自动配置和手动配置对属性名的要求不一样很多人在这里卡住。第二连接池参数要分开配。主库和从库的负载特征不一样主库写多从库读多。从库的maximum-pool-size可以适当调大因为读请求通常并发更高。但也不要无脑调大连接数超过数据库的max_connections限制反而会导致连接失败。第三serverTimezone必须配。MySQL 8.x的驱动如果不配时区会报时区错误。Asia/Shanghai是东八区国内项目用这个就行。3.3 主数据源的配置类写法配置类是多数据源的核心。先看主数据源的Configuration MapperScan( basePackages com.example.project.mapper.master, sqlSessionFactoryRef masterSqlSessionFactory ) public class MasterDataSourceConfig { Primary Bean(name masterDataSource) ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Primary Bean(name masterSqlSessionFactory) public SqlSessionFactory masterSqlSessionFactory( Qualifier(masterDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/master/*.xml) ); org.apache.ibatis.session.Configuration configuration new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); configuration.setCacheEnabled(false); bean.setConfiguration(configuration); return bean.getObject(); } Primary Bean(name masterSqlSessionTemplate) public SqlSessionTemplate masterSqlSessionTemplate( Qualifier(masterSqlSessionFactory) SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } Primary Bean(name masterTransactionManager) public DataSourceTransactionManager masterTransactionManager( Qualifier(masterDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }这段代码里有几个地方需要特别注意。Primary注解不能省。Spring容器里有两个DataSource Bean的时候如果不指定哪个是主自动注入会报错。加上Primary告诉Spring默认用这个。MapperScan的sqlSessionFactoryRef必须指定。如果不指定Mybatis会去找默认的SqlSessionFactory在多数据源环境下就会找错。basePackages的路径要精确。主库的Mapper放在mapper.master包下从库的放在mapper.slave包下。包名不要重叠否则一个Mapper会被两个数据源都扫描到行为不可预期。mapperLocations的路径也要分开。XML文件和Mapper接口的包路径最好保持一致的结构这样维护起来清晰。3.4 从数据源的配置类写法从数据源的配置和主数据源结构一样但有几个关键区别Configuration MapperScan( basePackages com.example.project.mapper.slave, sqlSessionFactoryRef slaveSqlSessionFactory ) public class SlaveDataSourceConfig { Bean(name slaveDataSource) ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } Bean(name slaveSqlSessionFactory) public SqlSessionFactory slaveSqlSessionFactory( Qualifier(slaveDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/slave/*.xml) ); org.apache.ibatis.session.Configuration configuration new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); configuration.setCacheEnabled(false); bean.setConfiguration(configuration); return bean.getObject(); } Bean(name slaveSqlSessionTemplate) public SqlSessionTemplate slaveSqlSessionTemplate( Qualifier(slaveSqlSessionFactory) SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } Bean(name slaveTransactionManager) public DataSourceTransactionManager slaveTransactionManager( Qualifier(slaveDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }从数据源不加Primary。这是和主数据源最大的区别。不加Primary意味着它不是默认的只有在明确指定的时候才会被注入。从库的事务管理器通常用不上。因为读操作一般不开事务或者只开只读事务。但为了配置完整性还是把它创建出来万一有需要呢。从库的cacheEnabled建议设为false。Mybatis的二级缓存是SqlSessionFactory级别的两个数据源各有一份缓存如果开启的话主库更新了数据从库的缓存不会自动失效会读到脏数据。关掉最省心。3.5 包结构规划建议包结构规划看起来是小事但直接影响后期的维护成本。我推荐的结构是这样的com.example.project ├── config │ ├── MasterDataSourceConfig.java │ └── SlaveDataSourceConfig.java ├── mapper │ ├── master │ │ ├── UserWriteMapper.java │ │ └── OrderWriteMapper.java │ └── slave │ ├── UserReadMapper.java │ └── OrderReadMapper.java └── service ├── UserService.java └── OrderService.javaMapper接口按读写分开放。写操作的Mapper放mapper.master读操作的放mapper.slave。Service层调用的时候根据业务需要注入对应的Mapper。XML文件路径和Mapper接口路径对应。resources/mapper/master/下放主库的XMLresources/mapper/slave/下放从库的XML。这样找文件的时候一目了然。不要在Service层做数据源切换。Service层只负责业务逻辑它不关心数据从哪来。数据源的切换在Mapper层就决定了Service层只管调用。4. 完整实操流程与关键环节4.1 环境准备与项目初始化假设你已经有一个SpringBoot项目现在要给它加上多数据源。第一步是确认现有的单数据源配置把原来的DataSource自动配置排除掉。在启动类上加SpringBootApplication(exclude {DataSourceAutoConfiguration.class}) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }为什么要排除自动配置SpringBoot默认会自动创建一个DataSource如果我们手动创建了两个加上自动配置的那个就是三个容器会混乱。排除掉自动配置完全由我们自己控制。这一步很多人会忘结果启动时报DataSource注入冲突找半天原因。4.2 主从数据源的创建与验证配置类写完之后怎么验证数据源真的生效了我常用的方法是写一个简单的测试接口RestController public class DataSourceCheckController { Autowired Qualifier(masterDataSource) private DataSource masterDataSource; Autowired Qualifier(slaveDataSource) private DataSource slaveDataSource; GetMapping(/check/datasource) public String checkDataSource() throws SQLException { try (Connection masterConn masterDataSource.getConnection(); Connection slaveConn slaveDataSource.getConnection()) { String masterUrl masterConn.getMetaData().getURL(); String slaveUrl slaveConn.getMetaData().getURL(); return master: masterUrl \nslave: slaveUrl; } } }访问这个接口如果返回的两个URL不一样说明数据源配置成功了。如果返回一样的URL那说明两个数据源指向了同一个库配置有问题。这个验证步骤非常关键。我见过有人配了半天以为生效了结果两个数据源连的是同一个库读写分离根本没起作用。4.3 Mapper层的读写分离实现数据源配好之后接下来是Mapper层的实现。先看写操作的MapperMapper public interface UserWriteMapper { int insertUser(User user); int updateUser(User user); int deleteUserById(Long id); }对应的XMLmapper namespacecom.example.project.mapper.master.UserWriteMapper insert idinsertUser parameterTypecom.example.project.entity.User INSERT INTO user (name, age, email) VALUES (#{name}, #{age}, #{email}) /insert update idupdateUser parameterTypecom.example.project.entity.User UPDATE user SET name #{name}, age #{age} WHERE id #{id} /update delete iddeleteUserById DELETE FROM user WHERE id #{id} /delete /mapper再看读操作的MapperMapper public interface UserReadMapper { User selectUserById(Long id); ListUser selectUserList(); }对应的XMLmapper namespacecom.example.project.mapper.slave.UserReadMapper select idselectUserById resultTypecom.example.project.entity.User SELECT * FROM user WHERE id #{id} /select select idselectUserList resultTypecom.example.project.entity.User SELECT * FROM user /select /mapper关键点写Mapper的XML放在mapper/master/下读Mapper的XML放在mapper/slave/下。两个XML的namespace分别对应各自的Mapper接口全限定名。4.4 Service层的调用方式Service层注入Mapper的时候直接按类型注入就行因为两个Mapper接口是不同的类型不会冲突Service public class UserService { Autowired private UserWriteMapper userWriteMapper; Autowired private UserReadMapper userReadMapper; Transactional(transactionManager masterTransactionManager) public void createUser(User user) { userWriteMapper.insertUser(user); } public User getUserById(Long id) { return userReadMapper.selectUserById(id); } public ListUser getUserList() { return userReadMapper.selectUserList(); } }写操作加Transactional并指定事务管理器。transactionManager masterTransactionManager明确告诉Spring用主库的事务管理器。不指定的话Spring会去找默认的事务管理器在多数据源环境下可能找错。读操作不加事务。读操作本身不需要事务加了反而多一层开销。如果非要加用Transactional(readOnly true)但还是要指定事务管理器。4.5 读写分离后的数据一致性处理读写分离有一个经典问题写完主库立刻读从库可能读不到刚写的数据。因为主从同步有延迟从库的数据可能还是旧的。处理这个问题有几种策略策略一写后读走主库。在写完之后的短时间内强制读主库。实现方式是在读Mapper里加一个走主库的方法或者用AOP注解标记。策略二关键业务不走读写分离。比如支付、订单创建这种对一致性要求极高的场景读写都走主库。读写分离只用在报表查询、列表展示这种对延迟不敏感的场景。策略三使用半同步复制。在数据库层面配置半同步复制确保主库提交的事务至少同步到一个从库。但这会增加写操作的延迟。我个人的选择是策略一和策略二结合。核心业务读写都走主库非核心的查询走从库。这样既减轻了主库压力又保证了关键业务的一致性。4.6 连接池参数调优实操连接池参数不是拍脑袋定的要根据实际负载来算。以HikariCP为例maximum-pool-size的计算假设你的数据库max_connections是500有3个应用实例每个实例配了两个数据源。那么每个数据源的连接数上限是500 / (3 * 2) ≈ 83。但实际不需要这么多一般按CPU核心数 * 2 磁盘数来估算。4核8G的机器配20个连接足够了。connection-timeout的设置默认30秒。如果数据库响应慢可以适当调大。但不要超过60秒否则请求会堆积。idle-timeout和max-lifetimeidle-timeout建议设10分钟max-lifetime建议设30分钟。max-lifetime要比数据库的wait_timeout小否则连接会被数据库主动断开应用这边还以为连接是好的用的时候报错。hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 connection-test-query: SELECT 1connection-test-queryMySQL 8.x的驱动支持JDBC4的isValid()方法可以不配这个。但如果用的是老版本驱动配一个SELECT 1更保险。5. 常见问题与排查技巧实录5.1 启动报错DataSource注入冲突现象启动时报NoUniqueBeanDefinitionException说找到了多个DataSource Bean。原因没有排除SpringBoot的DataSource自动配置或者两个DataSource都没有加Primary。解决在启动类上加SpringBootApplication(exclude {DataSourceAutoConfiguration.class})并确保主数据源加了Primary。5.2 Mapper扫描不到Invalid bound statement现象调用Mapper方法时报Invalid bound statement (not found)。原因通常是MapperScan的basePackages路径写错了或者XML文件的路径和Mapper接口的namespace不匹配。排查步骤检查MapperScan的basePackages是否包含了Mapper接口所在的包。检查XML文件的namespace是否等于Mapper接口的全限定名。检查mapperLocations的路径是否能匹配到XML文件。检查XML文件中的select、insert等标签的id是否和Mapper接口的方法名一致。5.3 事务不生效写操作没有回滚现象写操作抛异常了但数据没有回滚。原因事务管理器指定错了或者Transactional注解用在了非public方法上。排查步骤检查Transactional的transactionManager属性是否指定了正确的事务管理器。检查方法是否是public的。Spring的AOP代理只对public方法生效。检查是否在同一个类内部调用了带Transactional的方法。内部调用不走代理事务不生效。5.4 读写分离失效读操作走了主库现象配置了读写分离但读操作还是走主库。原因读Mapper被扫描到了主数据源的MapperScan范围内或者读Mapper的包路径和写Mapper的包路径重叠了。排查步骤检查两个MapperScan的basePackages是否有重叠。检查读Mapper的接口是否放在了mapper.master包下。在数据库层面开启慢查询日志观察读操作的SQL是否出现在主库的日志里。5.5 连接池耗尽Connection is not available现象请求量大的时候报Connection is not available, request timed out。原因连接池的maximum-pool-size设小了或者有连接泄漏。排查步骤检查maximum-pool-size是否够用。可以临时调大观察是否缓解。检查代码里是否有手动获取Connection但没有关闭的地方。检查是否有长事务占着连接不放。长事务会一直占用连接导致其他请求拿不到连接。开启HikariCP的泄漏检测spring.datasource.master.hikari.leak-detection-threshold60000超过60秒没归还的连接会打印堆栈。5.6 常见问题速查表问题现象可能原因快速排查方法启动报DataSource冲突未排除自动配置或未加Primary检查启动类exclude和Primary注解Invalid bound statementMapperScan路径错或XML namespace不匹配核对包路径和namespace事务不回滚事务管理器指定错或方法非public检查Transactional配置和方法修饰符读操作走主库Mapper包路径重叠检查两个MapperScan的basePackages连接池耗尽连接数不够或连接泄漏调大连接数并开启泄漏检测从库读到旧数据主从同步延迟关键业务走主库或加缓存5.7 几个我踩过的坑坑一ConfigurationProperties的prefix写错。spring.datasource.master和spring.datasource.master.hikari是两个不同的层级。如果prefix写成spring.datasource.master.hikari那jdbc-url就绑不上了。正确的写法是prefix写到spring.datasource.masterHikariCP的参数会自动绑定。坑二两个数据源用了同一个连接池名字。HikariCP默认的连接池名字是HikariPool-1、HikariPool-2这样自动生成的。但如果手动指定了pool-name两个数据源用了同一个名字日志里就分不清是哪个数据源的连接了。建议给每个数据源指定不同的pool-name。坑三从库的readOnly没设。从库的连接建议设为只读spring.datasource.slave.hikari.read-onlytrue。这样即使代码里不小心写了写操作数据库层面也会拒绝多一层保护。坑四忘了配serverTimezone。MySQL 8.x的驱动不配时区会报错而且报错信息不太直观容易让人以为是别的问题。serverTimezoneAsia/Shanghai是标配。坑五主从库的数据库名不一样。有些团队主库和从库的数据库名不同但表结构一样。这种情况下jdbc-url里的数据库名要分别写对。如果写错了会报表不存在但表其实是在另一个库名下的。6. 进阶扩展与生产环境建议6.1 多数据源的监控生产环境上两个数据源的连接池状态需要分别监控。SpringBoot Actuator可以暴露HikariCP的指标management: endpoints: web: exposure: include: health,metrics,hikaricp访问/actuator/metrics/hikaricp.connections.active可以看到活跃连接数。但默认只暴露一个数据源的指标多数据源需要自定义Metrics注册。更简单的做法是写一个定时任务定期打印两个连接池的状态Component public class DataSourceMonitor { Autowired Qualifier(masterDataSource) private DataSource masterDataSource; Autowired Qualifier(slaveDataSource) private DataSource slaveDataSource; Scheduled(fixedRate 60000) public void monitor() { printPoolStatus(master, masterDataSource); printPoolStatus(slave, slaveDataSource); } private void printPoolStatus(String name, DataSource dataSource) { if (dataSource instanceof HikariDataSource) { HikariDataSource hikari (HikariDataSource) dataSource; System.out.println(name pool - active: hikari.getHikariPoolMXBean().getActiveConnections() , idle: hikari.getHikariPoolMXBean().getIdleConnections() , total: hikari.getHikariPoolMXBean().getTotalConnections()); } } }6.2 动态数据源切换的扩展如果项目后期需要支持更多的数据源或者需要运行时动态添加数据源可以基于AbstractRoutingDataSource做扩展。核心思路是创建一个DynamicDataSource继承AbstractRoutingDataSource。重写determineCurrentLookupKey()方法从ThreadLocal里获取当前线程的数据源标识。写一个AOP切面在方法执行前设置数据源标识执行后清除。但这种方案复杂度较高除非确实有动态切换的需求否则不建议上。读写分离这种固定规则用包路径区分就够了。6.3 主从延迟的应对策略主从延迟是读写分离绕不开的问题。除了前面说的写后读走主库还有几个工程上的处理方式方式一缓存兜底。写操作完成后把数据写入Redis读操作先查Redis查不到再查从库。这样即使从库有延迟也能从缓存拿到最新数据。方式二延迟感知。在从库上执行SHOW SLAVE STATUS获取Seconds_Behind_Master的值。如果延迟超过阈值读操作自动降级到主库。但这个方案需要额外的监控组件。方式三业务容忍。对于列表查询、报表统计这类场景几秒的延迟是可以接受的。只要核心业务的实时性得到保证非核心业务走从库完全没问题。6.4 生产环境配置清单最后整理一份生产环境的配置清单可以直接对照检查配置项建议值说明maximum-pool-size20-50根据数据库max_connections和应用实例数计算minimum-idle5-10保持一定数量的热连接connection-timeout3000030秒不宜过长idle-timeout60000010分钟max-lifetime180000030分钟小于数据库wait_timeoutread-only从库true从库连接设为只读pool-namemaster-pool/slave-pool区分两个连接池connection-test-querySELECT 1老版本驱动需要serverTimezoneAsia/ShanghaiMySQL 8.x必须配这套配置我在几个项目里用过稳定性没问题。但每个项目的负载特征不一样连接数需要根据实际情况调整。上线前建议做一轮压测观察连接池的活跃连接数峰值再反推合适的maximum-pool-size。我个人在实际操作中的体会是多数据源配置本身不难难的是边界清晰。主库管写从库管读事务各管各的包路径各扫各的。只要这几个边界守住了后面基本不会出问题。最怕的就是边界模糊一会儿读走主库一会儿写走从库那排查起来就是噩梦。