一、前言
Saas租户隔离常用的有两种方法:
- 表字段增加tenant,查询时通过where语句进行隔离
- 直接数据库级别进行隔离
第一种方案有很多缺点:sql语句复杂,无法进行定制化开发,针对某个租户进行数据迁移很麻烦;
而第二个方案唯一的缺点就是每个租户需要构建一个数据库,成本可能较高。
本文介绍了一种基于springboot的数据库隔离saas方案,代码简单逻辑清晰,可供大家参考。
二、代码
1. 需要一个ThreadLocal存储tenant id
注意这里要用InheritableThreadLocal,该类可以在子线程中继承属性,否则针对@Async等异步操作会出现找不到tenant
@UtilityClass
public class TenantHolder {
/**
* 这里要用InheritableThreadLocal
* 在异步场景中例如 @Async @EventListener等新建的线程中使用
*/
private static final InheritableThreadLocal<String> TENANT = new InheritableThreadLocal<>();
public static void setTenant(String tenant) {
TENANT.set(tenant);
}
public static void remove() {
TENANT.remove();
}
public static String getTenant() {
return TENANT.get();
}
}
2.在过滤器中写入tenant id到ThreadLocal
@WebFilter(urlPatterns = {"/*"}, filterName = "TenantFilter")
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantId = httpRequest.getHeader("tenant_id");
TenantHolder.setTenant(tenantId);
try {
chain.doFilter(request, response);
} finally {
TenantHolder.remove();
}
}
}
3. 需要有一个动态数据源:
这里是核心,获取connection的时候,动态获取datasource,datasource如果不存在则从数据库中查询出租户对应的数据库信息从而实例化一个新的放入缓存中。
@EqualsAndHashCode(callSuper = true)
@Data
@Slf4j
public class DynamicDataSource extends AbstractDataSource {
private final Map<String, DataSource> dataSourceMap = new HashMap<>();
private final DataSource defaultDataSource;
private final JdbcTemplate jdbcTemplate;
public DynamicDataSource(DataSource defaultDataSource) {
this.defaultDataSource = defaultDataSource;
this.jdbcTemplate = new JdbcTemplate(defaultDataSource);
}
@Override
public Connection getConnection() throws SQLException {
return getDataSource().getConnection();
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return getDataSource().getConnection(username, password);
}
/**
* 根据租户id获取数据源
* @return 数据源
*/
private DataSource getDataSource() {
String tenantId = TenantHolder.getTenant();
if (tenantId == null) {
return defaultDataSource;
}
//从缓存获取
DataSource dataSource = dataSourceMap.get(tenantId);
if (dataSource != null) {
return dataSource;
}
//如果不存在,则根据租户信息实例化一个DataSource
synchronized (dataSourceMap) {
dataSource = createDataSource(tenantId);
dataSourceMap.put(tenantId, dataSource);
return dataSource;
}
}
/**
* 根据租户创建一个data source
* @param tenantId 租户id
* @return datasource
*/
private DataSource createDataSource(String tenantId) {
TenantDataSource config = getDataSourceConfig(tenantId);
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(config.getUrl());
dataSource.setUsername(config.getUsername());
dataSource.setPassword(config.getPassword());
dataSource.setDriverClassName(config.getDriver());
dataSource.setMaximumPoolSize(20);
dataSource.setMinimumIdle(5);
dataSource.setMaxLifetime(300000);
return dataSource;
}
/**
* 查询数据库,获取租户的数据源配置
* 注意,不能通过dao层查询,会导致循环依赖
*
* @param tenantId 租户id
* @return 数据源配置
*/
private TenantDataSource getDataSourceConfig(String tenantId) {
String sql = "select * from tb_tenant_data_source where tenant_id = ?";
BeanPropertyRowMapper<TenantDataSource> mapper = new BeanPropertyRowMapper<>(TenantDataSource.class);
List<TenantDataSource> list = jdbcTemplate.query(sql, mapper, tenantId);
Assert.notEmpty(list, "cannot find data source config for tenant: " + tenantId);
return list.get(0);
}
}
4.对于线程池注意事项
线程池中的线程默认不会清理ThreadLocal,因此需要我们自定义一个线程池,并设置TaskDecorator进行手动清理ThreadLocal
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 设置核心线程数
executor.setCorePoolSize(10);
// 设置最大线程数
executor.setMaxPoolSize(40);
// 设置队列容量
executor.setQueueCapacity(40);
// 设置线程活跃时间(秒)
executor.setKeepAliveSeconds(60);
// 设置默认线程名称
executor.setThreadNamePrefix("async-");
// 设置拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
//设置任务装饰器,用于清理thread local
executor.setTaskDecorator(ThreadLocalCleanTask::new);
return executor;
}
/**
* 清理ThreadLocal
* 如果有其他的ThreadLocal都放到finally中
*/
@Data
public static class ThreadLocalCleanTask implements Runnable {
private final Runnable runnable;
@Override
public void run() {
try {
runnable.run();
} finally {
TenantHolder.remove();
}
}
}
}
5.针对Feign或者rest调用注意事项
如果微服务之间有rest调用,要记得在header中设置tenant id,可以通过feign拦截器或者rest拦截器实现
三、其他注意事项
MQ和redis、es等是否需要租户隔离根据业务逻辑来自行判断;
定时任务尤其值得注意:因为定时任务执行时没有tenantId,此时可以通过AOP对定时任务方法进行处理,将原本一个方法改为遍历租户循环执行,伪代码如下:
List<String> list = getTenantList();
for (tenant : list) {
TenantHolder.set(tenant)
executeTask();
}