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:
2.3. 共享数据库 + 共享 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。可通过 Filter
或 Interceptor
在请求到达控制器前设置租户 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 并存入 TenantContext
。finally
块确保请求结束后重置租户上下文,避免跨请求污染。
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;
}
}
关键步骤:
- 读取
allTenants
目录下的配置文件 - 使用
DataSourceBuilder
构建数据源 - 设置默认数据源(通过
application.properties
的defaultTenant
指定) - 调用
afterPropertiesSet()
完成初始化
6. 测试
6.1. 创建租户数据库
在每个数据库中执行建表语句:
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. 示例请求
检查数据库可确认数据已正确存储到对应租户的数据库中。
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_1
,admin
访问 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 生成
生成的 Token 示例:
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoidGVuYW50XzEiLCJleHAiOjE2NTk2MDk1Njd9.
解码后可见租户 ID 存储在 aud
声明中:
{
"sub": "user",
"aud": [
"tenant_1"
],
"iat": 1705473402,
"exp": 1705559802
}
8.2. 示例请求
系统从 Token 中提取租户 ID 并设置到 TenantContext
,确保数据写入正确的租户数据库。
9. 总结
本文探讨了多租户的三种主流模型,重点演示了在 Spring Boot 中使用 Spring Data JPA 实现独立数据库方案。通过动态数据源路由和 JWT 安全控制,构建了完整的多租户应用。所有示例代码可在 GitHub 获取。