一、前言
多租户表示应用程序的单个运行实例同时为多个客户机(租户)服务的体系结构。这在SaaS解决方案中非常常见。在这些系统中,隔离与各种租户相关的信息(数据、定制等)是一个特殊的挑战。这包括存储在数据库中的每个租户拥有的数据。
二、分区数据解决方案
所有数据都保存在一个数据库Schema中。通过使用分区列对每个租户的数据进行分区。这种方法将使用单个连接池为所有租户提供服务。但是在这种方法何总,应用程序需要对每个SQL语句添加分区列(查询时where条件加入分区列作为查询条件)。
优点:
- 成本最低,因为所有租户都共享同一个数据库实例和模式
- 数据访问和查询效率可能较高,因为数据都在同一个表中
缺点:
- 数据隔离级别最低,可能存在安全风险
- 需要通过应用程序逻辑来确保数据的正确隔离和访问控制
- 数据备份和恢复操作可能非常复杂,因为需要考虑到所有租户的数据
1.引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.31</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
注:请先确保你当前使用的SpringBoot版本(Spring Data JPA)整合的Hibernate版本至少是6.0版本以上。
2.定义实体
package com.example.spepcdemo.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.TenantId;
/**
* @author qx
* @date 2024/7/1
* @des 学生类实体
*/
@Entity
@Table(name = "t_student")
@Data
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Integer age;
@TenantId
private String tenantId;
}
这里通过@TenantId注解,该字段专门用来分区租户的,Hibernate在查询数据时会自动添加该查询条件,如果你使用自定义SQL,那么需要你自行添加该条件(租户ID)。
3.编写数据持久层和服务层
数据持久层
package com.example.ruituodemo.repository;
import com.example.ruituodemo.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* @author qx
* @date 2024/7/1
* @des 数据持久层
*/
public interface StudentRepository extends JpaRepository<Student, Long> {
}
服务层:
package com.example.ruituodemo.service;
import com.example.ruituodemo.entity.Student;
import com.example.ruituodemo.repository.StudentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @author qx
* @date 2024/7/1
* @des 服务层
*/
@Service
public class StudentService {
@Autowired
private StudentRepository studentRepository;
public List<Student> getAllStudents() {
return studentRepository.findAll();
}
}
4.控制层
package com.example.ruituodemo.controller;
import com.example.ruituodemo.entity.Student;
import com.example.ruituodemo.service.StudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author qx
* @date 2024/7/1
* @des 控制层
*/
@RestController
@RequestMapping("/student")
public class StudentController {
@Autowired
private StudentService studentService;
@GetMapping("/list")
public List<Student> students() {
return studentService.getAllStudents();
}
}
5.租户标识解析处理
package com.example.ruituodemo.component;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* @author qx
* @date 2024/7/1
* @des
*/
@Component
public class TenantIdResolver implements CurrentTenantIdentifierResolver<String> {
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("default");
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
这个类的作用是先向ThreadLocal存入租户ID,并从ThreadLocal获取当前租户ID。
6.Web拦截器
该拦截器的作用用来从请求Header中获取租户ID,存入ThreadLocal中。
package com.example.ruituodemo.interceptor;
import com.example.ruituodemo.component.TenantIdResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* @author qx
* @date 2024/7/1
* @des 拦截器
*/
@Component
public class TenantIdInterceptor implements HandlerInterceptor {
@Autowired
private TenantIdResolver tenantIdResolver;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String tenantId = request.getHeader("x-tenant-id");
System.out.println("tenantId=" + tenantId);
tenantIdResolver.setCurrentTenant(tenantId);
return true;
}
}
package com.example.ruituodemo.config;
import com.example.ruituodemo.interceptor.TenantIdInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author qx
* @date 2024/7/1
* @des 配置
*/
@Configuration
public class InterceptorWebConfig implements WebMvcConfigurer {
@Autowired
private TenantIdInterceptor tenantIdInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantIdInterceptor).addPathPatterns("/**");
}
}
7.配置Hibernate,设置租户ID的解析器
#mysql????
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/funly?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true
#jpa????
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.'[tenant_identifier_resolver]'='com.example.ruituodemo.component.TenantIdResolver'
8.测试
运行程序,我们先往数据库中添加几条测试数据。
我们使用postman进行测试。
sql执行情况:
每一条sql语句自动添加了tenant_id这个条件,这样就实现了我们的多租户解决方案。