springboot+mysql+mybatis实现读写分离-基于程序(一招解决)

前言

这篇文章不仅有现成代码,还有解决问题的每个思路,希望给大家更多的扩展,两外,基于shardingsphare和mycat可以查看主页中其他文章

解决思路

网上大多解决的方法是,定义注解、枚举,aop拦截,本地线程ThreadLocal等来达到切换数据源的目的,这个思路是对的,但是有两个缺点:

  • 代码量就太大了;
  • 无法兼容mybatis-plus自带的sql查询方法(因为用的注解来做,plus自带的方法根本不会走你自定义注解)

基于这个思路,我们可以利用项目里已经写好的公用配置类来实现这个功能并且完美兼容mybatis和mybatis-plus。

流程

对于程序解决读写分离的问题,最大的两个问题就是:动态切换数据源、区分读写请求。

1. 区分读写请求

这个没必要自己去定义注解,写aop拦截,基本每个项目都会配置一个mybatis的拦截器,我们可以直接利用这个,在访问数据库之前,区分读写请求,切换数据源;

@Slf4j
@Component
@EnableConfigurationProperties(DynamicDataSourceProperties.class)
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class MybatisInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
        Object parameter = invocation.getArgs()[1];
        if (parameter == null) {
            return invocation.proceed();
        }
        if (SqlCommandType.SELECT == sqlCommandType) {
           //在这里切换读库数据源
        }
        return invocation.proceed();
    }
}

做这一步遇到了查询(用的mybatisplus查询)不走拦截器的问题,网上查了很久没找到方法,于是就查看Interceptor的实现类;
springboot+mysql+mybatis实现读写分离-基于程序(一招解决)_第1张图片
你会发现这里有mybatis-plus官方自定义的拦截器,那就参考它,点进去看看;
springboot+mysql+mybatis实现读写分离-基于程序(一招解决)_第2张图片
重要代码是这一行

 @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})}

网上大多数的查询拦截,对于args的参数都只写了前四个,这样就造成了mybatisplus自带的方法不走拦截器,必须要加上后边的两个参数才行,至少我测试的是这样;
现在我们可以区分出来读写请求了,接下来解决切换数据源的问题。

2. 切换数据源

首先,找到我们配置的数据源,配置文件里数据源的配置。
springboot+mysql+mybatis实现读写分离-基于程序(一招解决)_第3张图片
你也可以自己注入到拦截器里,我这里直接用注入好的,点击slave进去看看;
springboot+mysql+mybatis实现读写分离-基于程序(一招解决)_第4张图片
直接在这里拿,这是mybatis的配置类
在MybatisInterceptor里注入DynamicDataSourceProperties类,然后拿到数据源数据集合,删除master,只存储读库数据源,多从库这里采用随机算法来切换数据源。

@Component
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class MybatisInterceptor implements Interceptor {
 public static ArrayList<String> dbs = new ArrayList<>();

    @Autowired
    private DynamicDataSourceProperties properties;

    private static final Random r = new Random();

    public String getDbName() {
        if (dbs.size() <= 0) {
            Map<String, DataSourceProperty> datasource = properties.getDatasource();
            Set<String> keySet = datasource.keySet();
            keySet.remove("master");
            dbs.addAll(keySet);
        }
        //随机获取源库
        int num = dbs.size();
        //随机获取一个
        int index = r.nextInt(num);
        return dbs.get(index);
    }
  }

接下来,切换数据源,切换数据源我通过mybatis-plus自带的注解@DS一路找到了mybatis切换数据源的工具类:DynamicDataSourceContextHolder,只需要调用它的静态方法push,即可达到切换数据源的目的。

 DynamicDataSourceContextHolder.push(dbName);

