如何解决@Transactional导致@DS注解切换数据源失效问题

后端 潘老师 2个月前 (03-02) 107 ℃ (0) 扫码查看

在Java开发中,多商户多租户业务场景常需分库处理。本文将分享使用mybatisplusdynamic.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;
    }
}

解决技术问题时,需要耐心地深入研究,不能只是为了解决问题而简单应付,只有这样才能真正找到合适的解决方案。


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/back/15230.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】