一、前言

多租户表示应用程序的单个运行实例同时为多个客户机(租户)服务的体系结构。这在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.测试

运行程序,我们先往数据库中添加几条测试数据。

SpringBoot多租户架构之分区数据实现方案_分区数据

我们使用postman进行测试。

SpringBoot多租户架构之分区数据实现方案_多租户_02

SpringBoot多租户架构之分区数据实现方案_多租户_03

sql执行情况:

SpringBoot多租户架构之分区数据实现方案_分区数据_04

每一条sql语句自动添加了tenant_id这个条件,这样就实现了我们的多租户解决方案。