SpringBoot + 多租户数据隔离(Schema/字段级):一套代码服务百家企业客户
多租户系统的挑战
在我们的日常开发工作中,经常会遇到这样的需求:
- 一套系统需要服务多家企业客户,每个客户的数据要完全隔离
- 客户A不能看到客户B的数据,哪怕是一个字节都不行
- 需要灵活支持新客户的接入,不能因为客户数量增长而影响性能
- 要支持客户数据的独立备份和迁移
传统的单租户架构显然无法满足这些需求,而多租户架构的实现方式也各有优劣。今天我们就来聊聊如何用SpringBoot构建一个高效、安全的多租户系统。
多租户实现方案对比
1. Schema隔离方案
每个租户拥有独立的数据库Schema,数据完全物理隔离,安全性最高,但资源消耗较大。
2. 字段级隔离方案
所有租户共享同一个数据库,通过tenant_id字段区分数据,资源利用率高,但需要严格的访问控制。
3. 混合方案
根据业务特点选择合适的隔离级别,例如核心数据用Schema隔离,日志数据用字段级隔离。
SpringBoot多租户实现
1. 租户标识解析
首先需要确定当前请求对应的租户:
@Component
public class TenantResolver {
public String resolveTenantId() {
// 从请求头获取租户ID
HttpServletRequest request = getCurrentRequest();
String tenantId = request.getHeader("X-Tenant-ID");
if (StringUtils.isEmpty(tenantId)) {
// 从域名解析租户ID
String host = request.getServerName();
tenantId = extractTenantFromHost(host);
}
if (StringUtils.isEmpty(tenantId)) {
throw new TenantNotFoundException("Tenant ID not found");
}
return tenantId;
}
private String extractTenantFromHost(String host) {
// 从子域名提取租户ID: customer1.yourapp.com -> customer1
return host.split("\\.")[0];
}
}
2. 动态数据源路由
基于租户ID动态选择数据源:
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Autowired
private TenantResolver tenantResolver;
@Override
protected Object determineCurrentLookupKey() {
return tenantResolver.resolveTenantId();
}
@PostConstruct
public void initializeDataSources() {
// 根据租户配置动态创建数据源
Map<String, DataSource> dataSourceMap = new HashMap<>();
List<TenantConfig> tenants = tenantService.getAllTenants();
for (TenantConfig tenant : tenants) {
DataSource dataSource = createDataSource(tenant);
dataSourceMap.put(tenant.getId(), dataSource);
}
setTargetDataSources(dataSourceMap);
setDefaultTargetDataSource(dataSourceMap.values().iterator().next());
afterPropertiesSet();
}
}
3. MyBatis-Plus租户插件
对于字段级隔离,使用MyBatis-Plus的租户插件:
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 租户插件
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
tenantInterceptor.setTenantLineHandler(new TenantLineHandler() {
@Override
public Expression getTenantId() {
return new StringValue(getCurrentTenantId());
}
@Override
public boolean ignoreTable(String tableName) {
// 指定哪些表不需要租户隔离
return "sys_tenant".equalsIgnoreCase(tableName) ||
"sys_user".equalsIgnoreCase(tableName);
}
});
interceptor.addInnerInterceptor(tenantInterceptor);
return interceptor;
}
}
4. JPA租户过滤
对于JPA应用,可以使用@Where注解:
@Entity
@Table(name = "user_info")
@Where(clause = "tenant_id = '" + "#{@tenantContext.getCurrentTenantId()}" + "'")
public class UserInfo {
@Id
private Long id;
private String username;
private String email;
@CreatedBy
private String tenantId; // 租户ID字段
// getters and setters
}
5. 租户上下文管理
创建租户上下文管理器:
@Component
public class TenantContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public void setCurrentTenant(String tenantId) {
CONTEXT.set(tenantId);
}
public String getCurrentTenant() {
return CONTEXT.get();
}
public void clear() {
CONTEXT.remove();
}
}
@Component
public class TenantFilter implements Filter {
@Autowired
private TenantContextHolder tenantContextHolder;
@Autowired
private TenantService tenantService;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantId = extractTenantId(httpRequest);
if (tenantService.isValidTenant(tenantId)) {
tenantContextHolder.setCurrentTenant(tenantId);
}
try {
chain.doFilter(request, response);
} finally {
tenantContextHolder.clear();
}
}
}
高级特性实现
1. 租户动态扩缩容
支持租户的动态接入和移除:
@Service
public class TenantManagementService {
@Autowired
private DataSource masterDataSource; // 主数据源,管理租户信息
public void createTenant(String tenantId, TenantConfig config) {
// 1. 在主表中注册租户信息
registerTenantInfo(tenantId, config);
// 2. 创建租户专属Schema或初始化租户数据表
initializeTenantSchema(tenantId, config);
// 3. 重新加载数据源路由
reloadDataSourceRouting(tenantId);
}
public void removeTenant(String tenantId) {
// 1. 标记租户为删除状态
markTenantAsDeleting(tenantId);
// 2. 等待当前租户请求处理完成
waitForActiveRequests(tenantId);
// 3. 删除租户数据和Schema
deleteTenantData(tenantId);
// 4. 重新加载数据源路由
reloadDataSourceRouting(null);
}
}
2. 跨租户查询
某些场景下需要跨租户查询(如管理员统计):
@Service
public class CrossTenantService {
@Autowired
private MasterTenantService masterTenantService;
public List<TenantStat> getGlobalStatistics() {
// 只有超级管理员才能执行跨租户查询
if (!SecurityUtils.isAdmin()) {
throw new AccessDeniedException("Access denied");
}
// 临时切换到主租户上下文
String originalTenant = TenantContextHolder.getCurrentTenant();
TenantContextHolder.setCurrentTenant("master");
try {
return masterTenantService.getGlobalStats();
} finally {
// 恢复原始租户上下文
TenantContextHolder.setCurrentTenant(originalTenant);
}
}
}
3. 租户数据备份与恢复
@Component
public class TenantBackupService {
public void backupTenantData(String tenantId) {
// 1. 获取租户专属数据源
DataSource tenantDataSource = getTenantDataSource(tenantId);
// 2. 执行备份操作
String backupFileName = "backup_" + tenantId + "_" +
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + ".sql";
executeBackupScript(tenantDataSource, backupFileName);
// 3. 存储备份文件信息
saveBackupRecord(tenantId, backupFileName);
}
public void restoreTenantData(String tenantId, String backupFile) {
// 验证备份文件安全性
if (!isValidBackupFile(backupFile)) {
throw new IllegalArgumentException("Invalid backup file");
}
DataSource tenantDataSource = getTenantDataSource(tenantId);
executeRestoreScript(tenantDataSource, backupFile);
}
}
最佳实践建议
- 安全优先:确保数据隔离的绝对性,任何情况下都不能让租户看到其他租户数据
- 性能优化:合理选择隔离方案,避免过度隔离导致性能问题
- 监控告警:监控租户资源使用情况,及时发现异常
- 灰度发布:新租户接入时采用灰度策略,确保系统稳定性
通过这样的多租户架构设计,我们可以用一套代码服务多个企业客户,既保证了数据安全,又提高了资源利用率。
标题:SpringBoot + 多租户数据隔离(Schema/字段级):一套代码服务百家企业客户
作者:jiangyi
地址:http://jiangyi.space/articles/2026/01/30/1769664221825.html
0 评论