数据源被写死情况下的Mysql读写分离实现

背景

    项目中由于历史问题,存在很多join的情况,所以存在着大量的慢SQL,对数据库的稳定性产生一定影响,随时可能导致主库性能瓶颈。这个项目也大量的使用了低代码平台,这就导致了,如果要优化SQL的话,很多地方等于推翻重写,这样的话对系统的稳定性和兼容性会带来很大的挑战。

讨论

    跟DBA讨论了一下,然后有一个通用方案,就是做读写分离,将慢SQL中可以走从库的全部路由到从库中去。减少主库压力,提升稳定性。

目标

1.通过简单的方式支持慢SQL走从库。
2.对于项目中的代码通过接口 注解 实现走从库。
3.对于低代码平台通过数据服务name白名单的方式,根据appolo配置实现走从库。
4.写保护:1.对于开事务的方法强制走主库;2.对于有INSET,UPDATE的dao方法强制走主库

方案

    根据网上的介绍,可以使用 AbstractRoutingDataSource,来实现线程级别的读写切换。但是问题来了,这个项目的数据源不是代码或配置中自己定义的,而且低代码平台引入的jar包(二方包)中定义的数据源,并且被写死命名为"defaultDataSource",创建SqlSession的时候也写死根据beanName使用"defaultDataSource"。也就是说我们无法通过代码修改的方式替换数据源。
    既然无法直接通过代码修改,只能上一些黑科技了。因为项目是基于SpringBoot架构的,也是Spring容器单例那一套,所以想着如何把他原本的给替换掉,研究了一下发现,Spring 在 Bean初始化之前是可以对 beanDefinition 信息进行串改的,那么好戏来了,在beanDefinition解析完毕的扩展点,可以做一些事情,将原本的数据源替换成我们想要的AbstractRoutingDataSource数据源。但是要注意 beanDefinitionMap 中的数据不能少,可以替换但是不能光remove。而且也不能多,如果想要新增必须提前留好站位的beanDefinition
    好了,既然方案确定了,开始动手干了。GOGOGO

创建数据源

    这里需要创建 从库数据源 以及 支持路由AbstractRoutingDataSource数据源。(主库不用创建已经有了,但是需要一个占位符)

代码

import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

@Slf4j
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        Integer currentDb = RoutingDataSourceSwitcher.getCurrentDb();
        log.debug("[RoutingSlave] look up currentDb:" + currentDb);
        return currentDb;
    }
}
import com.alibaba.druid.pool.DruidDataSource;
import com.nio.qplant.properties.slave.SlaveDatasource1Properties;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
public class RoutingDataSourceConfig {

    @Autowired
    private SlaveDatasource1Properties slaveDatasource1Properties;

    @Bean("defaultMasterDataSource")
    public DataSource defaultMasterDataSource() throws Exception {
        //这里先不处理,具体会在MyBeanDefinitionRegistryPostProcessor中进行替换
        //主要是占个坑,否则后面无法使用 defaultMasterDataSource
        return null;
    }

    @Bean("defaultSlaveDataSource")
    public DataSource defaultSlaveDataSource() throws Exception {
        DruidDataSource datasource = new DruidDataSource();
        datasource.setUrl(slaveDatasource1Properties.getUrl());
        datasource.setUsername(slaveDatasource1Properties.getUsername());
        datasource.setPassword(slaveDatasource1Properties.getPassword());
        datasource.setDriverClassName(slaveDatasource1Properties.getDriverClassName());

        try {
            if (StringUtils.isNotBlank(slaveDatasource1Properties.getFilters())) {
                datasource.setFilters(slaveDatasource1Properties.getFilters());
            }
        } catch (SQLException e) {
            log.warn("[RoutingDataSourceConfig] setFilters fail", e);
        }

        datasource.setInitialSize(slaveDatasource1Properties.getInitialSize());
        datasource.setMinIdle(slaveDatasource1Properties.getMinIdle());
        datasource.setMaxActive(slaveDatasource1Properties.getMaxActive());
        datasource.setMaxWait((long) slaveDatasource1Properties.getMaxWait());
        datasource.setTimeBetweenEvictionRunsMillis((long) slaveDatasource1Properties.getTimeBetweenEvictionRunsMillis());
        datasource.setMinEvictableIdleTimeMillis((long) slaveDatasource1Properties.getMinEvictableIdleTimeMillis());
        datasource.setValidationQuery(slaveDatasource1Properties.getValidationQuery());
        datasource.setTestWhileIdle(slaveDatasource1Properties.getTestWhileIdle());
        datasource.setTestOnBorrow(slaveDatasource1Properties.getTestOnBorrow());
        datasource.setTestOnReturn(slaveDatasource1Properties.getTestOnReturn());
        if (StringUtils.isNotBlank(slaveDatasource1Properties.getFilters())) {
            datasource.setFilters(slaveDatasource1Properties.getFilters());
        }
        datasource.init();
        return datasource;
    }

