通常情况下,多租户有三种形式:
1、分区(Partitioned)数据:不同租户的数据都在一张表里,通过一个值(tenantId)来区分不同的租户。
![](https://s8.51cto.com/oss/202210/13/53b548885e531ee81867741a9a9894cbb85bd1.webp)
2、分结构(Schema):不同的租户数据放置在相同数据库实例的不同结构(Schema)中。
![](https://s7.51cto.com/oss/202210/13/61b25547990316c9434681a020390860d525de.webp)
3、分数据库(Database):不同租户的数据放置在不同的数据中。
![](https://s5.51cto.com/oss/202210/13/b780a9213bc8cfd87d4375f6dc6b03274720f9.webp)
在Spring Boot中,多租户的能力是由Hibernate提供的,我们在本文中结合Spring Data JPA一起对三种多租户的模式进行演示。本文不需要你具备任何Spring Data JPA和Hibernate基础,也可以通过本文领略下Spring Data JPA。
多租户最最简单也是最常用的多租户方式是“分区数据“”,而这个功能是Hibernate 6.0才具有的功能,而Spring Boot 2.x只支持Hibernate 5.x,所以使用“分区数据”的方式进行多租户需要采用Spring Boot 3.x。幸运的是Spring Boot 3.0将于今年11月份发布,到时候你就可以在生产环境使用本功能了。
“分结构”和“分数据库”的实现方式Spring Boot 2.x是支持的,本文为了演示简单以及远期的前瞻性,将全部以Spring Boot 3.0来实现。
![](https://s2.51cto.com/oss/202210/13/c9f8068839541af9230323b2c232faf5ea11ae.png)
首先,让我们从最简单的例子开始。
一、“分区数据”多租户
新建演示项目:spring-boot-multitenant-partition,依赖为Spring Web、Spring Data JPA、Lombok,Spring Boot版本注意选择3.x,Spring Boot 3.x的最小支持JDK版本为17。
![](https://s9.51cto.com/oss/202210/13/d57094b72612ff8e3678472e69a07b5c8ea695.png)
@Entity //1
@Data
public class Person {
@Id //2
@GeneratedValue(strategy= GenerationType.IDENTITY) //3
private Long id;
@TenantId //4
private String tenantId;
private String name;
private Integer age;
}
1、通过@Entity注解定义一个实体,对应数据库一张表;
2、通过@Id注解表名该属性对应数据库的主键;
3、通过@GeneratedValue(strategy= GenerationType.IDENTITY)配置使用MySQL的主键自增;
4、使用@TenantId注解的属性tenantId作为分区数据多租户的的区分标识。
import org.springframework.data.jpa.repository.JpaRepository;
public interface PersonRepository extends JpaRepository<Person,Long> {
List<Person> findByName(String name);
}
这个是Spring Data JPA神奇的地方,通过一个定义一个接口PersonRepository继承框架提供的JpaRepository接口,框架将会给我们自动代理一个实现类,这个实现类除了基本的增删查改以外,还会通过方法名自动推算查询语句。如findByName相当于select * from person where name = ?
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
@Component
public class WiselyTenantIdResolver implements CurrentTenantIdentifierResolver, //1
HibernatePropertiesCustomizer { //2
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); //1.1
public void setCurrentTenant(String currentTenant) { //1.2
CURRENT_TENANT.set(currentTenant);
}
@Override
public String resolveCurrentTenantIdentifier() { //1
return Optional.ofNullable(CURRENT_TENANT.get()).orElse("unknown");
}
@Override
public boolean validateExistingCurrentSessions() {
return false;
}
@Override
public void customize(Map<String, Object> hibernateProperties) { //2
hibernateProperties.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this);
}
}
1.通过实现CurrentTenantIdentifierResolver接口来获取确定TenantId的来源。
1. 1使用线程本地变量CURRENT_TENANT来存储当前的TenantId;
1.2.通过setCurrentTenant方法接受外部设置当前访问者的TenantId,并存储在线程本地变量CURRENT_TENANT中;
1.3.通过重写接口的resolveCurrentTenantIdentifier方法,获得当前的TenantId;
2.通过重写HibernatePropertiesCustomizer接口的customize方法,可以将当前类注册到Hibernate的配置。
@Component
public class TenantIdInterceptor implements HandlerInterceptor {
private final WiselyTenantIdResolver tenantIdResolver;
public TenantIdInterceptor(WiselyTenantIdResolver tenantIdResolver) {
this.tenantIdResolver = tenantIdResolver;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
tenantIdResolver.setCurrentTenant(request.getHeader("x-tenant-id"));
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}
通过定义一个Spring MVC的拦截器,在每个request请求的头部设置key:x-tenant-id(如:companya),在拦截器中获取到TenantId后设置WiselyTenantIdResolver的TenantId,即当前的TenantId。
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TenantIdInterceptor tenantIdInterceptor;
public WebConfig(TenantIdInterceptor tenantIdInterceptor) {
this.tenantIdInterceptor = tenantIdInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantIdInterceptor);
WebMvcConfigurer.super.addInterceptors(registry);
}
}
@RestController
@RequestMapping("/people")
public class PersonController {
private final PersonRepository personRepository;
public PersonController(PersonRepository personRepository) {
this.personRepository = personRepository;
}
@PostMapping
public Person save(@RequestBody PersonDto personDto){ //1
return personRepository.save(personDto.createPerson());
}
@GetMapping
private List<Person> all(){ //2
return personRepository.findAll();
}
}
1.通过设置在头信息中设置不同的TenantId,数据库中的tenant_id字段将自动存储头中的租户;
2.通过设置在头信息中设置不同的TenantId,只能查询到该租户下的数据。
import lombok.Value;
@Value
public class PersonDto {
private String name;
private Integer age;
public Person createPerson(){
Person person = new Person();
person.setName(this.name);
person.setAge(this.age);
return person;
}
}
spring.datasource.url: jdbc:mysql://localhost:3306/partitioned #1
spring.datasource.username: root
spring.datasource.password: example
spring.datasource.driver-class-name: com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto: update # 2
server.port: 80
1.在数据库中创建一个schema叫partitioned;
2.设置为update属性,hibernate会自动因实体类的变化自动创建和更新数据库的表;
通过postman构造四条数据,分别为:
租户:companya,通过x-tenant-id头来设置:
{"name":"wang","age":22}
{"name":"li","age":23}
租户:companya,通过x-tenant-id头来设置:
{"name":"peng","age":24}
{"name":"zhang","age":25}
在postman的样子是这样的:
![](https://s7.51cto.com/oss/202210/13/d893f98330c1e982c2b3311036f4d6fbf717af.png)
![](https://s9.51cto.com/oss/202210/13/73c19cb28d94df63298686f6244d0eff264c49.png)
我们一次对上述四条数据进行请求,查看数据库:
![](https://s9.51cto.com/oss/202210/13/21c983a27f60e85f9bf9144403db6afd9ab4be.png)
我们看见数据都添加了正确的tenant_id。
查询租户companya的数据:
![](https://s9.51cto.com/oss/202210/13/35ef1da63d2ddb0d5e4128711b9d3bb4204605.png)
查询租户companyb的数据:
![](https://s6.51cto.com/oss/202210/13/52b296996b4a34dcc075352619dfb9e2b64673.png)
二、“分结构”多租户
第二个例子,我们在一个数据库中分别建立2个schema,分别是companya,用来放置租户companya的数据;companyb用来放置租户companyb的数据。再建一个schema:public作为默认的连接的schema。
public class Person {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;
private String name;
private Integer age;
}
无须标识租户的tenantId字段,因为租户已经通过schema来隔离开了。
@Component
public class WiselyTenantIdResolver implements CurrentTenantIdentifierResolver, HibernatePropertiesCustomizer {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public void setCurrentTenant(String currentTenant) {
CURRENT_TENANT.set(currentTenant);
}
@Override
public String resolveCurrentTenantIdentifier() {
return Optional.ofNullable(CURRENT_TENANT.get()).orElse("public");
}
@Override
public boolean validateExistingCurrentSessions() {
return false;
}
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this);
}
}
这里和上例没有什么区别,只是上例设置如果没有获取到TenantId,则将tenant_id字段设置为unknown,本例是将数据库的schema连到public。
- 通过获得tenantId,连接到对应的schema
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@Component
public class WiselyMultiTenantConnectionProvider implements MultiTenantConnectionProvider, HibernatePropertiesCustomizer {
private final DataSource dataSource;
public WiselyMultiTenantConnectionProvider(DataSource dataSource){
this.dataSource = dataSource;
}
@Override
public Connection getAnyConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
final Connection connection = getAnyConnection();
connection.createStatement().execute(String.format("use %s;", tenantIdentifier));
return connection;
}
@Override
public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
connection.createStatement().execute("use public;");
connection.close();
}
//...省略一些非关键方法
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
}
}
因为我们是连接单个数据库的不同schema,所以我们只需要在系统中配置一个dataSource,通过这个dataSource我们获得数据库的连接。通过重写MultiTenantConnectionProvider接口的Connection getConnection(String tenantIdentifier)方法,我们根据在上面的WiselyTenantIdResolver获得的tenantId,即当前方法的tenantIdentifier参数来切换dataSource连接到不同的schema.
spring.datasource.url: jdbc:mysql://127.0.0.1:3306/public
spring.datasource.username: root
spring.datasource.password: example
spring.datasource.driver-class-name: com.mysql.cj.jdbc.Driver
spring.jpa.show-sql: true
server.port: 80
其余代码和上例保持一致,省略
测试数据和postman和上例保持一致,查看数据库中的数据:
租户:companya
![](https://s2.51cto.com/oss/202210/13/d13b69c4711e2315637552f55ff0c6cfbb632a.png)
租户:companyb
![](https://s3.51cto.com/oss/202210/13/51bac71422523d8f76d213898076322b4c1bd1.png)
查询租户:companya
![](https://s8.51cto.com/oss/202210/13/c9317b887cd69d0cdb256133d646f31720fbc8.png)
查询租户:companyb
![](https://s4.51cto.com/oss/202210/13/a4c8b1a00b21a571eff865ea84624aed069fd0.png)
三、“分数据库”多租户
第三个例子是不同租户的数据分别在不同的数据库里,为了演示方便,本例还是用2个schema来模拟两个数据库,区别是相同数据库时我们使用一个dataSource来切换不同的schema;而本例中会有多个dataSource,使用tenantId来切换到不同的dataSource。
spring-jdbc包为我们提供一个类叫做AbstractRoutingDataSource,它可以设置多个数据源,并通过一个key来切换这个数据源,很显然,这个key是我们的tenantId。
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
@Component
public class WiselyTenantRoutingDatasource extends AbstractRoutingDataSource {
private final WiselyTenantIdResolver wiselyTenantIdResolver; //1
public WiselyTenantRoutingDatasource(WiselyTenantIdResolver wiselyTenantIdResolver) {
this.wiselyTenantIdResolver = wiselyTenantIdResolver;
setDefaultTargetDataSource(createDatabase("jdbc:mysql://127.0.0.1:3306/public", "root", "example")); //2
HashMap<Object, Object> targetDataSources = new HashMap<>(); //3
targetDataSources.put("companya",createDatabase("jdbc:mysql://127.0.0.1:3306/companya", "root", "example")); //4
targetDataSources.put("companyb",createDatabase("jdbc:mysql://127.0.0.1:3306/companyb", "root", "example"));
setTargetDataSources(targetDataSources); //5
}
@Override
protected String determineCurrentLookupKey() { //6
return wiselyTenantIdResolver.resolveCurrentTenantIdentifier();
}
private DataSource createDatabase(String databaseUrl, String username, String password) {
DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();
dataSourceBuilder.driverClassName("com.mysql.cj.jdbc.Driver");
dataSourceBuilder.url(databaseUrl);
dataSourceBuilder.username(username);
dataSourceBuilder.password(password);
return dataSourceBuilder.build();
}
}
1、注入WiselyTenantIdResolver的bean获得当前的tenantId;
2、添加默认数据源到动态路由数据源里;
3、定义一个Map,在里面存储不同租户的数据源;
4、通过代码编程的方式构建一个数据源;
5、将这些数据源都添加到动态路由数据源里;
6、通过从WiselyTenantIdResolver中拿到的TenantId,切换到不同的数据源。
@Component
public class WiselyMultiTenantConnectionProvider implements MultiTenantConnectionProvider, HibernatePropertiesCustomizer {
private final DataSource dataSource;
public WiselyMultiTenantConnectionProvider(DataSource dataSource){
this.dataSource = dataSource;
}
@Override
public Connection getAnyConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
return dataSource.getConnection();
}
@Override
public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
connection.close();
}
// 省略不重要的方法
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
}
}
这里直接注入前面切换数据源后的得到的dataSource,从dataSource中得到数据库的连接
其余代码与上例保持一致,数据源是编程获得,所以无须在配置中配置。
演示效果和上例一致,再此就不做演示了
四、源码地址
分区数据多租户:https://github.com/wiselyman/spring-boot-multitenant-partition
分结构多租户:https://github.com/wiselyman/spring-boot-multitenant-schema
分数据库多租户:https://github.com/wiselyman/spring-boot-multitenant-database
五、参考资料
https://spring.io/blog/2022/07/31/how-to-integrate-hibernates-multitenant-feature-with-spring-data-jpa-in-a-spring-boot-application
https://www.baeldung.com/hibernate-5-multitenancy
https://www.baeldung.com/multitenancy-with-spring-data-jpa
文章出自:爱科学的卫斯理,作者:汪云飞。如有转载本文请联系爱科学的卫斯理今日头条号。