背景
项目中由于历史问题,存在很多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;
}
}
}