1. 概述

多租户(Multi-tenancy)是指单个软件应用实例同时服务多个租户(客户)的架构模式。该架构通过租户间的数据隔离机制,确保每个租户的数据和资源独立安全。本教程将演示如何在 Spring Boot 应用中结合 Spring Data JPA 实现多租户配置,并通过 JWT 为租户添加安全控制。

2. 多租户模型

多租户系统主要有三种实现方案:

  • 独立数据库(Separate Database)
  • 共享数据库 + 独立 Schema(Shared Database and Separate Schema)
  • 共享数据库 + 共享 Schema(Shared Database and Shared Schema)

2.1. 独立数据库

每个租户的数据存储在独立的数据库实例中,实现完全隔离。这种模式也称为一租户一数据库独立数据库架构

2.2. 共享数据库 + 独立 Schema

所有租户共享同一个数据库,但每个租户拥有独立的 Schema。这种模式也称为一租户一 Schema独立Schema架构

2.3. 共享数据库 + 共享 Schema

所有租户共享数据库和 Schema,每张表通过租户标识列区分数据:共享Schema架构

3. Maven 依赖

首先在 pom.xml 中添加 Spring Data JPA 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

由于使用 PostgreSQL 数据库,添加相应驱动:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

注意:独立数据库和共享数据库+独立Schema的配置方式相似,本教程重点实现独立数据库方案

4. 动态数据源路由

本节介绍一租户一数据库模型的核心实现原理。

4.1. AbstractRoutingDataSource

实现多租户的核心思路是根据当前租户标识动态路由数据源。Spring 提供的 AbstractRoutingDataSource 可帮我们实现这一目标:

public class MultitenantDataSource extends AbstractRoutingDataSource {

    @Override
    protected String determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant();
    }
}

该类通过查找键(lookup key)将 getConnection 调用路由到目标数据源。查找键通常通过线程绑定的上下文获取,因此我们需要创建 TenantContext 存储租户标识:

public class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static String getCurrentTenant() {
        return CURRENT_TENANT.get();
    }

    public static void setCurrentTenant(String tenant) {
        CURRENT_TENANT.set(tenant);
    }
}

使用 ThreadLocal 存储当前请求的租户 ID,确保线程安全。set() 方法用于存储租户 ID,get() 方法用于获取。

4.2. 按请求设置租户 ID

执行租户操作前,必须在事务创建前确定租户 ID。可通过 FilterInterceptor 在请求到达控制器前设置租户 ID:

@Component
@Order(1)
class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
      FilterChain chain) throws IOException, ServletException {

        HttpServletRequest req = (HttpServletRequest) request;
        String tenantName = req.getHeader("X-TenantID");
        TenantContext.setCurrentTenant(tenantName);

        try {
            chain.doFilter(request, response);
        } finally {
            TenantContext.setCurrentTenant("");
        }
    }
}

该过滤器从请求头 X-TenantID 获取租户 ID 并存入 TenantContextfinally 块确保请求结束后重置租户上下文,避免跨请求污染。

5. 数据库方案实现

本节实现一租户一数据库的多租户方案。

5.1. 租户声明

为每个租户创建独立的数据源配置文件。在 allTenants 目录下创建 tenant_1.properties

name=tenant_1
datasource.url=jdbc:postgresql://localhost:5432/tenant1
datasource.username=postgres
datasource.password=123456
datasource.driver-class-name=org.postgresql.Driver

再为第二个租户创建 tenant_2.properties

name=tenant_2
datasource.url=jdbc:postgresql://localhost:5432/tenant2
datasource.username=postgres
datasource.password=123456
datasource.driver-class-name=org.postgresql.Driver

最终目录结构:租户配置文件

5.2. 数据源声明

通过 DataSourceBuilder 读取租户配置并创建数据源:

@Configuration
public class MultitenantConfiguration {

    @Value("${defaultTenant}")
    private String defaultTenant;