这一行代码即可,查看DynamicDataSourceContextHolder源码,会发现它用了本地线程ThreadLocal,所以我们push之后,还需要clear来删除当前线程,否则容易造成OOM,当然我们最好不要在拦截器里调用clear方法来关闭;
原因:避免频繁切换数据源,比如,我们一个请求需要多次查询数据库或者不同的表数据,每次我们对数据库进行操作,都会经过拦截器,每次都要切换从库有点浪费,而且容易出现数据不一致的问题。
因为一个请求可能涉及到读写掺和调用,所以我们需要多几个判断来确保读写数据源的切换正常。

String peek = DynamicDataSourceContextHolder.peek();
boolean empty = StringUtils.isEmpty(peek);
 if (SqlCommandType.SELECT == sqlCommandType) {
     if (empty || "master".equals(peek)) {
         System.out.println("设置数据源=======================");
         String dbName = getDbName();
         DynamicDataSourceContextHolder.push(dbName);
     }
 } else {
     if (!empty && !"master".equals(peek)) {
         System.out.println("设置主数据源=======================");
         DynamicDataSourceContextHolder.push("master");
     }
 }

因为每个从库同步的时间都是不一样的,最好保持一次请求涉及到的所有查询都在同一个从库进行。
所以,我们需要利用aop的@After后置拦截来清除本次线程。

@After("alllog()")
public void doAfter()  {
    boolean empty = StringUtils.isEmpty(DynamicDataSourceContextHolder.peek());
    if (!empty) {
        System.out.println("清空线程=======================");
        DynamicDataSourceContextHolder.clear();
    }
}

下边上完整代码,里边自己用不上的逻辑代码可以自行删除或改造。

完整代码

拦截器 MybatisInterceptor


/**
 * mybatis拦截器,自动注入创建人、创建时间、修改人、修改时间
 * 参考mybatisplus配置 MybatisPlusInterceptor, @Signature注解里的args参数不齐全,会造成拦截失效
 *
 * @Author zhouwenjie
 * @Date 2022-03-19
 */
@Slf4j
@Component
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class MybatisInterceptor implements Interceptor {

    public static ArrayList<String> dbs = new ArrayList<>();

    @Autowired
    private DynamicDataSourceProperties properties;

    private static final Random r = new Random();

    public String getDbName() {
        if (dbs.size() <= 0) {
            Map<String, DataSourceProperty> datasource = properties.getDatasource();
            Set<String> keySet = datasource.keySet();
            keySet.remove("master");
            dbs.addAll(keySet);
        }
        //随机获取源库
        int num = dbs.size();
        //随机获取一个
        int index = r.nextInt(num);
        return dbs.get(index);
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
        Object parameter = invocation.getArgs()[1];
        if (parameter == null) {
            return invocation.proceed();
        }
        String peek = DynamicDataSourceContextHolder.peek();
        boolean empty = StringUtils.isEmpty(peek);
        if (SqlCommandType.SELECT == sqlCommandType) {
            if (empty || "master".equals(peek)) {
                System.out.println("设置数据源=======================");
                String dbName = getDbName();
                DynamicDataSourceContextHolder.push(dbName);
            }
        } else {
        	//如果有多个主库,直接进行写库数据源切换,默认数据源是master
            if (!empty && !"master".equals(peek)) {
                System.out.println("设置主数据源=======================");
                DynamicDataSourceContextHolder.push("master");
            }
        }
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
}

AOP 切面

/**
 * 日志切面类---专门针对控制层,如谁被请求了,花了多少时间,请求发送的参数,返回得值等
 * zwj
 *
 * @author
 */
@Aspect
@Component
public class LogAspect {

    @Pointcut("execution(* com.sfcx.modules.web.controller..*.*(..))")
    public void alllog() {
    }

    @After("alllog()")
    public void doAfter()  {
        boolean empty = StringUtils.isEmpty(DynamicDataSourceContextHolder.peek());
        if (!empty) {
            System.out.println("清空本次请求线程=======================");
            DynamicDataSourceContextHolder.clear();
        }
    }
}

提示:如果你是一主一从,那么请删掉通过DynamicDataSourceProperties获取数据源和随机选择从库的逻辑,直接写数据源的名称即可;

你可能感兴趣的:(#,mysql,mysql,spring,boot)