⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 码农参上 「Dr Hydra」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

前言

多租户(Multi-Tenant)是SaaS中的一个重要概念,它是一种软件架构技术,在多个租户的环境下,共享同一套系统实例,并且租户之间的数据具有隔离性,也就是说一个租户不能去访问其他租户的数据。基于不同的隔离级别,通常具有下面三种实现方案:

  1. 每个租户使用独立DataBase,隔离级别高,性能好,但成本大
  2. 租户之间共享DataBase,使用独立的Schema
  3. 租户之间共享Schema,在表上添加租户字段,共享数据程度最高,隔离级别最低。

数据库设计

Mybatis-plus在第3层隔离级别上,提供了基于分页插件的多租户的解决方案,我们对此来进行介绍。在正式开始前,首先做好准备工作创建两张表,在基础字段后都添加租户字段tenant_id:

CREATE TABLE `user` (
`id` bigint(20) NOT NULL,
`name` varchar(20) DEFAULT NULL,
`phone` varchar(11) DEFAULT NULL,
`address` varchar(64) DEFAULT NULL,
`tenant_id` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`)
)
CREATE TABLE `dept` (
`id` bigint(20) NOT NULL,
`dept_name` varchar(64) DEFAULT NULL,
`comment` varchar(128) DEFAULT NULL,
`tenant_id` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`)
)

引入依赖

在项目中导入需要的依赖:

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>3.1</version>
</dependency>

实现

Mybatis-plus 配置类:

@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
public class MybatisPlusConfig {
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();

List<ISqlParser> sqlParserList=new ArrayList<>();
TenantSqlParser tenantSqlParser=new TenantSqlParser();
tenantSqlParser.setTenantHandler(new TenantHandler() {
@Override
public Expression getTenantId(boolean select) {
String tenantId = "3";
return new StringValue(tenantId);
}

@Override
public String getTenantIdColumn() {
return "tenant_id";
}

@Override
public boolean doTableFilter(String tableName) {
return false;
}
});

sqlParserList.add(tenantSqlParser);
paginationInterceptor.setSqlParserList(sqlParserList);
return paginationInterceptor;
}
}

这里主要实现的功能:

  • 创建SQL解析器集合
  • 创建租户SQL解析器
  • 设置租户处理器,具体处理租户逻辑

这里暂时把租户的id固定写成3,来进行测试。测试执行全表语句:

public List<User> getUserList() {
return userMapper.selectList(new LambdaQueryWrapper<User>().isNotNull(User::getId));
}

使用插件解析执行的SQL语句,可以看到自动在查询条件后加上了租户过滤条件:

那么在实际的项目中,怎么将租户信息传给租户处理器呢,根据情况我们可以从缓存或者请求头中获取,以从Request请求头获取为例:

@Override
public Expression getTenantId(boolean select) {
ServletRequestAttributes attributes=(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String tenantId = request.getHeader("tenantId");
return new StringValue(tenantId);
}

前端在发起http请求时,在Header中加入tenantId字段,后端在处理器中获取后,设置为当前这次请求的租户过滤条件。

如果是基于请求头携带租户信息的情况,那么在使用中可能会遇到一个坑,如果当使用多线程的时候,新开启的异步线程并不会自动携带当前线程的Request请求。

@Override
public List<User> getUserListByFuture() {
Callable getUser=()-> userMapper.selectList(new LambdaQueryWrapper<User>().isNotNull(User::getId));
FutureTask<List<User>> future=new FutureTask<>(getUser);
new Thread(future).start();
try {
return future.get();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

执行上面的方法,可以看出是获取不到当前的Request请求的,因此无法获得租户id,会导致后续报错空指针异常:

修改的话也非常简单,开启RequestAttributes的子线程共享,修改上面的代码:

@Override
public List<User> getUserListByFuture() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Callable getUser=()-> {
RequestContextHolder.setRequestAttributes(sra, true);
return userMapper.selectList(new LambdaQueryWrapper<User>().isNotNull(User::getId));
};
FutureTask<List<User>> future=new FutureTask<>(getUser);
new Thread(future).start();
try {
return future.get();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

这样修改后,在异步线程中也能正常的获取租户信息了。

那么,有的小伙伴可能要问了,在业务中并不是所有的查询都需要过滤租户条件啊,针对这种情况,有两种方式来进行处理。

1、如果整张表的所有SQL操作都不需要针对租户进行操作,那么就对表进行过滤,修改doTableFilter方法,添加表的名称:

@Override
public boolean doTableFilter(String tableName) {
List<String> IGNORE_TENANT_TABLES= Arrays.asList("dept");
return IGNORE_TENANT_TABLES.stream().anyMatch(e->e.equalsIgnoreCase(tableName));
}

这样,在dept表的所有查询都不进行过滤:

2、如果有一些特定的SQL语句不想被执行租户过滤,可以通过@SqlParser注解的形式开启,注意注解只能加在Mapper接口的方法上:

@SqlParser(filter = true)
@Select("select * from user where name =#{name}")
User selectUserByName(@Param(value="name") String name);

或在分页拦截器中指定需要过滤的方法:

@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
paginationInterceptor.setSqlParserFilter(metaObject->{
MappedStatement ms = SqlParserHelper.getMappedStatement(metaObject);
// 对应Mapper、dao中的方法
if("com.cn.tenant.dao.UserMapper.selectUserByPhone".equals(ms.getId())){
return true;
}
return false;
});
...
}

上面这两种方式实现的功能相同,但是如果需要过滤的SQL语句很多,那么第二种方式配置起来会比较麻烦,因此建议通过注解的方式进行过滤。

除此之外,还有一个比较容易踩的坑就是在复制Bean时,不要复制租户id字段,否则会导致SQL语句报错:

public void createSnapshot(Long userId){
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getId, userId));
UserSnapshot userSnapshot=new UserSnapshot();
BeanUtil.copyProperties(user,userSnapshot);
userSnapshotMapper.insert(userSnapshot);
}

查看报错可以看出,本身Bean的租户字段不为空的情况下,SQL又自动添加一次租户查询条件,因此导致了报错:

我们可以修改复制Bean语句,手动忽略租户id字段,这里使用的是hutool的BeanUtil工具类,可以添加忽略字段。

BeanUtil.copyProperties(user,userSnapshot,"tenantId");

在忽略了租户id的拷贝后,查询可以正常执行。

最后,再来看一下对联表查询的支持,首先看一下包含子查询的SQL:

@Select("select * from user where id in (select id from user_snapshot)")
List<User> selectSnapshot();

查看执行结果,可以看见,在子查询的内部也自动添加的租户查询条件:

再来看一下使用Join进行联表查询:

@Select("select u.* from user u left join user_snapshot us on u.id=us.id")
List<User> selectSnapshot();

同样,会在左右两张表上都添加租户的过滤条件:

再看一下不使用Join的普通联表查询:

@Select("select u.* from user u ,user_snapshot us,dept d where u.id=us.id and d.id is not null")
List<User> selectSnapshot();

查看执行结果,可以看见在这种情况下,只在FROM关键字后面的第一张表上添加了租户的过滤条件,因此如果使用这种查询方式,需要额外注意,用户需要手动在SQL语句中添加租户过滤。

文章目录
  1. 1. 前言
  2. 2. 数据库设计
  3. 3. 引入依赖
  4. 4. 实现