    @Bean
    @ConfigurationProperties(prefix = "tenants")
    public DataSource dataSource() {
        File[] files = Paths.get("allTenants").toFile().listFiles();
        Map<Object, Object> resolvedDataSources = new HashMap<>();

        for (File propertyFile : files) {
            Properties tenantProperties = new Properties();
            DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();

            try {
                tenantProperties.load(new FileInputStream(propertyFile));
                String tenantId = tenantProperties.getProperty("name");

                dataSourceBuilder.driverClassName(tenantProperties.getProperty("datasource.driver-class-name"));
                dataSourceBuilder.username(tenantProperties.getProperty("datasource.username"));
                dataSourceBuilder.password(tenantProperties.getProperty("datasource.password"));
                dataSourceBuilder.url(tenantProperties.getProperty("datasource.url"));
                resolvedDataSources.put(tenantId, dataSourceBuilder.build());
            } catch (IOException exp) {
                throw new RuntimeException("Problem in tenant datasource:" + exp);
            }
        }

        AbstractRoutingDataSource dataSource = new MultitenantDataSource();
        dataSource.setDefaultTargetDataSource(resolvedDataSources.get(defaultTenant));
        dataSource.setTargetDataSources(resolvedDataSources);

        dataSource.afterPropertiesSet();
        return dataSource;
    }

}

关键步骤:

  1. 读取 allTenants 目录下的配置文件
  2. 使用 DataSourceBuilder 构建数据源
  3. 设置默认数据源(通过 application.propertiesdefaultTenant 指定)
  4. 调用 afterPropertiesSet() 完成初始化

6. 测试

6.1. 创建租户数据库

在 PostgreSQL 中创建两个数据库:租户数据库

在每个数据库中执行建表语句:

create table employee (id int8 generated by default as identity, name varchar(255), primary key (id));

6.2. 示例控制器

创建 EmployeeController 根据请求头中的租户 ID 保存员工数据:

@RestController
@Transactional
public class EmployeeController {

    @Autowired
    private EmployeeRepository employeeRepository;

    @PostMapping(path = "/employee")
    public ResponseEntity<?> createEmployee() {
        Employee newEmployee = new Employee();
        newEmployee.setName("Baeldung");
        employeeRepository.save(newEmployee);
        return ResponseEntity.ok(newEmployee);
    }
}

6.3. 示例请求

使用 Postman 向租户 tenant_1 发送请求:租户1请求

再向租户 tenant_2 发送请求:租户2请求

检查数据库可确认数据已正确存储到对应租户的数据库中。

7. 安全控制

多租户环境必须确保租户数据隔离,每个租户只能访问自己的数据。下面通过 JWT 实现租户安全控制。

7.1. Maven 依赖

添加 Spring Security 和 JWT 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.3</version>
</dependency>

7.2. 安全配置

为简化演示,使用内存用户认证。注意 Spring Security 5.7+ 已弃用 WebSecurityConfigurerAdapter

@Bean
public InMemoryUserDetailsManager userDetailsService() {
    UserDetails user1 = User
      .withUsername("user")
      .password(passwordEncoder().encode("baeldung"))
      .roles("tenant_1")
      .build();

    UserDetails user2 = User
      .withUsername("admin")
      .password(passwordEncoder().encode("baeldung"))
      .roles("tenant_2")
      .build();
    return new InMemoryUserDetailsManager(user1, user2);
}

这里将租户标识映射为用户角色:用户 user 访问 tenant_1admin 访问 tenant_2

创建登录过滤器 LoginFilter

public class LoginFilter extends AbstractAuthenticationProcessingFilter {

