如何解决@Transactional导致@DS注解切换数据源失效问题
在Java开发中,多商户多租户业务场景常需分库处理。本文将分享使用mybatisplus
和dynamic.datasource
实现多数据源切换时,@Transactional
导致@DS
注解切换数据源失效的问题及解决办法。
一、问题背景
最近在业务开发里,碰到了多商户多租户的业务逻辑,这种情况就需要进行分库操作。项目使用的是mybatisplus
框架,于是选用了同是baomidou
开发的dynamic.datasource
来实现多数据源切换。刚开始使用的时候,程序运行一切正常。可后来发现,在调用com.baomidou.mybatisplus.extension.service.IService.saveBatch
方法时,@DS
注解切换数据源的功能竟然失效了。
二、问题原因分析
深入到saveBatch
方法内部查看,会发现这个方法上加了Transactional
注解。Transactional
主要是用来管理事务的,在事务开启之后,如果再进行数据库的切换,这个切换操作并不会生效。从源代码来看,当线程持有数据库连接时,它会复用当前线程绑定的数据库连接;要是线程没有绑定连接,那就会绑定默认的主库连接。所以最终连接到主库,也就意味着@DS
注解没有起到应有的作用。
三、尝试解决问题的过程
(一)查看Github上的Issues
发现问题后,首先到Github的dynamic-datasource
代码仓库去查看Issues
,想看看有没有其他人也遇到过类似问题。结果发现有大量关于@DS
多数据源切换无效的Issues
。但官方的回应不太给力,要么直接回复说没有复现问题,要么就直接把问题关闭了。不过在众多问题中,还是找到了一条比较有用的信息,就是在调用被Transactional
注解的方法所在的方法或类上添加@DS
注解,试了之后发现确实有效果。但从代码分层结构的角度来看,这样做并不合适。因为Spring
框架的优势就在于清晰的分层结构,控制层负责处理Web相关的事情,Service
层专注于业务逻辑,持久层负责数据库交互。把@DS
注解放在Mapper
层来进行数据库切换才是比较合理的,而不应该为了解决这个问题,就随意把注解加在方法和类上,破坏了原有的分层结构。而且mybatisplus
里有很多添加了Transactional
注解的方法,要是都需要在调用的地方重写并添加@DS
注解,那工作量可太大了,也不合理。
(二)搜索引擎查找解决方案
在Github上没有找到满意的解决方案,就想着去问问“度娘”和“谷歌大神”,毕竟这种问题前辈们大概率早就遇到过,说不定已经有成熟的解决办法了。但中文技术博客的现状不太乐观,很多文章都是抄袭的,也不注明转载来源,导致大量博客内容相似,还存在很多表述不清楚的地方。通过搜索引擎,找到了3种常见的解决方案:
- 在Service类或方法上添加@DS注解:这个方案在前面查看
Issues
的时候就试过,从代码分层的角度考虑,个人不太认可这种做法。 - 在调用带有Transactional注解的方法前切换数据库:这种方案比第一种更灵活一些,因为可以在方法里面根据不同的
Service
来获取需要切换的数据源。但缺点也很明显,侵入性太强,项目里所有使用了mybatisplus
批量方法的Service
都得进行处理,改动量非常大。 - 自己实现TransactionManager:通过自己实现
TransactionManager
,在使用Transactional
时手动指定,以此来替换Spring
默认的DataSourceTransactionManager
。不过这个方案风险太高了,自己实现TransactionManager
需要考虑事务、异步、同步等很多方面的问题,还要保证单元测试尽可能全面,短时间内很难做得比经过多年迭代的框架更好,所以也放弃了这种方案。
(三)使用切面编程解决问题
大家都知道Spring
框架有个很强大的AOP
特性,利用这个特性,能够在不修改原有代码的基础上,对特定的内容进行增强。于是决定使用切面编程来解决这个问题,具体就是拦截mybatisplus
中带有Transactional
注解的方法,然后手动切换数据库。注册切面部分的代码很快就写好了,接下来就是调试数据库切换的功能。
在调试过程中,使用了dynamic.datasource
包里面的DynamicDataSourceContextHolder.push
方法来切换数据库,但是一直没有成功,卡了很长时间。期间还尝试用DynamicRoutingDataSource.setPrimary
方法把需要使用的数据库指定为主库,这样虽然能运行成功,但这种做法风险太大,肯定不能用。
在不断调试的过程中,发现了一个关键信息。在调试时关注chain
变量,会发现里面包含3个拦截器,其中动态数据库切换的拦截器在事务拦截器前面。这就找到了问题的关键,原来是写的切面类在事务之后才执行,所以只要调整切面类的执行优先级就可以了。把Order
注解的优先级提高之后,程序就完美运行了。
下面是最终的切面类代码,要是你也遇到了调用mybatisplus
中批量方法无法切换多数据源的问题,可以直接使用这段代码,它不会对现有代码造成任何侵入和更改。如果只是处理Transactional
和@DS
的冲突,稍微修改一下切面类的作用范围就能解决问题。
// 以下代码用于解决@Transactional导致@DS注解切换数据源失效的问题 // 通过切面编程,在事务开启前切换数据源,保证数据源切换生效 package com.spman.common.aspect; import com.alibaba.fastjson2.JSON; import com.baomidou.dynamic.datasource.annotation.DS; import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import java.lang.reflect.Field; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @Slf4j @Aspect @Order(0) @Component public class MyBatisPlusServiceTransactionalAspect { // 用于存储当前切面主动切换的数据库,在方法执行完成后主动出栈 private static final ThreadLocal<String> DS_KEY = new ThreadLocal<>(); // 定义切点,匹配mybatisplus扩展服务接口的所有方法 @Pointcut("execution(* com.baomidou.mybatisplus.extension.service.IService+.*(..))") public void myBatisPlusMethodPointcut() {} // 定义切点,匹配被@Transactional注解的方法 @Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)") public void transactionalPointcut() {} // 在方法执行前进行处理,主要用于切换数据源 @Before("myBatisPlusMethodPointcut() && transactionalPointcut()") public void beforeHandler(JoinPoint joinPoint) { // 将方法参数转换为JSON字符串,方便记录日志 String argsJson = JSON.toJSONString(joinPoint.getArgs()); // 获取目标对象,即ServiceImpl实例 ServiceImpl<?,?> target = (ServiceImpl<?,?>)joinPoint.getTarget(); // 构建方法名,格式为类名.方法名 String methodName = target.getClass().getTypeName() + "." + joinPoint.getSignature().getName(); // 记录日志,表明拦截到方法开始执行,并打印参数列表 log.info("MyBatisPlusServiceAspect拦截到{}开始执行, 参数列表->{}", methodName, argsJson); // 获取ServiceImpl绑定的Mapper类 Class<? extends BaseMapper<?>> mapperClass = getMapperClass(target); // 获取Mapper类上的@DS注解 DS dsAnnotation = getDSAnnotation(mapperClass); // 如果Mapper类没有绑定@DS注解,记录日志并跳过数据源切换 if (dsAnnotation == null) { log.info("{}未绑定DS注解, 跳过数据源切换", mapperClass.getName()); } else { // 将注解中的数据源名称存入线程变量 DS_KEY.set(dsAnnotation.value()); // 切换数据源 DynamicDataSourceContextHolder.push(dsAnnotation.value()); // 记录日志,表明已切换数据源 log.info("{}已绑定DS注解, 已主动切换数据源为{}", mapperClass.getName(), dsAnnotation.value()); } } // 在方法执行后进行处理,主要用于恢复数据源 @After("myBatisPlusMethodPointcut() && transactionalPointcut()") public void afterHandler(JoinPoint joinPoint) { // 从线程变量中获取之前切换的数据源名称 String dsKey = DS_KEY.get(); // 获取目标对象,即ServiceImpl实例 ServiceImpl<?,?> target = (ServiceImpl<?,?>)joinPoint.getTarget(); // 构建方法名,格式为类名.方法名 String methodName = target.getClass().getTypeName() + "." + joinPoint.getSignature().getName(); // 如果线程变量中有数据源名称,执行数据源变量出栈操作 if (dsKey != null &&!dsKey.isEmpty()) { DynamicDataSourceContextHolder.poll(); log.info("DS_KEY线程变量为{}, 已执行数据源变量出栈操作", dsKey); } else { // 如果线程变量中没有数据源名称,记录日志并跳过出栈操作 log.info("DS_KEY线程变量不存在, 跳过数据源变量出栈操作"); } // 记录日志,表明拦截到方法结束执行 log.info("MyBatisPlusServiceAspect拦截到{}结束执行", methodName); } // 从ServiceImpl中获取service绑定的mapper @SneakyThrows private Class<? extends BaseMapper<?>> getMapperClass(ServiceImpl<?,?> target) { // 获取ServiceImpl父类中的mapperClass字段 Field mapperClassField = target.getClass().getSuperclass().getDeclaredField("mapperClass"); // 设置字段可访问 mapperClassField.setAccessible(true); // 获取字段的值,即Mapper类 return (Class<? extends BaseMapper<?>>) mapperClassField.get(target); } // 根据BaseMapper接口获取标记的DS注解 public static DS getDSAnnotation(Class<? extends BaseMapper<?>> clazz) { // 如果传入的类为空,直接返回null if (clazz == null) return null; // 获取类上的@DS注解 DS target = clazz.getAnnotation(DS.class); // 如果类上没有@DS注解,则从继承的接口上继续查找 if (target == null) { for (Class<?> parentInterface : clazz.getInterfaces()) { target = getDSAnnotation((Class<? extends BaseMapper<?>>) parentInterface); // 如果找到@DS注解,则返回该注解 if (target != null) return target; } } return target; } }
解决技术问题时,需要耐心地深入研究,不能只是为了解决问题而简单应付,只有这样才能真正找到合适的解决方案。