Skip to content

多租户架构

概念说明

多租户(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);

注意事项

  1. 忘记添加 tenant_id 过滤:最常见的安全问题
  2. 跨租户查询:禁止跨租户查询,除非明确授权
  3. 数据迁移:租户数据导出需要完整导出关联数据
  4. 并发问题:使用 ThreadLocal 存储租户 ID,避免线程安全问题
  5. 测试覆盖:所有接口都需要测试多租户场景

相关链接

基于 MIT 许可发布 · 由 ❤️ 和 ☕ 驱动 · 支持作者