    @Bean("targetRoutingDataSource")
    public DataSource targetDs(@Qualifier("defaultMasterDataSource") DataSource masterDs,
                               @Qualifier("defaultSlaveDataSource") DataSource slaveDs) {
        Map<Object, Object> targetDs = new HashMap<>();
        targetDs.put(RoutingDataSourceSwitcher.MASTER, masterDs);
        targetDs.put(RoutingDataSourceSwitcher.SLAVE, slaveDs);

        RoutingDataSource routingDs = new RoutingDataSource();
        //绑定所有的数据源
        routingDs.setTargetDataSources(targetDs);
        //绑定默认数据源
        routingDs.setDefaultTargetDataSource(masterDs);
        return routingDs;
    }

    /**
     * 替换原有的 DataSourceTransactionManagerAutoConfiguration,因为DataSourceTransactionManagerAutoConfiguration会因为多数据源找不到dataSource
     * 只对Master主库开事物
     * @param defaultMasterDataSource
     * @return
     */
    @Bean
    @ConditionalOnMissingBean({PlatformTransactionManager.class})
    public DataSourceTransactionManager transactionManager(@Qualifier("defaultMasterDataSource") DataSource defaultMasterDataSource) {
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(defaultMasterDataSource);
        return transactionManager;
    }


}

替换数据源

    这里是上了黑科技了,在Bean注册之后,初始化之前,进行Bean替换。注意替换后的Bean必须为原Bean的子类,另外Bean的数量不能多也不能少(之前尝试过将 beanDefinitionMap 增加或remove操作,然后各种报错)

代码


import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanInitializationException;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.stereotype.Component;

import javax.sql.DataSource;
import java.lang.reflect.Field;
import java.util.Map;

/**
 * @author ares.wang1
 * 在bean注册完成,初始化之前,进行一些处理,替换掉一些代码
 */
@Component
@Slf4j
public class MyBeanDefinitionRegistryPostProcessor implements BeanDefinitionRegistryPostProcessor {


    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry beanDefinitionRegistry) throws BeansException {

        try {
            Field bdmField = beanDefinitionRegistry.getClass().getDeclaredField("beanDefinitionMap");
            bdmField.setAccessible(true);
            final Map<String, BeanDefinition> beanDefinitionMap = (Map<String, BeanDefinition>) bdmField.get(beanDefinitionRegistry);
            BeanDefinition defaultDataSource = beanDefinitionMap.get("defaultDataSource");    //默认的数据源
            BeanDefinition targetRoutingDataSource = beanDefinitionMap.get("targetRoutingDataSource");  //要替换默认数据源头的路由数据源
            BeanDefinition defaultQueryService = beanDefinitionMap.get("defaultQueryService");  //默认的低代码平台服务
            BeanDefinition defaultQueryServiceNio = beanDefinitionMap.get("defaultQueryServiceNio");   //要替换低代码平台的重写服务

            //替换逻辑:defaultDataSource -> defaultMasterDataSource
            beanDefinitionMap.put("defaultMasterDataSource", defaultDataSource);
            //替换逻辑:targetRoutingDataSource -> defaultDataSource
            beanDefinitionMap.put("defaultDataSource", targetRoutingDataSource);
            //替换逻辑:移除 targetRoutingDataSource 会报错。先不移除了,毕竟只是一个空类,并不会导致创建线程池
            //beanDefinitionMap.remove("targetRoutingDataSource");
            log.info("[MyBeanDefinitionRegistryPostProcessor] replace defaultDataSource to targetRoutingDataSource");

            //替换低代码平台默认服务
            beanDefinitionMap.put("defaultQueryService", defaultQueryServiceNio);
            log.info("[MyBeanDefinitionRegistryPostProcessor] replace defaultQueryService to defaultQueryServiceNio");

        } catch (Throwable e) {
            log.error("[MyBeanDefinitionRegistryPostProcessor] replace bean [defaultQueryService] fail", e);
            throw new BeanInitializationException("postProcessBeanDefinitionRegistry fail", e);
        }

    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory configurableListableBeanFactory) throws BeansException {
        //configurableListableBeanFactory.getBeanNamesForType();
        //DataSource dataSource = configurableListableBeanFactory.getBean(DataSource.class);
        //System.out.println(dataSource);
    }
}