    public LoginFilter(String url, AuthenticationManager authManager) {
        super(new AntPathRequestMatcher(url));
        setAuthenticationManager(authManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
      throws AuthenticationException, IOException, ServletException {

        AccountCredentials creds = new ObjectMapper().
          readValue(req.getInputStream(), AccountCredentials.class);

        return getAuthenticationManager().authenticate(
          new UsernamePasswordAuthenticationToken(creds.getUsername(),
            creds.getPassword(), Collections.emptyList())
        );
    }
}

AccountCredentials DTO 定义:

public class AccountCredentials {
    private String username;
    private String password;
    // getter and setter methods
}

7.3. JWT 实现

认证成功后生成包含租户 ID 的 JWT:

@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res,
  FilterChain chain, Authentication auth) throws IOException, ServletException {

    Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
    String tenant = "";
    for (GrantedAuthority gauth : authorities) {
        tenant = gauth.getAuthority();
    }

    AuthenticationService.addToken(res, auth.getName(), tenant.substring(5));
}

AuthenticationService 负责生成 JWT:

public class AuthenticationService {

    private static final long EXPIRATIONTIME = 864_000_00; // 1 day in milliseconds
    private static final String SECRETKEY = "q3t6w9zCFJNcQfTjWnq3t6w9zCFJNcQfTjWnZr4u7xADGKaPd";
    private static final SecretKey SIGNINGKEY = Keys.hmacShaKeyFor(SECRETKEY.getBytes(StandardCharsets.UTF_8));
    private static final String PREFIX = "Bearer";

    public static void addToken(HttpServletResponse res, String username, String tenant) {
        String JwtToken = Jwts.builder()
          .subject(username)
          .audience().add(tenant).and()
          .issuedAt(new Date(System.currentTimeMillis()))
          .expiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
          .signWith(SIGNINGKEY)
          .compact();
        res.addHeader("Authorization", PREFIX + " " + JwtToken);
    }
}

将租户 ID 存储为 JWT 的 audience 声明。最后配置安全过滤器链:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    final AuthenticationManager authenticationManager = authenticationManager(http.getSharedObject(AuthenticationConfiguration.class));
    http
      .authorizeHttpRequests(authorize ->
        authorize.requestMatchers("/login").permitAll().anyRequest().authenticated())
      .sessionManagement(securityContext -> securityContext.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .addFilterBefore(new LoginFilter("/login", authenticationManager), UsernamePasswordAuthenticationFilter.class)
      .addFilterBefore(new AuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
      .csrf(csrf -> csrf.disable())
      .headers(header -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
      .httpBasic(Customizer.withDefaults());

    return http.build();
}

添加 AuthenticationFilter 设置安全上下文:

public class AuthenticationFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {

        Authentication authentication = AuthenticationService.getAuthentication((HttpServletRequest) req);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        chain.doFilter(req, res);
    }
}

7.4. 从 JWT 提取租户 ID

修改 TenantFilter 从 JWT 获取租户 ID:

String tenant = AuthenticationService.getTenant((HttpServletRequest) req);
TenantContext.setCurrentTenant(tenant);

AuthenticationService.getTenant() 方法实现:

public static String getTenant(HttpServletRequest req) {
    String token = req.getHeader("Authorization");
    if (token == null) {
        return null;
    }
    String tenant = Jwts.parser()
      .setSigningKey(SIGNINGKEY)
      .build().parseClaimsJws(token.replace(PREFIX, "").trim())
      .getBody()
      .getAudience()
        .iterator()
        .next();
    return tenant;
}

8. 安全测试

8.1. JWT 生成

/login 接口发送用户 user 的凭据:JWT生成

生成的 Token 示例:

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoidGVuYW50XzEiLCJleHAiOjE2NTk2MDk1Njd9.

解码后可见租户 ID 存储在 aud 声明中:

{
  "sub": "user",
  "aud": [
    "tenant_1"
  ],
  "iat": 1705473402,
  "exp": 1705559802
}

8.2. 示例请求

使用生成的 Token 访问 /employee 接口:带Token的请求

系统从 Token 中提取租户 ID 并设置到 TenantContext,确保数据写入正确的租户数据库。

9. 总结

本文探讨了多租户的三种主流模型,重点演示了在 Spring Boot 中使用 Spring Data JPA 实现独立数据库方案。通过动态数据源路由和 JWT 安全控制,构建了完整的多租户应用。所有示例代码可在 GitHub 获取。


原始标题:Multitenancy With Spring Data JPA