在Spring Boot中实现多租户,核心是解决两个问题:如何识别请求来自哪个租户 ,以及如何根据租户信息隔离数据。
业界主要有三种数据隔离方案,你可以根据业务需求选择:
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 每个租户拥有独立的物理数据库实例。 | 数据隔离级别最高,安全性最好。 | 成本最高,维护多个数据库实例、连接池和备份策略复杂。 | 金融、医疗等对数据安全要求极高的行业,或大企业客户。 |
| 独立Schema | 共享同一个数据库实例,但为每个租户创建独立的Schema(数据库模式)。 | 隔离性较好,资源利用率比独立数据库高,运维成本相对较低。 | 需要管理多个Schema,跨Schema查询稍复杂。 | 中等规模、对数据隔离有较高要求的SaaS应用。 |
| 共享表(行级隔离) | 所有租户共享相同的数据库和表,通过tenant_id字段来区分数据。 |
成本最低,资源利用率最高,实现简单。 | 隔离性弱,开发中容易遗漏tenant_id导致数据泄露;数据量大时性能可能下降。 |
初创SaaS、租户数量多但单租户数据量小的场景,或产品的免费/基础版本。 |
你也可以根据租户等级,组合使用多种方案,实现"混合隔离"。
核心实现步骤
无论选择哪种方案,实现步骤都大同小异。下面以最常用的"共享表+tenant_id"方案为例,演示核心实现。
1. 识别租户:从请求中获取租户ID
这是多租户的入口。你需要定义一个机制来获取每个请求的租户身份。
- 常见方式 :从请求头(Header) (如
X-Tenant-ID)、JWT Token 中解析,或通过子域名 (如tenant1.yourdomain.com)识别。 - 代码实现 :编写一个
HandlerInterceptor,在请求处理前拦截,提取租户ID并保存到线程上下文中。
java
// 1. 定义租户上下文持有者,使用ThreadLocal保证线程隔离
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
// 2. 编写拦截器,从请求头中提取租户ID并存入上下文
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 从请求头获取租户ID
String tenantId = request.getHeader("X-Tenant-Id");
// 可加入判空和默认值逻辑
TenantContext.setTenantId(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 请求结束后清理上下文,防止内存泄漏
TenantContext.clear();
}
}
2. 隔离数据:根据租户ID过滤或路由
这是多租户的核心。根据你选择的方案,实现方式不同:
- 对于"共享表"方案 :使用 MyBatis-Plus 的多租户插件 ,自动为所有SQL添加
tenant_id = ?条件。
java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从 TenantContext 中获取当前租户ID
return new LongValue(TenantContext.getTenantId());
}
// ... 其他配置,如忽略某些表
}));
return interceptor;
}
}
- 对于"独立数据库/Schema"方案 :使用
AbstractRoutingDataSource实现动态数据源。它能在运行时根据租户ID,动态切换到对应的数据源。
java
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 从 TenantContext 中获取当前租户ID作为路由键
return TenantContext.getTenantId();
}
}
3. 进阶:多线程环境下的租户传递
在异步任务或多线程环境中,ThreadLocal 无法自动传递。你需要使用 TransmittableThreadLocal 或配置 TaskDecorator 来确保租户信息不丢失。
三种方案对比与适用场景
| 对比维度 | 方案一:独立数据库 | 方案二:独立Schema | 方案三:共享表 |
|---|---|---|---|
| 隔离级别 | 最高(物理隔离) | 较高(逻辑隔离) | 最低(逻辑/行级隔离) |
| 数据安全性 | 极高,租户数据物理隔离,互不影响 | 高,Schema级隔离,权限清晰 | 低 ,依赖代码规范,容易因漏写tenant_id导致数据泄露 |
| 性能隔离 | 最好,租户独享数据库资源,无"噪音邻居"问题 | 较好,但同实例下仍可能因资源争抢相互影响 | 最差,一个租户的复杂查询可能耗尽整个库的资源,拖垮所有租户 |
| 硬件/运维成本 | 最高,每个租户需独立实例、连接池和备份策略 | 中等,共享数据库实例,但需管理多个Schema | 最低,一套数据库即可服务所有租户 |
| 开发复杂度 | 中等,需要实现动态数据源路由 | 中等,同样需要动态数据源或Schema切换 | 最低,框架(如MyBatis-Plus)支持完善,易上手 |
| 租户定制能力 | 强,可为特定租户独立调整表结构、索引等 | 较强,可针对不同Schema进行定制 | 弱,所有租户共享一套表结构,难以定制 |
| 适用场景 | 金融、医疗等强合规行业 ;少量但体量大的大企业客户 | 中等规模SaaS;对数据隔离和性能有要求,但预算适中的场景 | 初创SaaS 、大量小型租户 ;产品免费/基础版,追求快速上线和低成本 |
决策框架
我们将通过思考下面四个关键问题,来逐步明确自己的选型方向。
-
数据安全和合规要求有多高?
- 如果业务涉及金融、医疗、政务 等强监管行业,要求数据必须物理隔离,那么方案一(独立数据库) 是唯一选择。
- 如果只是一般的商业数据,方案二(独立Schema) 的逻辑隔离通常已足够。
-
租户数量和规模是怎样的?
- 租户数量少(如几十个)但每个数据量大 :方案一(独立数据库) 更合适,能提供最佳性能和隔离性。
- 租户数量中等(如几百到上千) :方案二(独立Schema) 是性价比较高的选择。
- 租户数量巨大(数千至数万)且单个数据量小 :方案三(共享表) 成本最低,但需关注性能和隔离风险。
-
你的团队开发运维能力如何?
- 方案一(独立数据库) 运维最复杂,需管理大量实例、连接池和备份。
- 方案二(独立Schema) 运维中等,需处理Schema的创建、迁移和备份。
- 方案三(共享表) 开发和运维最简单,能快速上手。
-
未来业务如何变化?
- 架构设计要为未来留有余地。例如,可以先从方案三(共享表) 起步,当大客户出现时,为其单独升级为方案一(独立数据库)。
趋势与最佳实践:混合模式
如今,混合模式 已成为一种主流实践。它允许你根据不同租户的等级和需求,采用不同的数据隔离方案。
- 案例参考 :一个典型的SaaS平台可能会这样设计:
- 免费或基础版租户 :使用 方案三(共享表),以最低成本提供基础服务。
- 付费或高级版租户 :升级到 方案二(独立Schema),提供更好的性能和隔离性。
- 企业级大客户 :采用 方案一(独立数据库),满足其最高的安全与性能要求。
这种模式能让你用一套代码和架构,服务好不同层次的客户。
总结与建议
- 方案选择是第一步 :根据你的安全需求、预算和租户规模,在三种方案中做出权衡。对于大多数SaaS应用,从"共享表"方案开始是一个成本较低的选择。
- 租户识别和上下文管理是基础 :使用拦截器(
HandlerInterceptor)+ThreadLocal是管理租户上下文的经典组合。 - 善用框架能力 :MyBatis-Plus 的"多租户插件"和 Spring 的
AbstractRoutingDataSource可以大幅简化你的实现工作。 - 安全是生命线 :在"共享表"方案中,必须确保所有查询都带上租户ID过滤,这是最需要警惕的地方。
- 考虑未来扩展:设计时考虑未来可能从"共享表"迁移到"独立Schema"或"独立数据库"的成本,保持架构的灵活性。