最近在负责一个SaaS平台的重构,核心需求是实现多租户隔离,既要保证不同租户数据的安全性、独立性,又要兼顾系统的扩展性和运维成本,避免过度设计导致后期维护困难。结合之前做过的几个多租户项目踩过的坑,整理出一套可直接落地的SpringBoot多租户系统架构设计方案,全程干货,无多余套话,适合需要快速落地多租户需求的开发同行参考。
先明确一个核心前提:多租户架构设计的核心是「隔离」,但隔离程度并非越高越好,需根据业务场景、用户体量、运维能力做取舍。本文不空谈理论,只讲可落地的设计,重点解决「怎么选隔离方案」「怎么无缝集成SpringBoot」「怎么避坑」这三个核心问题。
一、多租户隔离方案选型(重中之重)
市面上主流的多租户隔离方案有三种,各有优劣,不存在绝对的最优解,只有最适合业务的选择。先结合我这边的业务场景(SaaS平台,租户数量500+,单租户最大用户量1000+,数据量中等,运维人力有限),直接给出选型结论:采用「共享数据库、独立Schema」方案,下文会详细说明选型原因和落地细节。
1.1 三种隔离方案对比(落地视角,非理论版)
|
独立数据库(多库) |
每个租户一个独立数据库,数据源完全隔离 |
数据隔离级别最高,故障影响范围小,可单独备份/扩容 |
运维成本极高(数据库部署、备份、监控),系统部署复杂,租户扩容繁琐 |
租户数量少(<50)、单租户数据量大、对数据安全要求极高(如金融、政务) |
|
共享数据库、独立Schema(多Schema) |
所有租户共享一个数据库,每个租户一个独立Schema(相当于数据库下的独立命名空间) |
隔离级别适中,运维成本低(单库管理),扩容方便,数据备份/迁移灵活 |
数据库层面无隔离,需通过代码严格控制租户权限,避免跨租户访问 |
租户数量中等(50-1000)、数据量适中、运维人力有限的SaaS平台(推荐大多数场景) |
|
共享数据库、共享Schema(单Schema) |
所有租户共享数据库和Schema,通过表中「租户ID」字段区分数据 |
运维成本最低,部署最简单,支持海量租户 |
隔离级别最低,数据安全风险高,SQL优化难度大,易出现跨租户数据泄露 |
租户数量极多(>1000)、数据量小、对隔离要求极低的场景(如轻量工具类SaaS) |
1.2 本次选型原因(结合实际业务)
放弃「独立数据库」:租户数量500+,如果每个租户一个数据库,运维人员需要管理500+数据库,备份、监控、扩容都会崩溃,且硬件成本极高,不符合项目预算。
放弃「共享Schema」:项目涉及部分敏感数据(如租户的客户信息、订单数据),单Schema方案全靠代码控制租户ID,一旦代码出现疏漏(如SQL忘记加租户ID条件),就会导致跨租户数据泄露,风险不可控;且后期租户数据量增长后,SQL优化会非常困难,无法单独对某个租户的数据进行备份和迁移。
最终选择「共享数据库、独立Schema」:兼顾隔离性和运维成本,既保证每个租户的数据独立(Schema隔离,互不干扰),又只需管理一个数据库,运维压力小;同时支持单独对某个租户的Schema进行备份、扩容,后期租户数量增加时,可灵活迁移部分租户到新的数据库,扩展性强。
二、整体架构设计(SpringBoot为核心)
架构设计遵循「简单、可落地、可扩展」原则,不引入过多复杂中间件,基于SpringBoot+MyBatis-Plus+Shiro(权限控制)构建,核心分为5层:接入层、网关层、业务层、数据访问层、数据存储层,每层职责清晰,通过租户上下文贯穿整个请求链路,实现租户隔离。
2.1 整体架构图(简洁落地版)
接入层 → 网关层(租户认证+路由转发) → 业务层(租户上下文管理+核心业务逻辑) → 数据访问层(租户SQL拦截+数据源切换) → 数据存储层(共享数据库+独立Schema)
补充说明:没有画复杂的架构图(实际开发中用不到花里胡哨的图),重点是每层的核心职责和租户相关的处理逻辑,下文逐一层拆解。
2.2 各层核心设计(重点讲租户相关逻辑)
2.2.1 接入层
核心职责:接收前端请求,传递租户标识(TenantId),支持两种租户标识传递方式(根据业务灵活选择):
1. URL路径传递(推荐):如 /{tenantId}/api/user/list,适用于前后端分离场景,前端在请求时拼接租户ID,网关层拦截解析。
2. 请求头传递:如 X-Tenant-Id,适用于第三方调用、内部服务调用场景,避免URL路径暴露租户ID。
注意点:租户标识必须是唯一的(如UUID、自增ID),禁止使用中文、特殊字符,避免后续Schema命名、SQL解析出现问题;前端必须严格传递租户标识,无标识的请求直接拦截拒绝。
2.2.2 网关层(核心:租户认证+路由转发)
采用Spring Cloud Gateway作为网关,核心处理两件事:租户合法性校验、租户标识透传,避免非法租户访问系统。
1. 核心逻辑:
(1)拦截所有请求,解析请求中的租户标识(URL路径或请求头);
(2)校验租户合法性:查询租户表(公共Schema中的租户信息表),判断租户是否存在、是否启用,非法租户直接返回403;
(3)透传租户标识:将合法的租户标识放入请求头(如X-Tenant-Id),转发到后端业务服务,确保后端所有接口都能获取到租户标识。
2. 关键代码片段(简化版,可直接复用):
@Component
public class TenantGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private TenantMapper tenantMapper; // 操作公共Schema中的租户表
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 解析租户标识(这里以URL路径为例,如 /tenant1/api/user/list)
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
String[] pathSegments = path.split("/");
if (pathSegments.length < 2) {
// 无租户标识,直接拒绝
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
String tenantId = pathSegments[1];
// 2. 校验租户合法性(查询公共Schema中的租户表)
TenantDO tenant = tenantMapper.selectById(tenantId);
if (tenant == null || !tenant.getStatus().equals(1)) {
// 租户不存在或已禁用
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
// 3. 透传租户标识到请求头
ServerHttpRequest newRequest = request.mutate()
.header("X-Tenant-Id", tenantId)
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
}
@Override
public int getOrder() {
// 优先级高于路由转发过滤器,确保先校验租户
return -1;
}
}
3. 注意点:网关层只做租户合法性校验和标识透传,不处理复杂业务逻辑,避免网关成为性能瓶颈;租户表必须放在「公共Schema」中(如public、common_schema),供网关和所有业务服务访问。
2.2.3 业务层(核心:租户上下文管理)
业务层是核心业务逻辑实现层,租户相关的核心是「租户上下文管理」—— 确保每个请求的整个链路中,都能快速获取到当前租户标识,无需手动传递TenantId参数,简化开发。
1. 租户上下文实现(基于ThreadLocal,线程安全):
核心思路:请求进入业务服务后,拦截器解析请求头中的租户标识,存入ThreadLocal,业务逻辑中可直接通过工具类获取,请求结束后清除ThreadLocal,避免内存泄漏。
// 租户上下文工具类
public class TenantContextHolder {
// ThreadLocal存储当前租户标识,线程隔离
private static final ThreadLocal<String> TENANT_CONTEXT = new ThreadLocal<>();
// 设置租户标识
public static void setTenantId(String tenantId) {
TENANT_CONTEXT.set(tenantId);
}
// 获取租户标识
public static String getTenantId() {
return TENANT_CONTEXT.get();
}
// 清除租户标识(必须在请求结束后调用,避免内存泄漏)
public static void clear() {
TENANT_CONTEXT.remove();
}
}
// 租户拦截器(解析请求头中的租户标识,存入上下文)
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 解析网关透传的租户标识
String tenantId = request.getHeader("X-Tenant-Id");
if (StringUtils.isEmpty(tenantId)) {
response.setStatus(HttpStatus.FORBIDDEN);
response.getWriter().write("租户标识不存在");
return false;
}
// 存入上下文
TenantContextHolder.setTenantId(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 请求结束,清除上下文
TenantContextHolder.clear();
}
}
// 注册拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private TenantInterceptor tenantInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns("/api/tenant/public/**"); // 排除公共接口(如租户注册)
}
}
2. 业务逻辑中的使用(简化开发):
无需在每个方法中传递TenantId,直接通过TenantContextHolder.getTenantId()获取,例如:
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public List<UserDO> listUser() {
// 直接获取当前租户标识
String tenantId = TenantContextHolder.getTenantId();
// 调用mapper查询当前租户的用户(MyBatis-Plus会自动拼接Schema前缀)
return userMapper.selectList(null);
}
}
3. 注意点:ThreadLocal是线程级别的,必须在请求结束后调用clear()方法清除,否则会导致内存泄漏(尤其是在异步请求场景下,需额外处理,下文会讲);拦截器需排除公共接口(如租户注册、登录),避免死循环。
2.2.4 数据访问层(核心:租户SQL拦截+数据源切换)
数据访问层是租户隔离的关键,核心实现两件事:动态切换租户Schema、拦截SQL自动拼接Schema前缀(避免手动写Schema,简化开发),基于MyBatis-Plus实现(MyBatis也可类似实现,略复杂)。
1. 数据源配置(共享数据库,动态切换Schema):
核心思路:配置一个主数据源(共享数据库),通过租户标识动态切换Schema,无需配置多个数据源,简化配置。
@Configuration
@MapperScan("com.example.tenant.mapper")
public class DataSourceConfig {
// 读取数据库配置(application.yml中配置)
@Value("${spring.datasource.url}")
private String url;
@Value("${spring.datasource.username}")
private String username;
@Value("${spring.datasource.password}")
private String password;
@Value("${spring.datasource.driver-class-name}")
private String driverClassName;
// 配置主数据源(共享数据库)
@Bean
public DataSource dataSource() {
HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setJdbcUrl(url);
hikariConfig.setUsername(username);
hikariConfig.setPassword(password);
hikariConfig.setDriverClassName(driverClassName);
// 关键:允许切换Schema(不同数据库配置不同,MySQL无需额外配置,PostgreSQL需设置)
hikariConfig.addDataSourceProperty("currentSchema", "public"); // 默认Schema(公共Schema)
return new HikariDataSource(hikariConfig);
}
// MyBatis-Plus配置(关键:注入SQL拦截器,动态拼接Schema前缀)
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加租户SQL拦截器
interceptor.addInnerInterceptor(new TenantSchemaInterceptor());
return interceptor;
}
}
2. 租户SQL拦截器(核心,自动拼接Schema前缀):
核心思路:拦截所有SQL语句,在表名前拼接当前租户的Schema前缀(如tenant1.user、tenant2.order),无需手动在XML或注解中写Schema,简化开发,同时避免漏写Schema导致跨租户访问。
/**
* 租户Schema SQL拦截器,自动给表名拼接租户Schema前缀
*/
@Component
public class TenantSchemaInterceptor implements InnerInterceptor {
@Override
public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) throws SQLException {
// 1. 获取当前租户标识
String tenantId = TenantContextHolder.getTenantId();
if (StringUtils.isEmpty(tenantId)) {
throw new SQLException("租户标识为空,无法执行SQL");
}
// 2. 获取当前执行的SQL
MetaObject metaObject = SystemMetaObject.forObject(sh);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
String sql = (String) metaObject.getValue("delegate.boundSql.sql");
if (StringUtils.isEmpty(sql)) {
return;
}
// 3. 排除公共表(如租户表、字典表,无需拼接Schema)
String mappedStatementId = mappedStatement.getId();
if (mappedStatementId.contains("com.example.tenant.mapper.common.")) {
return;
}
// 4. 拼接Schema前缀(格式:租户ID.表名)
// 这里采用简单的正则替换,实际开发中可根据SQL复杂度优化正则
String newSql = sql.replaceAll("(?i)from (\\\\w+)", "from " + tenantId + ".$1")
.replaceAll("(?i)join (\\\\w+)", "join " + tenantId + ".$1")
.replaceAll("(?i)into (\\\\w+)", "into " + tenantId + ".$1")
.replaceAll("(?i)update (\\\\w+)", "update " + tenantId + ".$1")
.replaceAll("(?i)delete from (\\\\w+)", "delete from " + tenantId + ".$1");
// 5. 替换原SQL
metaObject.setValue("delegate.boundSql.sql", newSql);
}
}
3. 注意点:
(1)公共表(如租户表、字典表)需放在公共Schema中,SQL拦截器需排除这些表的SQL,避免拼接错误的Schema前缀;
(2)SQL拦截器的正则表达式需根据实际SQL复杂度优化,避免出现替换错误(如表名包含关键字、SQL中有子查询等场景);
(3)如果使用PostgreSQL数据库,需在数据源配置中设置currentSchema,MySQL无需额外配置;
(4)MyBatis-Plus的分页插件、逻辑删除插件等,需与租户SQL拦截器兼容,优先级需合理设置(租户拦截器优先级高于分页插件)。
2.2.5 数据存储层(共享数据库+独立Schema)
核心设计:
1. 数据库规划:创建一个共享数据库(如tenant_db),所有租户共享该数据库,每个租户对应一个独立的Schema(Schema名称与租户ID一致,如tenant1、tenant2);
2. 公共Schema:创建一个公共Schema(如public),用于存储所有租户共用的数据(如租户表、字典表、系统配置表),所有业务服务都能访问该Schema;
3. 租户Schema:每个租户的Schema结构完全一致(表结构、索引、约束相同),仅存储当前租户的数据,Schema名称与租户ID严格对应(避免混淆);
4. 权限控制:数据库层面,创建一个通用账号,仅授予该账号「访问公共Schema、以及对应租户Schema」的权限,禁止授予超级管理员权限,避免越权访问;
5. 备份策略:支持单独备份某个租户的Schema(按需备份),也支持备份整个共享数据库(全量备份),兼顾备份灵活性和运维成本。
补充:租户Schema的创建,可在租户注册时通过代码自动创建(调用数据库原生SQL:CREATE SCHEMA IF NOT EXISTS 租户ID),无需手动创建,简化运维。
三、关键问题解决方案(踩坑总结)
这部分是重点,结合我之前项目中踩过的坑,整理出几个核心问题的解决方案,避免大家重复踩坑。
3.1 异步请求场景下,租户上下文丢失问题
问题描述:在异步方法(如@Async注解)中,通过TenantContextHolder.getTenantId()获取不到租户标识,因为ThreadLocal是线程级别的,异步方法会开启新线程,原线程的ThreadLocal数据无法传递到新线程。
解决方案:使用Spring的TaskDecorator,将原线程的租户上下文传递到异步线程中。
// 租户上下文装饰器,传递租户标识到异步线程
@Component
public class TenantTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 原线程的租户标识
String tenantId = TenantContextHolder.getTenantId();
return () -> {
try {
// 将原线程的租户标识存入异步线程的上下文
TenantContextHolder.setTenantId(tenantId);
// 执行异步任务
runnable.run();
} finally {
// 清除异步线程的上下文
TenantContextHolder.clear();
}
};
}
}
// 配置异步任务池,注入装饰器
@Configuration
@EnableAsync
public class AsyncConfig {
@Autowired
private TenantTaskDecorator tenantTaskDecorator;
@Bean
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("async-tenant-");
// 注入租户上下文装饰器
executor.setTaskDecorator(tenantTaskDecorator);
executor.initialize();
return executor;
}
}
3.2 跨服务调用时,租户标识透传问题
问题描述:微服务架构中,服务A调用服务B,服务B无法获取到租户标识,导致SQL拦截器报错(租户标识为空)。
解决方案:使用Feign拦截器,将当前服务的租户标识透传到被调用服务的请求头中。
@Component
public class FeignTenantInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 获取当前服务的租户标识,透传到请求头
String tenantId = TenantContextHolder.getTenantId();
if (StringUtils.isNotEmpty(tenantId)) {
template.header("X-Tenant-Id", tenantId);
}
}
}
注意点:被调用服务必须配置租户拦截器,解析请求头中的租户标识,存入上下文,否则依然无法获取。
3.3 租户Schema自动创建问题
问题描述:租户注册时,需要自动创建对应的Schema和表结构,避免手动创建,简化运维。
解决方案:在租户注册接口中,调用数据库原生SQL创建Schema,再通过Flyway或MyBatis-Plus的代码生成器,自动执行表结构脚本。
@Service
public class TenantServiceImpl implements TenantService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private Flyway flyway;
@Override
@Transactional
public void registerTenant(TenantRegisterDTO dto) {
// 1. 生成租户ID(如UUID)
String tenantId = UUID.randomUUID().toString().replace("-", "");
// 2. 创建租户Schema
String createSchemaSql = "CREATE SCHEMA IF NOT EXISTS " + tenantId;
jdbcTemplate.execute(createSchemaSql);
// 3. 使用Flyway执行表结构脚本(脚本放在resources/db/migration目录下)
// 切换到当前租户的Schema,执行脚本
flyway.setSchemas(tenantId);
flyway.migrate();
// 4. 保存租户信息到公共Schema的租户表
TenantDO tenantDO = new TenantDO();
tenantDO.setId(tenantId);
tenantDO.setName(dto.getTenantName());
tenantDO.setStatus(1); // 启用
tenantMapper.insert(tenantDO);
}
}
补充:Flyway是一款数据库版本管理工具,可自动执行SQL脚本,适合多环境、多租户的表结构管理,避免手动执行脚本导致的表结构不一致问题。
3.4 跨租户数据访问漏洞问题
问题描述:代码疏漏(如SQL忘记加租户条件、SQL拦截器失效),导致跨租户访问数据,泄露敏感信息。
解决方案:双重防护,避免漏洞:
1. 代码层面:所有查询、新增、修改、删除操作,必须通过SQL拦截器自动拼接Schema前缀,禁止手动写固定Schema;
2. 数据库层面:给通用账号设置权限,仅允许访问公共Schema和对应租户的Schema,禁止访问其他租户的Schema;
3. 测试层面:专门编写跨租户访问的测试用例,模拟非法租户标识,测试是否能访问其他租户的数据,及时发现漏洞。
四、架构扩展与优化建议
1. 租户扩容优化:当某个租户数据量过大时,可将该租户的Schema迁移到新的数据库,修改数据源配置,实现租户级别的扩容,不影响其他租户;
2. 缓存优化:使用Redis缓存时,给缓存Key拼接租户标识(如tenantId:user:1),避免跨租户缓存污染;
3. 日志优化:所有日志输出时,拼接租户标识,便于排查问题(如某个租户的接口报错,可快速筛选该租户的日志);
4. 监控优化:新增租户维度的监控指标(如每个租户的接口调用量、SQL执行耗时、数据量),便于运维人员监控每个租户的系统运行状态;
5. 性能优化:针对高频访问的接口,可添加租户级别的缓存,减少数据库访问;SQL拦截器的正则表达式可优化,避免频繁替换SQL导致的性能损耗。
五、总结
本文围绕SpringBoot多租户系统的架构设计,从隔离方案选型、整体架构分层、各层核心实现,到关键问题解决方案、架构优化建议,全程结合实际落地经验,摒弃AI式套话和冗余理论,重点讲可复用、可落地的代码和思路。
核心总结:多租户架构设计无需过度复杂,「共享数据库、独立Schema」方案适合大多数SaaS平台,兼顾隔离性和运维成本;核心是通过「租户上下文+SQL拦截器+网关校验」,实现租户标识的透传和数据隔离,同时做好异步请求、跨服务调用、权限控制等细节,避免踩坑。
如果你的业务场景与本文类似,可直接复用文中的代码片段(已简化,可根据实际业务调整);如果有特殊场景(如海量租户、极高隔离要求),可根据本文的思路,调整隔离方案和实现细节。
最后,欢迎同行留言交流,分享你在多租户项目中踩过的坑和解决方案,共同进步!
![基于SpringBoot的企业资产借还与维修管理系统[源码免费+文档免费]-171主机测评](https://www.171host.com/wp-content/uploads/2026/08/20260825170029-6a8dca2dbb882-220x150.png)





