多租户架构
概念说明
多租户(Multi-Tenant)是一种软件架构模式,允许同一套系统为多个客户(租户)提供服务,各租户之间的数据相互隔离。
适用场景
- SaaS 平台
- 企业级应用
- 集团公司多子公司管理
架构模式
1. 独立数据库模式
每个租户使用独立的数据库。
租户 A → Database A
租户 B → Database B
租户 C → Database C优点:
- 数据隔离性最强
- 备份恢复简单
- 性能影响最小
缺点:
- 成本最高
- 维护复杂
- 资源浪费
2. 共享数据库 + 独立 Schema
所有租户共享一个数据库,每个租户使用独立的 Schema。
Database
├── tenant_a.users
├── tenant_a.orders
├── tenant_b.users
├── tenant_b.orders优点:
- 成本适中
- 一定程度的隔离
- 管理方便
缺点:
- 跨租户查询复杂
- Schema 迁移繁琐
3. 共享数据库 + 共享 Schema(本项目采用)
所有租户共享数据库和 Schema,通过 tenant_id 字段隔离。
users
├── id: 1, name: "张三", tenant_id: 100
├── id: 2, name: "李四", tenant_id: 100
├── id: 3, name: "王五", tenant_id: 200优点:
- 成本最低
- 开发简单
- 资源利用率高
缺点:
- 隔离性较弱
- 需要严格的数据权限控制
- 数据迁移需要谨慎
实现方案
数据库设计
所有需要租户隔离的表都添加 tenant_id 字段:
sql
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(50),
tenant_id BIGINT, -- 租户 ID
...
INDEX idx_tenant_id (tenant_id)
);后端实现
1. 租户上下文
java
public class TenantContext {
private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}
public static Long getTenantId() {
return TENANT_ID.get();
}
public static void clear() {
TENANT_ID.remove();
}
}2. 拦截器注入
java
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 从请求头获取租户 ID
String tenantId = request.getHeader("X-Tenant-Id");
if (StringUtils.hasText(tenantId)) {
TenantContext.setTenantId(Long.parseLong(tenantId));
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
TenantContext.clear();
}
}3. MyBatis 拦截器
java
@Intercepts({
@Signature(type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class})
})
public class TenantInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Long tenantId = TenantContext.getTenantId();
if (tenantId == null) {
return invocation.proceed();
}
// 动态添加 WHERE tenant_id = ?
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
BoundSql boundSql = ms.getBoundSql(parameter);
String sql = boundSql.getSql();
if (!sql.toLowerCase().contains("tenant_id")) {
sql = sql.replace("WHERE", "WHERE tenant_id = " + tenantId + " AND");
// 重新设置 SQL
// ...
}
return invocation.proceed();
}
}前端实现
1. 请求头携带租户 ID
typescript
// src/utils/request.ts
axiosInstance.interceptors.request.use(
config => {
const tenantId = localStorage.getItem('tenantId')
if (tenantId) {
config.headers['X-Tenant-Id'] = tenantId
}
return config
}
)2. 租户切换组件
vue
<template>
<el-select v-model="currentTenantId" @change="switchTenant">
<el-option
v-for="tenant in tenants"
:key="tenant.id"
:label="tenant.name"
:value="tenant.id"
/>
</el-select>
</template>
<script setup lang="ts">
const tenants = ref([
{ id: 100, name: '总公司' },
{ id: 200, name: '北京分公司' },
{ id: 300, name: '上海分公司' }
])
const currentTenantId = ref(100)
const switchTenant = (tenantId: number) => {
localStorage.setItem('tenantId', String(tenantId))
// 刷新页面重新加载数据
location.reload()
}
</script>数据隔离策略
强制隔离
以下数据必须隔离:
| 模块 | 说明 |
|---|---|
| 用户管理 | 不同租户的用户不能互相查看 |
| 角色权限 | 租户独立角色体系 |
| 菜单管理 | 租户独立菜单配置 |
| 部门管理 | 租户独立组织架构 |
共享数据
以下数据可以共享:
| 模块 | 说明 |
|---|---|
| 字典数据 | 全局字典,所有租户共用 |
| 系统配置 | 全局配置,所有租户共用 |
| 操作日志 | 按租户隔离存储,但可跨租户查询 |
权限控制
租户级权限
java
@PreAuthorize("hasPermission('sys:user:list') and hasTenant(#tenantId)")
public List<User> getUsers(Long tenantId) {
// ...
}数据权限
结合 RBAC + 租户隔离:
sql
SELECT * FROM sys_user
WHERE tenant_id = #{tenantId}
AND dept_id IN (
SELECT dept_id FROM sys_role_dept
WHERE role_id IN (
SELECT role_id FROM sys_user_role
WHERE user_id = #{userId}
)
)性能优化
1. 索引优化
sql
-- 所有租户字段建立索引
CREATE INDEX idx_tenant_id ON sys_user(tenant_id);
-- 复合索引(租户 + 查询条件)
CREATE INDEX idx_tenant_status ON sys_user(tenant_id, status);2. 缓存策略
java
// 缓存键包含租户 ID
String cacheKey = "user:" + tenantId + ":" + userId;3. 数据归档
定期归档历史数据:
sql
-- 归档超过 1 年的数据
INSERT INTO sys_user_archive
SELECT * FROM sys_user
WHERE tenant_id = ? AND create_time < DATE_SUB(NOW(), INTERVAL 1 YEAR);
DELETE FROM sys_user
WHERE tenant_id = ? AND create_time < DATE_SUB(NOW(), INTERVAL 1 YEAR);注意事项
- 忘记添加 tenant_id 过滤:最常见的安全问题
- 跨租户查询:禁止跨租户查询,除非明确授权
- 数据迁移:租户数据导出需要完整导出关联数据
- 并发问题:使用 ThreadLocal 存储租户 ID,避免线程安全问题
- 测试覆盖:所有接口都需要测试多租户场景
