什么是多租户?
多租户就是指一套软件系统可以为多个客户(租户)提供服务,这些租户之间相互独立,彼此不可见。例如:一个在线商城系统可以为多个店铺提供服务,每个店铺都有自己独立的商品、订单等数据。
多租户的实现方式
通常有以下两种实现方式:
-
数据库级别的隔离
在同一台数据库中,为每个租户创建一个独立的数据库或者独立的表结构,各个租户之间相互隔离,彼此不可见。
-
字段级别的隔离
在同一张表中,通过某个字段(通常是租户ID)对数据进行隔离。在查询数据时,只查询符合租户ID的数据,从而达到隔离的效果。
基于字段隔离的多租户实现方式
本文介绍如何使用Spring Boot集成MySQL和MyBatis-Plus,实现基于字段隔离的多租户功能。
1. 添加依赖
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
2. 数据源配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/multi_tenant?useSSL=false&serverTimezone=UTC&characterEncoding=utf8
username: root
password: root
3. 实体类定义
@Data
public class User {
private Long id;
private String name;
private Integer age;
private Long tenantId; // 租户ID
}
4. Mapper接口
public interface UserMapper extends BaseMapper<User> {
}
5. SQL拦截器
MyBatis-Plus提供了SQL拦截器的功能,可以在执行SQL之前、之后对SQL进行处理。我们可以使用SQL拦截器,在执行查询时动态加入租户ID的条件。
@Component
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
})
public class TenantSqlInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement statement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = statement.getBoundSql(parameter);
String originalSql = boundSql.getSql();
Object parameterObject = boundSql.getParameterObject();
// 获取租户ID
Long tenantId = getTenantIdFromContext();
// 如果是select语句,则加入租户ID的条件
if (StatementType.SELECT.equals(statement.getStatementType())) {
String sql = addTenantCondition(originalSql, tenantId);
BoundSql newBoundSql = new BoundSql(statement.getConfiguration(), sql, boundSql.getParameterMappings(), parameterObject);
MappedStatement newStatement = copyFromMappedStatement(statement, new BoundSqlSqlSource(newBoundSql));
invocation.getArgs()[0] = newStatement;
}
return invocation.proceed();
}
/**
* 从当前线程上下文中获取租户ID
*/
private Long getTenantIdFromContext() {
// 这里使用ThreadLocal来存储租户信息,具体实现可以根据业务场景进行调整
return TenantContext.getTenantId();
}
/**
* 在原始SQL语句的基础上加入租户ID的查询条件
*/
private String addTenantCondition(String originalSql, Long tenantId) {
if (tenantId == null || "".equals(tenantId)) {
return originalSql;
}
StringBuilder sb = new StringBuilder(originalSql);
int index = sb.indexOf("where");
if (index < 0) {
sb.append(" where tenant_id = ").append(tenantId);
} else {
sb.insert(index + 5, " tenant_id = " + tenantId + " and ");
}
return sb.toString();
}
/**
* 复制MappedStatement对象并更新BoundSqlSqlSource
*/
private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
builder.resource(ms.getResource());
builder.fetchSize(ms.getFetchSize());
builder.statementType(ms.getStatementType());
builder.keyGenerator(ms.getKeyGenerator());
builder.timeout(ms.getTimeout());
builder.parameterMap(ms.getParameterMap());
builder.resultMaps(ms.getResultMaps());
builder.cache(ms.getCache());
builder.useCache(ms.isUseCache());
return builder.build();
}
/**
* 绑定SQL语句和参数映射的类,用于生成新的MappedStatement
*/
public static class BoundSqlSqlSource implements SqlSource {
private final BoundSql boundSql;
public BoundSqlSqlSource(BoundSql boundSql) {
this.boundSql = boundSql;
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
return boundSql;
}
}
}
6. 配置SQL拦截器
@Configuration
public class MybatisPlusConfig {
@Autowired
private TenantSqlInterceptor tenantSqlInterceptor;
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
// 添加SQL拦截器
configuration.addInterceptor(tenantSqlInterceptor);
};
}
}
7. 使用ThreadLocal来存储租户ID
为了在整个请求过程中都能够获取到当前的租户ID,我们可以使用ThreadLocal来存储租户信息。
public class TenantContext {
private static final ThreadLocal<Long> tenantIdHolder = new ThreadLocal<>();
public static Long getTenantId() {
return tenantIdHolder.get();
}
public static void setTenantId(Long tenantId) {
tenantIdHolder.set(tenantId);
}
public static void clear() {
tenantIdHolder.remove();
}
}
在每次请求进来时,我们可以从请求头中获取租户ID,并将其存储到ThreadLocal中。
@Component public class TenantHeaderFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantId = httpRequest.getHeader("X-Tenant-Id");
if (tenantId != null) {
try {
TenantContext.setTenantId(Long.valueOf(tenantId));
} catch (NumberFormatException e) {
// 如果租户ID不是数字,则忽略该请求
}
}
chain.doFilter(request, response);
TenantContext.clear();
}
}
总结
本文介绍了如何使用Spring Boot集成MySQL和MyBatis-Plus,实现基于字段隔离的多租户功能。通过SQL拦截器和ThreadLocal的配合使用,我们可以轻松地对数据进行隔离,为多租户系统提供支持。