1. 从一个真实的翻车现场说起:为什么你的多数据源配置总在“看起来没问题”时崩掉
我第一次在生产环境配多数据源,是在一个订单查询模块上。当时的需求很朴素:主库写订单,从库扛查询,读写分离,减轻主库压力。我照着网上搜到的“五分钟搞定SpringBoot多数据源”的帖子,把两个DataSource的Bean配好,@Primary标上,MapperScan分好包,本地跑得飞起。结果上线第二天,运营反馈“刚下的订单查不到”。我一看日志,写库确实写进去了,但读请求全打到从库,而从库的同步延迟在高峰期能到两三秒——用户下单后立刻跳转订单详情页,读从库自然读不到。
这个坑的本质,不是配置写错了,而是读写分离的“读”和“写”在业务语义上被粗暴地一刀切了。很多教程只教你“怎么配两个数据源”,却不告诉你“配完之后,哪些查询必须走主库”。这就是我写这篇东西的动机:把SpringBoot + MyBatis多数据源这件事,从“能跑起来”讲到“敢放生产”。
这篇文章适合三类人:一是刚接触多数据源、被各种Bean冲突和事务问题搞得头大的开发者;二是已经在用多数据源、但读写分离策略比较粗糙、想优化的人;三是想搞清楚“动态数据源切换”底层到底怎么实现的同学。我会从最朴素的配置讲起,一路讲到AOP动态切换、事务边界、以及那些文档里不会写的坑。核心关键词就几个:SpringBoot、MyBatis、多数据源、读写分离、动态数据源、AOP、事务管理。
先说结论:多数据源配置本身不难,难的是切换时机的判断和事务传播时的数据源一致性。把这两件事想清楚,剩下的都是体力活。
2. 两种主流方案的选择:静态分包 vs 动态切换,别一上来就选错
在动手写代码之前,得先想清楚用哪种方案。市面上关于SpringBoot多数据源的实现,归根结底就两条路:静态分包和动态切换。很多人一上来就抄动态数据源的代码,结果项目里其实只有一两个固定场景需要走从库,白白引入了一堆复杂度。
2.1 静态分包方案:简单粗暴,但有硬伤
静态分包的核心思路是:建两个包,比如mapper.master和mapper.slave,然后用两个@MapperScan分别扫描,各自绑定不同的SqlSessionFactory。写操作走master包的Mapper,读操作走slave包的Mapper。
这种方案的好处是零魔法——你调用哪个Mapper,就走哪个数据源,一目了然,调试的时候不用猜。配置起来也直白:
@Configuration @MapperScan(basePackages = "com.example.mapper.master", sqlSessionFactoryRef = "masterSqlSessionFactory") public class MasterDataSourceConfig { // 配置 masterDataSource、masterSqlSessionFactory、masterTransactionManager }但硬伤也很明显:同一个业务方法里,如果既要写又要读,你得手动调两个包的Mapper。比如“插入订单后立刻返回订单详情”,写用master的OrderMapper.insert,读用slave的OrderMapper.selectById,代码里到处是这种割裂的调用。更麻烦的是事务——@Transactional默认只认一个TransactionManager,跨数据源的事务它管不了。
我个人的判断标准很简单:如果你的读写场景是物理隔离的(比如报表查询走从库、业务写入走主库,两者几乎不交叉),静态分包够用且省心。如果同一个业务链路里读写交织,别犹豫,上动态切换。
2.2 动态切换方案:用AOP在方法级别决定走哪个库
动态切换的核心是AbstractRoutingDataSource——这是Spring提供的一个抽象类,它内部维护一个Map<Object, DataSource>,你通过determineCurrentLookupKey()方法返回一个key,它就帮你路由到对应的数据源。
整个链路是这样的:定义一个@DataSource注解,标在Service方法上;写一个AOP切面,在方法执行前把注解里的数据源标识塞进ThreadLocal;AbstractRoutingDataSource在获取连接时,从ThreadLocal里取出标识,决定用哪个库。
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceType(); } }DataSourceContextHolder就是一个ThreadLocal的持有者:
public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSourceType(String type) { CONTEXT.set(type); } public static String getDataSourceType() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }这里有个必须注意的细节:ThreadLocal用完一定要remove(),否则在Tomcat的线程池场景下,线程被复用时会带着上一次的数据源标识,导致路由错乱。我见过有人排查了一整天“为什么偶尔查询打到主库”,最后就是切面里忘了在finally里清理。
2.3 选型对比:一张表说清楚
| 维度 | 静态分包 | 动态切换 |
|---|---|---|
| 实现复杂度 | 低,纯配置 | 中,需要AOP + ThreadLocal |
| 切换粒度 | 包级别(Mapper级别) | 方法级别 |
| 事务支持 | 单数据源事务,跨库需手动 | 可配合事务传播控制 |
| 调试难度 | 低,调用即所见 | 中,需关注ThreadLocal状态 |
| 适用场景 | 读写物理隔离、报表分离 | 读写交织、多租户、分库分表前置 |
我的建议是:新项目直接上动态切换,因为它的扩展性更好,后面要加分库或者多租户,改造成本低。老项目如果只是想把报表查询挪到从库,静态分包改起来更快。
3. 动态数据源的完整落地:从注解到AOP再到路由,每一步都别省
选定了动态切换,接下来就是把它完整地搭起来。我见过太多人只写了AbstractRoutingDataSource就以为完事了,结果注解没生效、切面顺序不对、事务里切换失效,问题一个接一个。这一章我把每个环节拆开讲,顺便把容易踩的坑标出来。
3.1 数据源注册:为什么不能直接new一个DataSource
配置多数据源的第一步,是把主库和从库的DataSource都注册成Bean。这里有个常见的错误:有人直接new HikariDataSource()然后手动set各种属性。这样做不是不行,但你会失去SpringBoot的自动配置能力,比如连接池的监控、健康检查、以及配置文件里的spring.datasource.hikari.*这些参数都不会生效。
正确的做法是用@ConfigurationProperties把配置文件里的属性绑定到DataSource上:
@Bean(name = "masterDataSource") @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean(name = "slaveDataSource") @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); }对应的application.yml:
spring: datasource: master: jdbc-url: jdbc:mysql://master-host:3306/order_db username: app_user password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 slave: jdbc-url: jdbc:mysql://slave-host:3306/order_db username: read_user password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 30 minimum-idle: 10注意:多数据源场景下,配置文件的key是
jdbc-url而不是url。用url的话,HikariCP会报“jdbcUrl is required”的错。这个坑我第一次配的时候踩过,因为单数据源时用url是没问题的。
从库的连接池大小我一般配得比主库大一些,因为读请求通常比写请求多。但也不是越大越好——从库的连接数受限于数据库的max_connections,配太大反而会导致连接被拒绝。我的经验值是:从库连接池上限 = 数据库max_connections × 0.6 / 从库实例数,留出余量给其他应用。
3.2 路由数据源的构建:targetDataSources和defaultTargetDataSource
有了两个DataSource,接下来要构建DynamicDataSource。它需要两个关键属性:targetDataSources(一个Map,存所有数据源)和defaultTargetDataSource(默认走哪个)。
@Bean(name = "dynamicDataSource") @Primary public DataSource dynamicDataSource( @Qualifier("masterDataSource") DataSource master, @Qualifier("slaveDataSource") DataSource slave) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", master); targetDataSources.put("slave", slave); DynamicDataSource dynamicDataSource = new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(master); return dynamicDataSource; }这里@Primary很关键——它告诉Spring,当有多个DataSourceBean时,优先注入这个。SqlSessionFactory和TransactionManager都会依赖它。
setDefaultTargetDataSource(master)的意思是:如果ThreadLocal里没有设置数据源标识,默认走主库。这个设计是安全兜底——宁可走主库(可能有性能压力),也不能走从库(可能读到脏数据)。我见过有人把默认设成从库,结果定时任务里的写操作全打到从库,直接报错。
3.3 注解与切面的配合:顺序错了,一切白搭
@DataSource注解本身很简单:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DataSource { String value() default "master"; }关键是切面的实现。切面需要在目标方法执行之前设置数据源,执行之后清理:
@Aspect @Component @Order(1) public class DataSourceAspect { @Around("@annotation(dataSource)") public Object around(ProceedingJoinPoint point, DataSource dataSource) throws Throwable { String type = dataSource.value(); DataSourceContextHolder.setDataSourceType(type); try { return point.proceed(); } finally { DataSourceContextHolder.clear(); } } }@Order(1)这个值很重要。如果项目里还有事务切面(@Transactional),事务切面的Order默认是Ordered.LOWEST_PRECEDENCE,也就是最后执行。这意味着数据源切面会先于事务切面执行,ThreadLocal在事务开启之前就已经设置好了,事务拿到的连接就是正确的数据源。如果顺序反了,事务先开启、绑定了默认数据源,后面再切就来不及了。
提示:如果你的事务切面也自定义了Order,确保数据源切面的Order值小于事务切面的Order值。数字越小,优先级越高,越先执行。
3.4 Service层的使用姿势:注解标在哪里最合适
注解可以标在方法上,也可以标在类上。标在类上表示该类所有方法默认走某个数据源,方法上的注解会覆盖类上的。
我的习惯是:写操作方法显式标@DataSource("master"),读操作方法显式标@DataSource("slave"),不依赖默认值。这样做的好处是代码即文档,任何人看到方法就知道走哪个库。虽然多写几个字,但省去了“这个方法到底走哪个库”的猜测成本。
@Service public class OrderService { @DataSource("master") @Transactional public void createOrder(Order order) { orderMapper.insert(order); } @DataSource("slave") public Order getOrderById(Long id) { return orderMapper.selectById(id); } }注意createOrder上同时有@DataSource和@Transactional。由于数据源切面的Order更小,它会先执行,设置好ThreadLocal,然后事务切面开启事务时,DynamicDataSource根据ThreadLocal返回主库连接,事务就绑定在主库上。这个顺序是保证事务内数据源一致的关键。
4. 读写分离的“读”到底该走哪个库:主从延迟下的策略设计
配置跑通只是第一步,真正决定读写分离成败的,是读请求的路由策略。很多人配完多数据源,就简单地把所有查询都标成@DataSource("slave"),结果就是我开头说的那个坑——刚写入的数据查不到。
4.1 主从延迟不是异常,是常态
MySQL的主从复制是异步的(默认情况下),主库写完binlog,从库拉取、回放,这中间有延迟。延迟的大小取决于几个因素:主库的写入压力、从库的回放能力、网络带宽、以及大事务的存在。在低峰期,延迟可能只有几十毫秒;高峰期或者有大事务时,延迟到几秒甚至十几秒都有可能。
所以**“写后立即读”的场景,必须走主库**。这不是配置问题,是业务语义问题。哪些场景属于“写后立即读”?我列几个典型的:
- 用户提交订单后跳转到订单详情页
- 用户修改资料后刷新个人中心
- 后台管理员新增配置后立即查看配置列表
- 任何“操作成功”提示后紧接着的查询
这些场景的共同特征是:用户刚刚执行了写操作,并且期望立刻看到写操作的结果。如果走从库,用户会看到旧数据,体验上就是“我明明改了,怎么没变”。
4.2 用“强制主库”注解兜底
针对这些场景,我的做法是提供一个@DataSource("master")的显式标注,在读方法上强制走主库。虽然牺牲了一点从库的读性能,但保证了用户体验。
@DataSource("master") public Order getOrderDetailAfterCreate(Long orderId) { return orderMapper.selectById(orderId); }有人会问:那这样读写分离还有什么意义?意义在于,大部分查询不是“写后立即读”。比如订单列表页、历史订单查询、报表统计,这些查询对延迟不敏感,走从库完全没问题。真正需要走主库的“写后立即读”只占一小部分。把这一小部分识别出来,剩下的走从库,读写分离的收益就出来了。
4.3 更优雅的方案:基于GTID或位点的延迟感知
如果你的团队有精力做更精细的控制,可以考虑延迟感知方案:在从库上执行SHOW SLAVE STATUS,读取Seconds_Behind_Master,如果延迟超过阈值(比如1秒),就自动把读请求路由到主库。
这个方案的实现思路是:写一个定时任务,每隔几秒检查一次从库延迟,把结果存到一个原子变量里;DynamicDataSource在路由时,如果发现当前是读请求且延迟超阈值,就返回master的key。
public class DelayAwareDataSource extends AbstractRoutingDataSource { private final AtomicLong slaveDelay = new AtomicLong(0); @Override protected Object determineCurrentLookupKey() { String type = DataSourceContextHolder.getDataSourceType(); if ("slave".equals(type) && slaveDelay.get() > 1000) { return "master"; } return type; } }这个方案的好处是自动化,不需要人工判断哪些查询该走主库。但代价是引入了额外的监控逻辑,而且SHOW SLAVE STATUS本身也有开销。我的建议是:中小项目用显式注解就够了,大项目或者对一致性要求极高的场景再考虑延迟感知。
4.4 一个容易忽略的点:从库的读一致性
即使没有主从延迟,从库的读也可能不一致——如果从库是只读的,但主库和从库的隔离级别不同,或者从库上有其他写入(比如某些运维操作),读到的数据可能和主库不一样。这种情况比较少见,但在排查“数据对不上”的问题时,要把它纳入考虑。
我的经验是:在从库上设置read_only=1,确保从库只接受复制写入,不接受应用写入。这样至少排除了“从库被误写”的可能性。
5. 事务与多数据源的纠缠:那些@Transactional管不到的地方
多数据源和事务放在一起,是问题最集中的地方。@Transactional默认只认一个TransactionManager,而多数据源意味着有多个TransactionManager。如果不做特殊处理,跨数据源的操作要么报错,要么静默地只在一个数据源上生效。
5.1 单数据源事务:确保TransactionManager绑定正确
在动态数据源方案下,我们通常只注册一个TransactionManager,它绑定的是DynamicDataSource:
@Bean public PlatformTransactionManager transactionManager( @Qualifier("dynamicDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }这样@Transactional开启事务时,DynamicDataSource会根据ThreadLocal返回对应的连接。前提是数据源切面在事务切面之前执行,这一点在第3.3节已经强调过了。
如果项目里有多个TransactionManager(比如静态分包方案),@Transactional需要显式指定用哪个:
@Transactional(transactionManager = "masterTransactionManager")不指定的话,Spring会按类型找,找到多个就报NoUniqueBeanDefinitionException。
5.2 跨数据源事务:别硬扛,用最终一致性
如果业务真的需要“主库写入 + 从库写入”在同一个事务里,那@Transactional是做不到的。XA事务(两阶段提交)理论上可以,但性能差、运维复杂,互联网场景基本不用。
我的建议是:拆开,用最终一致性。主库写入成功后,发一个消息(MQ或者本地消息表),从库的写入由消费者异步完成。这样主库事务只管主库,从库写入失败可以重试,整体上达到最终一致。
如果业务上不能接受异步,那就得重新审视:这个从库写入真的必要吗?很多时候,所谓的“从库写入”其实是历史遗留设计,实际上从库只需要读,不需要写。
5.3 事务内的数据源切换:为什么切了也没用
有一个经典的误区:在@Transactional方法内部,手动调用DataSourceContextHolder.setDataSourceType("slave"),期望事务内的后续操作走从库。这是无效的,因为事务开启时已经绑定了连接,后续的切换不会改变已经绑定的连接。
@Transactional public void wrongExample() { masterMapper.insert(order); // 走主库 DataSourceContextHolder.setDataSourceType("slave"); slaveMapper.select(id); // 还是走主库!因为事务连接已绑定 }要解决这个问题,只能把读操作放到事务外面,或者用REQUIRES_NEW开一个新事务:
@Transactional(propagation = Propagation.REQUIRES_NEW) @DataSource("slave") public Order readFromSlave(Long id) { return orderMapper.selectById(id); }但REQUIRES_NEW会挂起当前事务、开启新事务,开销不小,而且如果外层事务还没提交,新事务读从库照样读不到数据。所以最稳妥的做法还是把读写拆到不同的方法里,读方法不加事务。
6. 上线前必须验证的几件事:我的检查清单
配置写完、代码跑通,不代表可以上线。多数据源的问题往往在特定条件下才暴露,比如高并发、主从切换、连接池耗尽。这一章我列一份上线前的检查清单,都是我用血泪换来的。
6.1 数据源路由的验证:别只看日志,要看实际连接
验证路由是否生效,最直接的方法是在数据库层面看连接来源。MySQL可以通过SHOW PROCESSLIST看到当前连接来自哪个IP。如果你在应用里执行一个查询,然后在主库和从库上分别看PROCESSLIST,就能确认请求到底打到了哪个库。
我见过有人只看应用日志里的“使用数据源:slave”,就以为路由生效了,结果实际连接还是主库——因为AbstractRoutingDataSource的determineCurrentLookupKey返回了slave,但targetDataSources里没有slave这个key,它就会fallback到defaultTargetDataSource。日志和实际行为可能不一致,必须从数据库侧验证。
6.2 连接池的监控:别等耗尽才发现
多数据源意味着多个连接池,每个池都要监控。HikariCP提供了hikaricp_connections_active、hikaricp_connections_idle、hikaricp_connections_pending等指标,接入Prometheus或者SpringBoot Actuator就能看到。
重点关注的指标是pending——如果有线程在等待连接,说明池子太小或者连接泄漏。连接泄漏的排查方法是:看active连接数是否持续增长不下降,如果是,大概率是有地方拿了连接没关。MyBatis的Mapper方法通常会自动关连接,但如果你手动用了SqlSession,记得在finally里close()。
6.3 主从切换的演练:从库挂了怎么办
生产环境的主从切换是常态——从库可能因为硬件故障、网络抖动、或者运维操作而短暂不可用。如果从库挂了,读请求会怎样?默认情况下,HikariCP会抛异常,用户看到报错。
我的做法是配置从库的降级策略:当从库连接失败时,自动降级到主库。实现方式是在DynamicDataSource里加一层判断,或者用Spring的AbstractRoutingDataSource配合自定义的DataSource包装。
public class FailoverDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { String type = DataSourceContextHolder.getDataSourceType(); if ("slave".equals(type) && !isSlaveAvailable()) { return "master"; } return type; } }isSlaveAvailable()可以基于连接池的健康状态判断,比如尝试获取一个连接,失败就返回false。这个方案会增加一点开销,但能保证从库故障时读请求不中断。
6.4 压测时的数据源表现:并发下的ThreadLocal污染
压测是暴露多数据源问题的好时机。我遇到过一个诡异的问题:压测时偶尔出现“写操作打到从库”的报错。排查后发现是线程池复用导致的ThreadLocal污染——某个线程上一次处理的是读请求,设置了slave,但切面的finally里没有清理(因为异常路径没走到finally),下一次这个线程处理写请求时,ThreadLocal里还是slave,写操作就打到从库了。
解决方法是在切面里用try-finally确保清理,并且在DataSourceContextHolder.clear()里用remove()而不是set(null)。set(null)只是把值设为null,ThreadLocal本身还在,remove()才是彻底清理。
@Around("@annotation(dataSource)") public Object around(ProceedingJoinPoint point, DataSource dataSource) throws Throwable { try { DataSourceContextHolder.setDataSourceType(dataSource.value()); return point.proceed(); } finally { DataSourceContextHolder.clear(); // 内部调用 remove() } }压测时还要关注连接池的并发获取。如果从库的连接池太小,高并发下大量线程等待连接,响应时间会飙升。我的经验值是:从库连接池上限 = 预估QPS × 平均查询耗时(秒) × 1.5。比如预估从库QPS是500,平均查询耗时20ms,那需要的连接数大约是500 × 0.02 × 1.5 = 15。留点余量,配20-30比较稳妥。
7. 几个我踩过的坑和对应的解法
前面几章偏系统性,这一章我单独拎几个具体的坑出来,都是我在实际项目里踩过的,每个坑都附上排查思路和解法。
7.1 @MapperScan重复扫描导致的Bean冲突
静态分包方案下,如果两个@MapperScan的basePackages有重叠,或者某个Mapper被两个SqlSessionFactory同时扫描,启动时会报BeanDefinitionOverrideException。排查方法是看报错里的Bean名称,找到是哪个Mapper被重复注册了。
解法是确保两个MapperScan的包路径完全隔离,不要有交集。如果有些Mapper确实需要被两个数据源共用(比如一些通用的查询),那就把它单独放一个包,只让一个SqlSessionFactory扫描,然后在需要的地方手动指定数据源。
7.2 事务未生效:@Transactional标在private方法上
这个坑和Spring AOP的代理机制有关——@Transactional和@DataSource都是基于AOP的,而Spring的AOP默认只对public方法生效。如果你把注标在private或protected方法上,切面不会执行,事务和数据源切换都不会生效。
@Transactional private void doSomething() { // 无效! // ... }解法很简单:把注标在public方法上。如果确实需要内部调用,可以通过AopContext.currentProxy()获取代理对象再调用,或者把方法拆到另一个Bean里。
7.3 从库延迟导致的“数据消失”
这个坑我在开头提过,这里补充一个更隐蔽的场景:分页查询。假设你查订单列表,第一页从主库读(因为刚下了单),第二页从从库读,结果第二页的数据和第一页对不上——因为从库还没同步完。用户翻页时看到重复或缺失的数据,体验很差。
解法是同一个查询链路走同一个数据源。分页查询要么全走主库,要么全走从库,不要混着来。如果第一页需要走主库,那整个分页查询都走主库。
7.4 连接池参数配置不当导致的超时
HikariCP的connectionTimeout默认是30秒,validationTimeout默认是5秒。如果从库网络不稳定,连接获取可能超时。我的建议是把connectionTimeout调小到3-5秒,这样快速失败,避免线程长时间阻塞。同时配置validationTimeout为1-2秒,确保连接有效性检查不会拖慢整体。
spring: datasource: slave: hikari: connection-timeout: 5000 validation-timeout: 2000 keepalive-time: 30000 max-lifetime: 1800000keepalive-time和max-lifetime也很重要——它们确保连接定期被回收和重建,避免使用到已经失效的连接。max-lifetime一般配得比数据库的wait_timeout小一些,比如数据库是30分钟,这里配25分钟。
8. 写在最后:多数据源不是银弹,想清楚再上
多数据源和读写分离能解决一些问题,但也会引入新的复杂度。我在决定是否上多数据源之前,会先问自己几个问题:主库的压力真的到了必须分离的程度吗?很多时候,加个索引、优化几条慢查询,比搞一套读写分离更有效。团队能维护这套东西吗?多数据源意味着更多的配置、更多的监控、更多的故障模式,如果团队没有相应的运维能力,可能会得不偿失。
如果确定要上,那就把本文提到的几个关键点做到位:数据源切面的Order要小于事务切面、ThreadLocal必须清理、写后立即读走主库、上线前从数据库侧验证路由。这几件事做到了,多数据源就能稳稳地跑在生产环境。
最后分享一个我个人的小习惯:在DataSourceContextHolder里加一个peek()方法,返回当前线程的数据源标识但不创建。在关键业务方法的入口和出口打日志,记录数据源的变化。这样排查问题时,一眼就能看出数据源在哪个环节被切换了。这个习惯帮我省了很多排查时间,推荐你也试试。