使用场景
- 多租户:使用SaaS交付,如果需要给每个租户数据库,就需要在请求访问时根据租户切换数据源
- 分库分表:为了提高性能和扩展性,将数据分散到多个数据库或表中,根据分片规则来选择正确的数据源,实现分库分表
- 读写分离:为了提高数据库的读写性能,可能会采用读写分离的方式,根据读写操作的类型来选择合适的数据源,实现读写分离。
- …
实现原理
1. AbstractRoutingDataSource
AbstractRoutingDataSource
实现了 DataSource
接口,作为一个数据源的封装类,负责路由数据库请求到不同的目标数据源
2. determineTargetDataSource 方法
AbstractRoutingDataSource
类中定义了一个 determineTargetDataSource
方法,会获取当前的目标数据源标识符,进而返回真正的数据源;
值得注意的是:其中 determineCurrentLookupKey
为抽象方法,明显是要让用户自定义实现获取数据源标识的业务逻辑。
3. getConnection 方法
当系统执行数据库操作之前,会先获取数据源链接,即调用 getConnection
方法,该类重写的 getConnection
方法,会获取到真正的目标数据源,进而将数据库操作委托给目标数据源进行处理。
使用
1. 自定义DynamicDataSource:
/**
* (切换数据源必须在调用service之前进行,也就是开启事务之前)
* 动态数据源实现类
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
/**
* 如果不希望数据源在启动配置时就加载好,可以定制这个方法,从任何你希望的地方读取并返回数据源
* 比如从数据库、文件、外部接口等读取数据源信息,并最终返回一个DataSource实现类对象即可
*/
@Override
protected DataSource determineTargetDataSource() {
return super.determineTargetDataSource();
}
/**
* 如果希望所有数据源在启动配置时就加载好,这里通过设置数据源Key值来切换数据,定制这个方法
*/
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceKey();
}
/**
* 设置默认数据源
* @param defaultDataSource
*/
public void setDefaultDataSource(Object defaultDataSource) {
super.setDefaultTargetDataSource(defaultDataSource);
}
/**
* 设置数据源
* @param dataSources
*/
public void setDataSources(Map<Object, Object> dataSources) {
super.setTargetDataSources(dataSources);
// 将数据源的 key 放到数据源上下文的 key 集合中,用于切换时判断数据源是否有效
DynamicDataSourceContextHolder.addDataSourceKeys(dataSources.keySet());
}
}
2. DynamicDataSourceContextHolder
为了线程安全,我们要把lookupKey放入ThreadLocal里面,因此我们写了一个DynamicDataSourceContextHolder来切换数据源,就是改变当前线程保存的lookupKey,上面DynamicDataSource.determineCurrentLookupKey从当前线程取出即可,代码如下:
/**
* (切换数据源必须在调用service之前进行,也就是开启事务之前)
* 动态数据源上下文
*/
public class DynamicDataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>() {
/**
* 将 master 数据源的 key作为默认数据源的 key
*/
@Override
protected String initialValue() {
return "master";
}
};
/**
* 数据源的 key集合,用于切换时判断数据源是否存在
*/
public static List<Object> dataSourceKeys = new ArrayList<>();
/**
* 切换数据源
* @param key
*/
public static void setDataSourceKey(String key) {
contextHolder.set(key);
}
/**
* 获取数据源
* @return
*/
public static String getDataSourceKey() {
return contextHolder.get();
}
/**
* 重置数据源
*/
public static void clearDataSourceKey() {
contextHolder.remove();
}
/**
* 判断是否包含数据源
* @param key 数据源key
* @return
*/
public static boolean containDataSourceKey(String key) {
return dataSourceKeys.contains(key);
}
/**
* 添加数据源keys
* @param keys
* @return
*/
public static boolean addDataSourceKeys(Collection<? extends Object> keys) {
return dataSourceKeys.addAll(keys);
}
}
3. 配置动态数据源生效、默认主数据源
@EnableTransactionManagement
@Configuration
@MapperScan({"com.sino.teamwork.base.dao","com.sino.teamwork.*.*.mapper"})
public class MybatisPlusConfig {
@Bean("master")
@Primary
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource master() {
return DataSourceBuilder.create().build();
}
@Bean("dynamicDataSource")
public DataSource dynamicDataSource() {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", master());
// 将 master 数据源作为默认指定的数据源
dynamicDataSource.setDefaultDataSource(master());
// 将 master 和 slave 数据源作为指定的数据源
dynamicDataSource.setDataSources(dataSourceMap);
return dynamicDataSource;
}
@Bean
public MybatisSqlSessionFactoryBean sqlSessionFactoryBean() throws Exception {
MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
/**
* 重点,使分页插件生效
*/
Interceptor[] plugins = new Interceptor[1];
plugins[0] = paginationInterceptor();
sessionFactory.setPlugins(plugins);
//配置数据源,此处配置为关键配置,如果没有将 dynamicDataSource作为数据源则不能实现切换
sessionFactory.setDataSource(dynamicDataSource());
sessionFactory.setTypeAliasesPackage("com.sino.teamwork.*.*.entity,com.sino.teamwork.base.model"); // 扫描Model
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
sessionFactory.setMapperLocations(resolver.getResources("classpath*:mapper/*/*Mapper.xml")); // 扫描映射文件
return sessionFactory;
}
@Bean
public PlatformTransactionManager transactionManager() {
// 配置事务管理, 使用事务时在方法头部添加@Transactional注解即可
return new DataSourceTransactionManager(dynamicDataSource());
}
/**
* 加载分页插件
* @return
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
List<ISqlParser> sqlParserList = new ArrayList<>();
// 攻击 SQL 阻断解析器、加入解析链
sqlParserList.add(new BlockAttackSqlParser());
paginationInterceptor.setSqlParserList(sqlParserList);
return paginationInterceptor;
}
可以看到有如下配置:
- 配置了主数据源叫master,主数据源放在spring配置文件里
- 配置动态数据源,并将主数据源加入动态数据源中,设为默认数据源
- 配置sqlSessionfactoryBean,并将动态数据源注入,sessionFactory.setDataSource(dynamicDataSource());
- 配置事务管理器,并将动态数据源注入new DataSourceTransactionManager(dynamicDataSource());
- 注意事项:
- 此处还有一点容易出错,就是分页问题,因为之前按spring默认配置,是不用在此配置数据源跟sqlSessionFactoryBean,配置了分页插件后,spring默认给你注入到了sqlSessionFactoryBean,但是此处因我们自己配置了sqlSessionFactoryBean,所以要自己手动注入,不然分页会无效,如下
/**
* 重点,使分页插件生效
*/
Interceptor[] plugins = new Interceptor[1];
plugins[0] = paginationInterceptor();
sessionFactory.setPlugins(plugins);
就是去掉springboot默认自动配置数据源
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class})
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
@EnableScheduling
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class})
public class TeamworkApplication {
public static void main(String[] args) {
SpringApplication.run(TeamworkApplication.class, args);
}
}
4. 初始化加载租户的数据源
我们写一个类来初始化加载所有租户的数据源,代码也很简单,就是查询主数据源的数据库,查出所有租户的数据源信息,添加到动态数据源中(此处也可以加上把动态数据源交托spring管理)
@Slf4j
@Configuration
public class DynamicDataSourceInit {
@Autowired
private ITenantInfoService tenantInfoService;
@PostConstruct
public void InitDataSource() {
log.info("=====初始化动态数据源=====");
DynamicDataSource dynamicDataSource = (DynamicDataSource)ApplicationContextProvider.getBean("dynamicDataSource");
HikariDataSource master = (HikariDataSource)ApplicationContextProvider.getBean("master");
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", master);
List<TenantInfo> tenantList = tenantInfoService.InitTenantInfo();
for (TenantInfo tenantInfo : tenantList) {
log.info(tenantInfo.toString());
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(tenantInfo.getDatasourceDriver());
dataSource.setJdbcUrl(tenantInfo.getDatasourceUrl());
dataSource.setUsername(tenantInfo.getDatasourceUsername());
dataSource.setPassword(tenantInfo.getDatasourcePassword());
dataSource.setDataSourceProperties(master.getDataSourceProperties());
dataSourceMap.put(tenantInfo.getTenantId(), dataSource);
}
//设置数据源
dynamicDataSource.setDataSources(dataSourceMap);
/**
* 必须执行此操作,才会重新初始化AbstractRoutingDataSource 中的 resolvedDataSources,也只有这样,动态切换才会起效
*/
dynamicDataSource.afterPropertiesSet();
}
}
5. DynamicDataSourceAspect
我们可以使用面向切面编程,自动切换数据源,我是在用户登录时,将用户的租户信息放入session,租户的ID就对应数据源的lookupKey
@Slf4j
@Aspect
@Component
@Order(1) // 请注意:这里order一定要小于tx:annotation-driven的order,即先执行DynamicDataSourceAspectAdvice切面,再执行事务切面,才能获取到最终的数据源
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class DynamicDataSourceAspect {
@Around("execution(* com.sino.teamwork.core.*.controller.*.*(..)) "
+ "or execution(* com.sino.teamwork.base.action.*.*(..))")
public Object doAround(ProceedingJoinPoint jp) throws Throwable {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpSession session= sra.getRequest().getSession(true);
String tenantId = (String)session.getAttribute("tenantId");
log.info("当前租户Id:{}", tenantId);
DynamicDataSourceContextHolder.setDataSourceKey(tenantId);
Object result = jp.proceed();
DynamicDataSourceContextHolder.clearDataSourceKey();
return result;
}
}
总结
实现动态切换的主要过程:
- 继承AbstractRoutingDataSource类,作为动态数据源
- 使用这个动态数据源创建MybatisSqlSessionFactoryBean
- 创建AOP截取请求的租户Id进行切换
- 这样创建出来的SqlSession就为根据当前用户创建的数据源