初步改造完毕

    好了,感觉差不多了,信心满满的启动项目,然后傻眼了,各种报错,主要就是 dataSource 注入的时候,因为有多个 dataSource,所以不知道取哪一个就异常终止了。
    网上查了一下,需要把dataSource相关的AutoConfig给排除掉。就是DataSourceTransactionManagerAutoConfiguration,DataSourceAutoConfiguration,JdbcTemplateAutoConfiguration 这三个AutoConfig
如下

@SpringBootApplication(exclude={PageHelperAutoConfiguration.class,
		DataSourceTransactionManagerAutoConfiguration.class, DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class})
@EnableDiscoveryClient
@ImportAutoConfiguration(value={CafApplicationConfiguration.class})
@EnableFeignClients
public class IssueApplication { 

	public static void main(String[] args) {
		System.setProperty("LOG_EXCEPTION_CONVERSION_WORD", "%throwable");
		SpringApplication.run(IssueApplication.class, args);
	}
}

重新启动

    这次没啥问题了,启动成功,后面就是考虑走从库的方案。

从库方式实现

    按照主流模式,基本都是通过注解来实现,所以项目中的代码就通过自定义注解来判断是否走从库。此外低代码平台中的代码,就通过数据服务name 开白名单的方式,指定某个数据服务走从库。

数据源切换

主要是基于 ThreadLocal 做线程隔离,保证数据切换的安全性

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class RoutingDataSourceSwitcher {

    /**
     * 主库,默认
     */
    public static int MASTER = 0;

    /**
     * 从库
     */
    public static int SLAVE = 1;

    /**
     * ThreadLocal 变量,避免并发问题
     * 存储当前DB标识
     */
    private static final ThreadLocal<Integer> DB_TYPE_CONTAINER = new ThreadLocal<>();

    /**
     * ThreadLocal 变量,避免并发问题
     * 存储当前是否强制走主(当开始事物的场景时)
     */
    private static final ThreadLocal<Boolean> FORCE_MASTER = new ThreadLocal<>();

    /**
     * ThreadLocal 变量,避免并发问题
     * 存储当前执行的数据服务CODE
     */
    private static final ThreadLocal<String> CAFE_QUERY_CODE = new ThreadLocal<>();

    private static void switchDb(Integer dbType) {
        DB_TYPE_CONTAINER.set(dbType);
        //log.info("切换数据源:{}", dbType);
    }

    /**
     * 强制走主:
     * 场景:开启事物
     */
    public static void forceMaster() {
        log.debug("[RoutingSlave] forceMaster");
        switchDb(MASTER);
        FORCE_MASTER.set(true);
    }

    /**
     * 标记走主库
     * 场景:写场景时
     */
    public static void useMaster() {
        log.debug("[RoutingSlave] useMaster");
        switchDb(MASTER);
    }

    /**
     * 标记走从库
     * 场景:追加RoutingSlave注解 或 白名单中的低代码平台的数据服务(排除开启事物以及写场景)
     */
    public static void useSlave() {
        log.debug("[RoutingSlave] useSlave");
        switchDb(SLAVE);
    }

    /**
     * 清理库标记
     * 场景:AOP结束的时候
     */
    public static void clear() {
        log.debug("[RoutingSlave] clear");
        DB_TYPE_CONTAINER.set(null);
    }

    public static void clearForce() {
        log.debug("[RoutingSlave] clearForce");
        DB_TYPE_CONTAINER.set(null);
        FORCE_MASTER.set(null);
    }

    /**
     * 获取当前使用的数据库标识
     * 如果是强制走库,则返回主库
     * @return
     */
    public static Integer getCurrentDb() {
        return DB_TYPE_CONTAINER.get();
    }

    /**
     * 是否强制走主库
     * 场景:在开启事物的时候,强制走主
     * 当前库是主库时,并不算强制走主
     * @return
     */
    public static boolean isForceMaster() {
        return FORCE_MASTER.get() != null;
    }

    /**
     * 获取当前执行的框架的数据服务名称
     * @return
     */
    public static String getCurrentCafeQueryCode() {
        return CAFE_QUERY_CODE.get();
    }

    /**
     * 获取当前执行的框架的数据服务名称
     * @return
     */
    public static void setCurrentCafeQueryCode(String name) {
        CAFE_QUERY_CODE.set(name);
    }

    /**
     * 清理当前执行的efast框架的数据服务名称
     */
    public static void clearCurrentCafeQueryCode() {
        CAFE_QUERY_CODE.set(null);
    }
}

