1. 一次线上接口超时,把我带到了路由源码面前
1.1 故障现场
先说一个我亲历的故障。有一天下午,运维同事突然在群里丢了一张监控截图,某个核心查询接口的P99耗时从平时的80ms直接飙到了1.8秒,而且是一阵一阵的,不是持续高延迟。一开始我以为是慢SQL,拿着日志去数据库管理平台看了半天,慢查询记录里啥都没有,数据库CPU也不高,连接数曲线倒是有点意思——主库连接数在超时时间段里几乎打满,从库却闲得很。
当时就觉得很奇怪:这个接口标注的是走只读数据源,为什么压力全落在主库上?顺着这个疑问往下查,才意识到问题根本不在SQL层面,而在MyBatisPlus多数据源的路由和连接管理机制上。也正是这次排查,让我下定决心把MyBatisPlus多数据源的源码完整过了一遍。
1.2 初步排查:从现象到嫌疑点
我先看了接口代码,标准的业务代码,方法上标注了@DS("slave"),理论上应该走从库。于是我在本地复现,开启数据源切换日志,发现了一个有意思的细节:虽然方法上的@DS生效了,但同一个请求链路里,后面的Mapper调用又切回了主数据源。
这里就牵扯出一个关键认知:MyBatisPlus的动态数据源是基于AOP+ThreadLocal实现的,它只对加了@DS注解的方法做线程上下文切换,一旦方法执行完,上下文数据源key就会出栈恢复。如果方法内部调用了另一个没有@DS注解的服务方法,或者事务边界比数据源切换边界更大,连接就会回头去找主数据源。
这不是源码的bug,而是使用姿势的问题,但如果不读源码,你根本想不到一个简单的顺序调用会引发连接归属的漂移。后面我会把这条链路完整拆开。
1.3 为什么性能优化必须接触源码
很多同学接触MyBatisPlus多数据源,第一反应是看文档配置两个DataSource就完事了,优化也停留在“把连接池调大一点”。但真实场景里,性能和故障的根因往往在框架的底层行为里,比如:
- 数据源key从ThreadLocal的栈里存取,嵌套切换到底怎么恢复;
- 每次
getConnection()和AOP拦截器之间的调用顺序; - 批量操作
saveOrUpdateBatch为什么在多数据源下反而变慢; - 分页插件为什么会偶尔失效,
selectCount为什么查出来是0。
这些光靠配置文档无法解释,必须到源码里找答案。本文我就按这次排障和后续优化的实际路径,带你走一遍MyBatisPlus多数据源的核心源码,并给出可以直接落地的性能优化方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从@DS注解到数据库连接:拆开多数据源的核心路由链
2.1 DynamicRoutingDataSource:路由总开关
要理解MyBatisPlus的多数据源实现,第一个要看的类是com.baomidou.dynamic.datasource.DynamicRoutingDataSource。这个类继承了Spring的AbstractRoutingDataSource,但干的事比父类多得多。
关键方法就两个:determineCurrentLookupKey()和getConnection()。前者决定当前线程应该用哪个数据源key,后者负责真正返回连接。
java复制public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
private Map<String, DataSource> dataSourceMap;
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.peek();
}
@Override
public Connection getConnection() throws SQLException {
String dsKey = determineCurrentLookupKey();
DataSource dataSource = dataSourceMap.get(dsKey);
if (dataSource == null) {
dataSource = dataSourceMap.get(primary);
}
return dataSource.getConnection();
}
}
注意这里有个细节:determineCurrentLookupKey()返回的是DynamicDataSourceContextHolder.peek()取出的当前key。peek()拿到的是栈顶元素,如果栈为空,返回的是null。null去map里查,查不到就会回退到主数据源。这就是很多同学遇到的“明明我配置了从库,结果请求还是打到主库”的源码级原因之一——注解没被解析到,或者线程上下文里根本没有压入数据源key。
2.2 DynamicDataSourceContextHolder:ThreadLocal中的栈
DynamicDataSourceContextHolder是支撑多数据源切换的核心数据结构,它的实现并不复杂,本质就是一个ThreadLocal<Deque<String>>:
java复制public final class DynamicDataSourceContextHolder {
private static final ThreadLocal<Deque<String>> LOOKUP_KEY_HOLDER = new ThreadLocal<>();
public static void push(String ds) {
Deque<String> deque = LOOKUP_KEY_HOLDER.get();
if (deque == null) {
deque = new ArrayDeque<>();
LOOKUP_KEY_HOLDER.set(deque);
}
deque.push(ds);
}
public static String peek() {
Deque<String> deque = LOOKUP_KEY_HOLDER.get();
return deque == null ? null : deque.peek();
}
public static void poll() {
Deque<String> deque = LOOKUP_KEY_HOLDER.get();
if (deque == null) {
return;
}
deque.poll();
if (deque.isEmpty()) {
LOOKUP_KEY_HOLDER.remove();
}
}
}
为什么用Deque(双端队列)而不是一个简单的String变量?因为实际业务里存在数据源嵌套切换的场景:外层方法标注了@DS("slave"),里面又调用了一个标注@DS("master")的方法,执行完后必须恢复到slave,再执行完再恢复到无状态。用栈结构就能实现这种后进先出的恢复逻辑。
这个类本身没什么性能问题,但它是后续性能问题的重要基础:如果开发者在代码里手动调用了push却忘了poll,或者异常路径没走finally块,ThreadLocal里的栈就会残留数据,导致后续同一个线程池里的其他请求错误路由到别的数据源。所以性能优化的第一课,是先保证上下文切换是干净的。
2.3 DynamicDataSourceAnnotationInterceptor:AOP拦截与注解缓存
@DS注解是如何被识别并触发切换的?答案是Spring AOP,核心拦截器叫DynamicDataSourceAnnotationInterceptor,它实现了Spring的MethodInterceptor,对加了@DS的方法进行拦截。
java复制public class DynamicDataSourceAnnotationInterceptor implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
// 优先解析方法上的@DS,其次解析类上的@DS
String dsKey = resolveDataSourceKey(invocation);
if (StringUtils.hasText(dsKey)) {
DynamicDataSourceContextHolder.push(dsKey);
}
try {
return invocation.proceed();
} finally {
DynamicDataSourceContextHolder.poll();
}
}
}
这段代码最值得注意的不是push和poll,而是resolveDataSourceKey()。它内部通过DynamicDataSourceClassResolver来解析注解值,而这个类内部有一个ConcurrentHashMap<String, String>作为缓存,避免同一个方法被反射重复解析@DS注解。
找到这个缓存的时候,我松了一口气——这意味着注解解析本身不会成为性能瓶颈。但在阅读源码前,我一直怀疑每次方法调用都走反射拿注解值会拖慢接口,实际看下来,MyBatisPlus团队早就用缓存把这个开销优化掉了。这给我一个启发:在优化性能之前,先用源码确认瓶颈到底在哪,不要凭感觉改代码。
3. 三个隐蔽的性能黑洞:连接池、批量写入与分页count
3.1 连接池默认参数扛不住多库切换
把路由链摸清之后,我回头看线上故障,最直接的性能黑洞浮出水面:每个数据源独立使用连接池,但连接池配置并没有按库区分,全都走了默认值。
MyBatisPlus动态数据源默认使用的是HikariCP,默认的maximumPoolSize是10,minimumIdle和maximumPoolSize相同。这意味着每个数据源最多只有10个连接。当一个接口链路里频繁切换多个数据源时,如果并发量稍微上来一点,每个数据源的连接池都容易被打满。
连接池打满后会发生什么?HikariCP的getConnection()会进入等待,默认的connectionTimeout是30秒。也就是说,应用层的表现不是报错,而是线程卡住,一直等到30秒超时才抛异常。我们的接口P99飙升到1.8秒,正是大量线程在等待连接,而不是数据库执行变慢了。
这里有一个容易被忽略的计算逻辑:如果你配置了3个数据源,每个连接池10个连接,那么最大连接数是30,但这是每个数据源独立统计的。如果业务并发中有80%的请求走主库,主库的10个连接很快就耗尽,而从库的10个连接可能只用了两三个。优化方向应该是按流量配参数,而不是所有库一视同仁。
3.2 saveOrUpdateBatch的批量提交陷阱
排查完连接池,我又注意到一个高频操作——saveOrUpdateBatch。这个方法是MyBatisPlus提供的批量保存/更新接口,很多同事以为它会在底层拼接成一条大的批量SQL,实际上源码并不是这样做的。
IService.saveBatch的实现在ServiceImpl里,核心逻辑是通过SqlHelper.executeBatch拿到一个批处理模式的SqlSession,然后循环执行单条SQL的insert或update,最后统一commit。
java复制public boolean saveBatch(Collection<T> entityList, int batchSize) {
String sqlStatement = SqlHelper.getSqlStatement(this.entityClass, SqlMethod.INSERT_ONE);
return SqlHelper.executeBatch(this.entityClass, this.log, entityList, batchSize,
(sqlSession, entity) -> sqlSession.insert(sqlStatement, entity));
}
关键在于,这里使用的是MyBatis的批量执行器(ExecutorType.BATCH),它确实能减少一次会话中的SQL网络往返次数。但有两个前提:
第一,数据库JDBC连接串必须开启rewriteBatchedStatements=true,否则MySQL驱动默认不会把多条单条INSERT重写为多值INSERT,批量执行的效果大打折扣。
第二,多数据源切换场景下,批量执行时如果中途切换了数据源,SqlSession对应的连接就会失效,MyBatis会重新获取连接并同步执行余下的SQL,导致前面的批量缓冲被清空,性能反而比单条执行还差。
这个坑非常隐蔽。我是在源码里看到SqlSession和Connection的绑定关系后,才彻底理解了为什么线上某些批量导入接口在切换数据源后直接变慢。
3.3 分页count查询:一次查询变两次
第三个性能黑洞来自分页插件。MyBatisPlus的分页PaginationInnerInterceptor在执行一条分页查询时,会先执行一条SELECT COUNT(*),再执行真正的分页SQL。也就是说,只要业务里用了分页查询,数据库实际承受的查询量就是双份的。
在多数据源场景下,这个count查询的代价会被放大,因为:
- count查询通常不带索引条件下推的优化空间,一旦数据量大,
count(*)可能比分页主查询还慢; - count查询走的是同一个数据源,如果正好赶上主库连接紧张,两条SQL都要排队等连接。
我见过最夸张的一个案例,某个列表页数据量200万行,分页查询本身的SQL通过索引优化后只要100ms,但count(*)因为要全表扫描,耗时800ms,最终接口P99被count拖垮。
4. 可落地的优化实践:从配置到源码的改造
4.1 连接池参数调优
在理解了连接池和多数据源的关系之后,我做的第一个优化就是按流量重新配置每个数据源的HikariCP参数,而不是全部使用默认值。
以主库和从库为例,合理的配置应该是:
yaml复制spring:
datasource:
dynamic:
primary: master
strict: true
datasource:
master:
url: jdbc:mysql://xxx:3306/master_db?rewriteBatchedStatements=true
username: root
password: xxx
hikari:
minimum-idle: 10
maximum-pool-size: 30
connection-timeout: 30000
max-lifetime: 1200000
idle-timeout: 600000
slave:
url: jdbc:mysql://xxx:3306/slave_db?rewriteBatchedStatements=true
username: root
password: xxx
hikari:
minimum-idle: 5
maximum-pool-size: 15
connection-timeout: 30000
max-lifetime: 1200000
idle-timeout: 600000
这里有三个参数值得单独说明:
minimum-idle不要设置得和maximum-pool-size一样大,否则连接池永远保持满负荷状态,空闲连接占据内存;max-lifetime要小于数据库服务器设置的wait_timeout,否则连接在数据库端已经断开,应用不知道,下一次获取就会报通信异常;通常设置20~30分钟比较稳妥;connection-timeout设成30秒是上线前的保底值,线上稳定后可以压到3~5秒,让连接耗尽的问题更早暴露,而不是让线程傻等30秒。
改完配置后,主库连接等待时间从最开始的几百毫秒降到了基本为零,接口P99从1.8秒降回120ms左右。这只是配置调整,一行代码没改。
4.2 批量写入优化方案
连接池解决的是“排队拿连接”的问题,但批量写入的效率还取决于底层JDBC驱动的执行方式。要真正把saveOrUpdateBatch的性能提上来,我建议做三件事:
第一,确保JDBC URL里加了rewriteBatchedStatements=true。这一步是在驱动层面把多条单行INSERT重写成多值INSERT,MySQL驱动才可能在一次网络往返中提交多行数据。
第二,控制批量操作的粒度。MyBatisPlus的saveBatch默认batchSize是1000,但并不是越大越好。批量太大会导致单条SQL过长,数据库解析和网络传输成本上升。实际压测下来,500左右往往比1000更稳。
java复制// 分批执行,避免一次攒太多
List<User> userList = ...;
int batchSize = 500;
for (int i = 0; i < userList.size(); i += batchSize) {
List<User> subList = userList.subList(i, Math.min(i + batchSize, userList.size()));
userService.saveBatch(subList, batchSize);
}
第三,批量操作期间不要切换数据源。如果业务必须在批量写入过程中读另一个数据源的数据,先把要用的数据查出来放到内存里,再进入批量写入阶段。否则一次中途切换,前面缓存的SQL全部失效,性能直接回到解放前。
4.3 减少无谓的数据源切换
源码读到这里,我意识到一个更简单的优化方向:能不切换就不切换。每一个@DS注解都意味着一次线程上下文的压栈弹栈,虽然这个操作本身的性能开销微乎其微,但每次切换对应一次getConnection()调用,而连接获取才是真正的成本所在。
我梳理了项目里的所有@DS使用点,发现了两种典型的无谓切换:
一种是在一个方法内部连续调用多个不同数据源的Mapper,每个Mapper操作之间来回切换。这种场景应该尽量把同数据源的操作聚合在一起,减少切换次数。
另一种是@Transactional和数据源切换的顺序问题。@Transactional一旦开启,事务管理器会提前获取数据库连接并绑定到当前线程,@DS切换的是数据源key,但连接可能已经被事务绑定在原始数据源上了,导致切换失效。处理方案是让事务边界小于数据源切换边界,或者把读操作放在事务外。
4.4 分页count的针对性优化
分页count的问题,我的实践经验是分两步走。
第一步,如果列表数据是已知规模,且不需要精确总数,就直接在Page对象里传入一个估算值,比如:
java复制Page<User> page = new Page<>(current, size, 100000L);
这样可以让分页插件跳过真正的count查询。这个操作的源码逻辑在PaginationInnerInterceptor里,它会判断page.getTotal()是否已经大于0,如果是就跳过count。
第二步,如果确实需要精确count,那就给count查询对应的表建立合适的索引,特别是where条件里的字段和逻辑删除字段都要纳入索引设计。很多count慢的根源不是SQL复杂,而是索引没覆盖到过滤条件。
5. 三类常见故障的源码级根因:分页失效、selectCount为0、saveOrUpdateBatch异常
5.1 分页失效到底怎么回事
分页失效可以说是我被问得最多的问题。源码层面,PaginationInnerInterceptor是通过拦截Executor.query方法实现的,它要求查询方法的参数里必须包含IPage类型的对象。如果你把Page对象放在参数列表的末尾,或者Page没有作为第一个参数传给Mapper,拦截器识别不到分页参数,自然就不做分页处理。
另一个容易被忽略的原因是拦截器顺序。MyBatisPlus的MybatisPlusInterceptor内部维护了一个拦截器列表,如果业务自定义了其他Interceptor,并且顺序排在PaginationInnerInterceptor之前,自定义拦截器可能提前修改了SQL或者提前消费了参数,导致分页逻辑无法正常工作。
多数据源场景下还会遇到一种特殊的分页失效:某个数据源对应的数据库方言和主库不一致(比如一个MySQL、一个PostgreSQL),但分页插件写死了DbType.MYSQL,导致从库分页生成的SQL语法不对,或者是报错,或者是查出来的数据不对。解决方法是按数据源维度配置不同的分页方言,或者确保各数据源数据库类型一致。
5.2 selectCount为0的根因
selectCount查询结果为0,很多人的第一反应是数据不存在,但源码层面往往藏着两个坑。
第一个坑是逻辑删除字段自动拼接。MyBatisPlus默认开启了逻辑删除后,selectCount会自动在where条件中追加deleted=0。如果你数据库里的历史数据是通过物理删除清理的,或者删除标记用的是is_deleted,但字段配置没对上,就会导致查询条件不符合预期,结果恒为0。
第二个坑是数据源路由错位。selectCount执行的SQL可能确实没问题,但当前线程的数据源上下文指向的是一个空的库或者被过滤了数据的库。这种情况在方法链路嵌套过深或者手动修改了ThreadLocal时尤其容易出现。
排查建议很简单:开启MyBatisPlus的SQL日志,看实际执行的SQL语句到底是什么、连的是哪个库。一行日志就能定位绝大多数”查不到数据“的问题。
5.3 saveOrUpdateBatch在多数据源下的异常行为
前面提到过saveOrUpdateBatch使用的是批量执行器。在多数据源下,有两种异常行为很常见。
第一种是连接提前关闭导致的Batch update returned unexpected row count异常。当批处理过程中数据源被切换,原SqlSession对应的连接被返还给连接池,下一次flush时发现连接已经不可用,就会出现这个异常。解决办法就是前面说的——批处理期间绝不切换数据源。
第二种是主键回填失效。saveOrUpdateBatch在批量插入时,MyBatisPlus默认是允许ID回填的,但多数据源切换后,如果连接和事务上下文对不上,自增ID字段可能填充不上。这个问题的根因和连接归属有关,很多情况下需要放弃批量模式,改用逐条插入并手动处理ID。
6. 研究源码后的几点实战心得
说几个我读完源码、做完优化之后的经验沉淀。
第一,MyBatisPlus多数据源的性能瓶颈几乎不在框架本身,而在我们对连接生命周期的理解上。框架的AOP切换逻辑是很轻的,真正拖垮系统的是连接池等待、上下文残留和连接归属漂移。排查任何多数据源性能问题,第一步永远是用日志确认当前线程拿到的连接属于哪个库、连接获取耗时多少。
第二,读源码时优先读核心类的字段和构造方法,而不是一头扎进方法细节。比如看到DynamicRoutingDataSource持有dataSourceMap,你就应该想到它是个路由分发器;看到DynamicDataSourceContextHolder持有ThreadLocal<Deque<String>>,你就应该想到它支持嵌套切换。字段决定了类的职责,职责清晰了,方法细节自然好懂。
第三,优化一定要有量化指标。我这次优化前后的对比数据是:接口P99从1.8秒降到120ms,批量导入1000条数据从6.5秒降到1.2秒,分页列表接口从900ms降到180ms。没有这些数字,配置改动就只是玄学。
第四,很多“框架的锅”其实是使用姿势的锅。saveOrUpdateBatch默认batchSize为1000不代表你就该传1000,分页count该跳过还是该建索引取决于业务是否需要精确总数,@DS加在Service方法上和加在Mapper接口上行为也不一样。花一个下午把源码路径走一遍,比看十篇踩坑文章都管用。
最后再分享一个小经验:如果你准备深入研究某一款框架的源码,建议把源码下载到本地,用IDE的Debug模式跑一个最简单的Demo,然后在determineCurrentLookupKey和invoke方法里打上断点,亲眼看看一次普通查询请求是怎么从Controller层层传递到数据库连接的。这个体感,是读任何文档都替代不了的。
