Mybatis-Plus 多数据源动态切换
多数据源解决方案
目前在SpringBoot
框架基础上多数据源的解决方案大多手动创建多个DataSource
,后续方案有三:
- 继承
org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
,使用AOP
切面注入相应的数据源 ,但是这种做法仅仅适用单Service
方法使用一个数据源可行,如果单Service
方法有多个数据源执行会造成误读。 - 通过
DataSource
配置JdbcTemplate
Bean,直接使用JdbcTemplate
操控数据源。 - 分别通过
DataSource
创建SqlSessionFactory
并扫描相应的Mapper
文件和Mapper
接口。
MybatisPlus
的多数据源解决方案正是AOP
,继承了org.springframework.jdbc.datasource.AbstractDataSource
,有自己对ThreadLocal
的处理。通过注解切换数据源。也就是说,MybatisPlus
只支持在单Service
方法内操作一个数据源,毕竟官网都指明——“强烈建议只注解在service实现上”。
而后,注意看com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder
,也就是MybatisPlus
是如何切换数据源的。
pom引用
<mybatisplus.version>3.5.0</mybatisplus.version>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-core</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-annotation</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba/transmittable-thread-local -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.12.2</version>
</dependency>
数据库配置
spring:
datasource:
dynamic:
primary: generali
strict: false
datasource:
generali:
url: jdbc:mysql://awschina-nx-xman-gis-rds-01.crag5sximeqt.rds.cn-northwest-1.amazonaws.com.cn:3306/leads_lms?allowMultiQueries=true&multiStatementAllow=true&serverTimezone=GMT%2B8&autoReconnect=true&failOverReadOnly=false&useSSL=false&rewriteBatchedStatements=true
username: admin
password: VP4tR5BmBVvaYGyc
gen:
url: jdbc:mysql://awschina-nx-xman-gis-rds-01.crag5sximeqt.rds.cn-northwest-1.amazonaws.com.cn:3306/leads_gen?allowMultiQueries=true&multiStatementAllow=true&serverTimezone=GMT%2B8&autoReconnect=true&failOverReadOnly=false&useSSL=false&rewriteBatchedStatements=true
username: admin
password: VP4tR5BmBVvaYGyc
配置租户与租户id
tenantcode:
ignoreUrl: /common/**,/nano/**,/test/**,/i18n/**,/**/v2/api-docs,/**/login,/**/token,/health
mapping:
generali: 1
gen: 2
代码:
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import java.util.Map;
/**
* @author qhong
* @date 2022/2/16 11:53
**/
@Configuration
@Data
@ConfigurationProperties(prefix = "tenantcode")
public class TenantCodeConfig {
/**
* 不进行身份校验的url配置
*/
private String ignoreUrl;
/**
* 租户code与db中tenantId匹配关系 ,例如 generali:1
*/
private Map<String, Long> mapping;
/**
* 根据tenantCode获取tenantId
* @param tenantCode
* @return
*/
public Long getTenantIdByTenantCode(String tenantCode) {
if (StringUtils.isEmpty(tenantCode)) {
return null;
}
return mapping.get(tenantCode);
}
/**
* 根据tenantId获取tenantCode
* @param tenantId
* @return
*/
public String getTenantCodeByTenantId(Long tenantId) {
if (tenantId == null) {
return null;
}
return mapping.entrySet().stream().filter(x -> x.getValue().equals(tenantId)).map(x -> x.getKey()).findFirst().orElse(null);
}
}
租户code全局获取
import com.alibaba.ttl.TransmittableThreadLocal;
import org.springframework.util.StringUtils;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* @author qhong
* @date 2022/1/26 16:30
**/
public class ThreadContextHolder {
private static final ThreadLocal<Deque<String>> CONTENT_HOLDER = new TransmittableThreadLocal<Deque<String>>() {
@Override
protected Deque<String> initialValue() {
return new ArrayDeque<>();
}
};
private ThreadContextHolder() {
}
/**
* 获得当前线程数据源
* @return 数据源名称
*/
// public static String peek() {
// return CONTENT_HOLDER.get().peek();
// }
/**
* 获得当前线程数据源
*
* @return 数据源名称
*/
public static String element() {
Deque<String> strings = CONTENT_HOLDER.get();
if (strings == null || strings.size() == 0) {
return null;
}
return strings.element();
}
/**
* 设置数据源信息
*/
public static void push(String ds) {
CONTENT_HOLDER.get().push(StringUtils.isEmpty(ds) ? "" : ds);
}
/**
* 清空当前线程数据源
*/
public static void poll() {
Deque<String> deque = CONTENT_HOLDER.get();
deque.poll();
if (deque.isEmpty()) {
CONTENT_HOLDER.remove();
}
}
/**
* 强制清空本地线程
*/
public static void clear() {
CONTENT_HOLDER.remove();
}
}
web全局请求拦截:
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
import com.zhongan.leads.config.AuthManager;
import com.zhongan.leads.config.TenantCodeConfig;
import com.zhongan.leads.dto.UserInfo;
import com.zhongan.leads.utils.ThreadContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.List;
/**
* @author qhong
* @date 2022/2/16 11:38
**/
@Component
@Slf4j
public class TenantInterceptor extends HandlerInterceptorAdapter {
@Autowired
private AuthManager authManager;
@Autowired
private TenantCodeConfig tenantCodeConfig;
private static final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (StringUtils.hasText(tenantCodeConfig.getIgnoreUrl()) && checkIgnoreUrl(tenantCodeConfig.getIgnoreUrl())) {
//不进行身份校验的,直接通过
return true;
}
try {
UserInfo userInfo = authManager.getUserInfo();
String tenantCode = userInfo.getTenantId().toString();
DynamicDataSourceContextHolder.push(tenantCode);
ThreadContextHolder.push(tenantCode);
return true;
} catch (Exception e) {
throw e;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
DynamicDataSourceContextHolder.clear();
ThreadContextHolder.clear();
}
/**
* 校验当前访问的url是否忽略校验的路径
*
* @param url
* @return
*/
private boolean checkIgnoreUrl(String url) {
if (StringUtils.isEmpty(url)) {
return false;
}
String currentUrl = authManager.getRequestURI();
List<String> urlList = Arrays.asList(url.split(","));
for (int i = 0; i < urlList.size(); i++) {
if (antPathMatcher.match(urlList.get(i), currentUrl)) {
return true;
}
}
return false;
}
}
定时任务:
定时任务需要遍历所有的租户数据库
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
import com.zhongan.leads.config.TenantCodeConfig;
import com.zhongan.leads.utils.ThreadContextHolder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @author qhong
* @date 2022/2/17 14:16
* 用于标志多租户定时任务,对各个租户db进行轮询处理,return 为null
**/
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Aspect
@Component
@Slf4j
public class ScheduledMultiTenantAspect {
private final TenantCodeConfig tenantCodeConfig;
@Pointcut("@annotation(org.springframework.scheduling.annotation.Scheduled)")
public void scheduledMultiTenantAspect() {
}
@Around(value = "scheduledMultiTenantAspect()")
public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
int tenantSize = tenantCodeConfig.getMapping().size();
if (tenantSize == 1) {
pjp.proceed();
return null;
}
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(tenantSize);
tenantCodeConfig.getMapping().entrySet().forEach(x -> {
fixedThreadPool.execute(() -> {
ThreadContextHolder.push(x.getKey());
DynamicDataSourceContextHolder.push(x.getKey());
log.info("ScheduledMultiTenantAspect,method:{},tenantcode:{}", method.getName(), x.getKey());
try {
pjp.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
} finally {
ThreadContextHolder.clear();
DynamicDataSourceContextHolder.clear();
}
});
});
fixedThreadPool.shutdown();
while (true) {
if (fixedThreadPool.isTerminated()) {
//log.info("ScheduledMultiTenantAspect,method:{} terminated", method.getName());
break;
}
}
//log.info("ScheduledMultiTenantAspect,method:{} end", method.getName());
return null;
}
}
Async异步任务:
DynamicDataSourceContextHolder
不支持异步,线程池,所以异步任务需要自己处理
public class MultiTenantCodeHelper {
/**
* DynamicDataSourceContextHolder不支持异步,线程池,使用该方法赋值
*/
public static void setDynamicDataSourceByThreadHolderForAsync(){
String tenantCode = ThreadContextHolder.element();
DynamicDataSourceContextHolder.push(tenantCode);
}
}
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
import com.zhongan.leads.utils.MultiTenantCodeHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @author qhong
* @date 2022/2/17 19:18
**/
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Aspect
@Component
@Slf4j
public class AsyncMultiTenantAspect {
@Pointcut("@annotation(org.springframework.scheduling.annotation.Async)")
public void asyncMultiTenantAspect() {
}
@Around(value = "asyncMultiTenantAspect()")
public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
try {
MultiTenantCodeHelper.setDynamicDataSourceByThreadHolderForAsync();
return pjp.proceed();
} catch (Exception e) {
throw e;
} finally {
DynamicDataSourceContextHolder.clear();
}
}
}
Redis缓存:
需要区分各个租户的缓存信息,防止重复
日志:
需要区分各个租户的日志信息,方便后续查看
总结
mybatis-plus
的动态数据库切换,必须在controller
方法之前,因为controller
初始化的时候就设置默认db