定义注解

import java.lang.annotation.*;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RoutingSlave {
}

基于 @RoutingSlave 的AOP

    一开始想使用常规的方式实现AOP拦截,

    @Pointcut("@annotation(com.xxx.xxx.config.slave.RoutingSlave)")
    public void slaveQueryCut() { }

    @Around(value = "slaveQueryCut()")
    public Object doAround(ProceedingJoinPoint jointPoint) throws Throwable {
        try {
            RoutingDataSourceSwitcher.useSlave();
            // 执行目标方法
            return jointPoint.proceed();
        } finally {
            RoutingDataSourceSwitcher.clear();
        }
    }

    但是遇到问题了,@RoutingSlave注解 只有在实现类上使用才有效果,如果定义在接口上则无法进行AOP切面拦截,网上查了一下,也没什么直接的解决办法。没办法了,只能利用 StaticMethodMatcherPointcutAdvisor 重写他的匹配方法matches了。
    进一步考虑,为了保证系统稳定性,需要对系统做一些保护。防止在写操作的时候走到从库。写保护场景主要有两个:1.开始事物的情况下,强制走主库。2.对于INSERT,UPDATE操作强制走主库。

/**
 * 切面类
 * 用于拦截走从库的方法
 */
@Slf4j
@Aspect
@Component
public class RoutingSlaveAspect extends StaticMethodMatcherPointcutAdvisor {

    private static List<String> QUERY_DAO_METHODS = Arrays.asList("select", "selectPage", "selecCount");

    /**
     * 替换低代码平台中的服务名称
     * 格式:,xxx1,xxx2,xxx3,
     */
    private static String REPLACE_QUERY_NAMES;

    @Value("${cafe.replace.query.names:sss}")
    public void setReplaceQueryNames(String param) {
        REPLACE_QUERY_NAMES = param;
    }

    /**
     * 重写匹配方法,解决 注解在接口上 不生效的问题
     *
     * @param method
     * @param aClass
     * @return
     */
    @Override
    public boolean matches(Method method, Class<?> aClass) {
        boolean res = false;    //默认不匹配
        if (AnnotatedElementUtils.hasAnnotation(method, RoutingSlave.class)) { //基于注解进行拦截
            res = true;
        } else if (AnnotatedElementUtils.hasAnnotation(method, Transactional.class)) {  //如果开启事物也要处理
            res = true;
        } else if (AnnotatedElementUtils.hasAnnotation(method.getDeclaringClass(), Transactional.class)) {
            if (!QueryServiceImpl.class.isAssignableFrom(aClass)) { //排除了QueryServiceImpl的子类,不做拦截
                //不是QueryServiceImpl才做拦截,对于 QueryServiceImpl 的事物注解忽略处理(全是查询操作,不知道为啥开个事物,也是醉了)
                res = true;
            }
        } else if (aClass != null && (QueryDao.class.isAssignableFrom(aClass))
                && QUERY_DAO_METHODS.contains(method.getName())) { //拦截低代码平台
            res = true;
        }
        if (res) {
            log.debug("[RoutingSaveAspect] matches " + aClass + "." + method);
        }
        return res;
    }

    /**
     * 重写 Advice
     */
    public RoutingSlaveAspect() {
        this.setAdvice(new RoutingSaveAspectAdvice());
    }

    /**
     * Advice 拦截类
     */
    public static class RoutingSaveAspectAdvice implements MethodInterceptor {

        private static Method CACHED_MAPPER_METHOD = null;

        private static Field CACHED_SQL_COMMAND_FIELD = null;

        @Override
        public Object invoke(MethodInvocation methodInvocation) throws Throwable {
            boolean hasTransactionalAnno = false;   //是否开启事物
            boolean isQueryDao = false;   //是否开启事物
            if (QueryDao.class.isAssignableFrom(methodInvocation.getThis().getClass())) {
                isQueryDao = true;
            }
            try {
                //方法上有注解 @Transactional
                hasTransactionalAnno = AnnotatedElementUtils.hasAnnotation(methodInvocation.getMethod(), Transactional.class);
                //方法所属的类上有注解 @Transactional
                hasTransactionalAnno = hasTransactionalAnno ||
                        AnnotatedElementUtils.hasAnnotation(methodInvocation.getMethod().getDeclaringClass(), Transactional.class);
                if (hasTransactionalAnno) {
                    //如果开了事物,强制走主
                    RoutingDataSourceSwitcher.forceMaster();
                } else if (RoutingDataSourceSwitcher.isForceMaster()) {
                    //如果当前被设置成了强制走主,直接走主库(此情况发生在开启事物的时候,强制走主)
                    RoutingDataSourceSwitcher.useMaster();
                } else if (forceMaster(methodInvocation)) {
                    //如果发现有写操作,强制走主
                    RoutingDataSourceSwitcher.useMaster();
                } else {
                    if (isQueryDao) {   //如果是 querydao,还需要判断一下白名单
                        String code = RoutingDataSourceSwitcher.getCurrentCafeQueryCode();
                        if (REPLACE_QUERY_NAMES.indexOf("," + code + ",") > -1) {
                            log.debug("[RoutingSlaveAspect] " + code + " useSlave");
                            RoutingDataSourceSwitcher.useSlave();
                        }
                    } else {
                        log.debug("[RoutingSlaveAspect] " + methodInvocation.getMethod() + " useSlave");
                        RoutingDataSourceSwitcher.useSlave();
                    }
                }
                // 执行目标方法
                return methodInvocation.proceed();
            } finally {
                if (hasTransactionalAnno) {
                    RoutingDataSourceSwitcher.clearForce();
                } else {
                    RoutingDataSourceSwitcher.clear();
                }
            }
        }

        private boolean forceMaster(MethodInvocation methodInvocation) {
            try {
                //以下判断是不是 dao层接口,不是的画直接返回
                Object thisObject = methodInvocation.getThis();
                if (thisObject == null || !(thisObject instanceof Proxy)) {
                    return false;
                }
                Proxy proxy = (Proxy) thisObject;
                InvocationHandler handler = Proxy.getInvocationHandler(proxy);
                if (handler == null || !(handler instanceof MapperProxy)) {
                    return false;
                }
                MapperProxy mapperProxy = (MapperProxy) handler;
                MapperMethod mapperMethod = (MapperMethod) getMapperMethodInCache().invoke(mapperProxy, methodInvocation.getMethod());
                if (mapperMethod == null) {
                    return false;
                }
                MapperMethod.SqlCommand sqlCommand = (MapperMethod.SqlCommand) getSqlCommandFieldInCache().get(mapperMethod);
                if (sqlCommand == null) {
                    return false;
                }

                //以下判断SQL执行的类型
                SqlCommandType type = sqlCommand.getType();
                if (type == SqlCommandType.INSERT || type == SqlCommandType.UPDATE || type == SqlCommandType.DELETE) {
                    return true;    //只有当写操作时候,强制走主库,这里是为了做保护
                }
                return false;   //默认走从库
            } catch (Throwable e) { //这里比较危险,所以是捕获 Throwable
                log.warn("[RoutingSlaveAspect] forceMaster exception,so force to master", e);
                return true;    //如果发生了异常,强制走主库,保证业务正常
            }

        }

        private Method getMapperMethodInCache() throws NoSuchMethodException {
            if (CACHED_MAPPER_METHOD == null) {
                CACHED_MAPPER_METHOD = MapperProxy.class.getDeclaredMethod("cachedMapperMethod", Method.class);
                CACHED_MAPPER_METHOD.setAccessible(true);
            }
            return CACHED_MAPPER_METHOD;
        }

        private Field getSqlCommandFieldInCache() throws NoSuchFieldException {
            if (CACHED_SQL_COMMAND_FIELD == null) {
                CACHED_SQL_COMMAND_FIELD = MapperMethod.class.getDeclaredField("command");
                CACHED_SQL_COMMAND_FIELD.setAccessible(true);
            }
            return CACHED_SQL_COMMAND_FIELD;
        }

    }

}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值