Spring Boot 4 + Vue3 企业级多租户 SaaS:从共享 Schema 架构到商业化套餐设计

前言

多租户系统是 SaaS 产品的基石,但其复杂性往往不在于代码实现,而在于初始的模型设计。一个好的设计能让业务开发事半功倍,反之则后患无穷。本文基于 youlai-boot-tenantvue3-element-admin,从真实工程经验出发,为你拆解一套生产级的共享 Schema 多租户方案。

本文看点:

  • 模型选择:为什么共享 Schema 是 90% SaaS 产品的首选?
  • 核心设计:平台与租户的边界在哪里?如何设计用户角色?
  • 权限模型:如何用"三层漏斗模型"(套餐、租户、角色)实现灵活的菜单权限?
  • 后端实现 :如何用 TransmittableThreadLocal + MyBatis-Plus 插件实现业务代码无感知的租户隔离?
  • 生产实践:数据库索引如何设计?Nginx 关键配置是什么?

一、模式选择

在写代码之前,必须先选清楚多租户模式,否则后续所有设计都会反复推翻。

1.1 三种模式

模式 特点 适用场景
独立数据库 隔离最强,成本最高 金融、政务、大客户
共享 DB + 独立 Schema 隔离与成本折中 中大型 SaaS
共享 DB + 共享 Schema 成本最低、运维最简单 绝大多数 SaaS

1.2 方案选择

共享数据库 + 共享 Schema + tenant_id 行级隔离

这是 90% SaaS 系统第一阶段最稳妥、性价比最高的方案。


二、模型设计

2.1 平台租户价值

在实际 SaaS 系统中,平台管理员需要做这些工作:

  • 系统配置管理:管理菜单、权限、系统参数
  • 租户业务管理:查看各租户数据,协助排查问题
  • 业务实时监控:实时了解各租户业务运行状况

没有平台租户时的痛点

  1. 找租户要域名和账号密码
  2. 用租户账号登录系统
  3. 操作完再退出
  4. 整个过程繁琐且无法统一管理

有了平台租户(tenant_id=0)的优势

  • 在平台视角查看系统配置、租户列表
  • 一键切换到任意租户视角查看具体业务数据
  • 无需频繁切换账号,统一管理所有租户

2.2 设计方案

平台管理员
平台视角 tenant_id=0
切换租户
租户管理
系统配置
租户A tenant_id=1
租户B tenant_id=2
查看租户A业务数据
查看租户B业务数据

核心设计:平台也是一个特殊的租户(tenant_id=0)

java 复制代码
// 统一的数据访问逻辑,tenant_id=0 代表平台
String sql = "SELECT * FROM users WHERE tenant_id = ?";

// 应用场景:
// 1. 平台管理员查看平台配置:tenant_id = 0
// 2. 平台管理员切换到租户A查看数据:tenant_id = 1
// 3. 租户A员工查看自己数据:tenant_id = 1

2.3 用户角色设计

用户类型 tenant_id 可切换租户 数据访问范围 典型用途
平台超级管理员 0 平台数据 + 所有业务租户数据 系统配置、租户管理、问题排查
平台普通用户 0 仅平台数据 系统监控、工单处理
业务租户用户 >0 仅当前租户数据 租户自身业务操作

三、权限设计

多租户的权限设计,就像是为每个租户分配一个带锁的抽屉柜,既要保证每个租户只能打开自己的抽屉,又要能灵活地给不同级别的租户配置不同数量和类型的抽屉。本项目通过一个"三层过滤模型"来实现这一目标。

3.1 漏斗模型

一个用户最终能看到哪些菜单,取决于三层过滤的结果。我们可以把它想象成一个漏斗:
所有业务菜单
第一层过滤 套餐边界
第二层过滤 租户配置
第三层过滤 角色权限
用户最终可见菜单

说明: 套餐决定功能上限,租户决定实际启用范围,角色决定用户可见权限。

3.2 三层模型详解

  1. 第一层:套餐 (sys_tenant_plan_menu) - 权限的"天花板"

    • 作用 :平台方用来定义不同级别的服务(如"基础版"、"专业版"),圈定每个级别最多能使用哪些功能菜单。
    • 比喻:平台卖给租户一个柜子,套餐决定了这个柜子最多有几个抽屉(比如基础版5个,专业版20个)。租户无论如何都不能拥有超出这个数量的抽屉。
  2. 第二层:租户 (sys_tenant_menu) - 功能的"总开关"

    • 作用 :平台可以为单个租户做个性化调整,在套餐允许的范围内,选择性地开启或关闭某些菜单。
    • 比喻:租户虽然买了20个抽屉的专业版柜子,但平台可以先帮他锁上其中5个暂时用不到的。如果租户没有特殊要求,则默认20个抽屉全部开启。
  3. 第三层:角色 (sys_role_menu) - 权限的"钥匙"

    • 作用:租户内部的管理员,为自己的员工(不同角色)分配具体菜单的访问权限。
    • 比喻:柜子里开启了15个抽屉,租户管理员可以决定:A员工给5把钥匙(访问5个菜单),B员工给10把钥匙(访问10个菜单)。

总结 :一个用户最终能看到的菜单,是 套餐边界 ∩ 租户配置 ∩ 角色权限 的三者交集。

3.3 套餐示例

  • 基础套餐:包含核心的系统管理功能,例如:用户管理、角色管理、部门管理、字典管理等。
  • 高级套餐:在基础套餐之上,额外提供代码生成、AI 助手、功能演示等所有业务功能模块。

3.4 核心表结构

sql 复制代码
-- 菜单表:新增 scope 字段区分平台和业务菜单
CREATE TABLE `sys_menu` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `name` varchar(64) NOT NULL COMMENT '菜单名称',
  `scope` tinyint(1) NOT NULL DEFAULT 2 COMMENT '菜单范围(1=平台菜单 2=业务菜单)',
  -- ... 其他字段
  PRIMARY KEY (`id`)
);

-- 租户套餐表:定义不同的服务级别
CREATE TABLE `sys_tenant_plan` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '套餐ID',
  `name` varchar(100) NOT NULL COMMENT '套餐名称',
  `code` varchar(50) NOT NULL COMMENT '套餐编码',
  -- ... 其他字段
  PRIMARY KEY (`id`)
);

-- 套餐菜单关联表:定义套餐的菜单边界
CREATE TABLE `sys_tenant_plan_menu` (
  `plan_id` bigint NOT NULL COMMENT '套餐ID',
  `menu_id` bigint NOT NULL COMMENT '菜单ID',
  PRIMARY KEY (`plan_id`, `menu_id`)
);

-- 租户菜单关联表:租户个性化菜单配置
CREATE TABLE `sys_tenant_menu` (
  `tenant_id` bigint NOT NULL COMMENT '租户ID',
  `menu_id` bigint NOT NULL COMMENT '菜单ID',
  PRIMARY KEY (`tenant_id`, `menu_id`)
);

四、后端实现

理解了租户模型的设计原理,我们再来思考技术实现:

既然 tenant_id 如此重要,它如何在系统中安全、准确地传递?

4.1 请求链路

数据库 MyBatis Mapper 业务服务 租户上下文 租户过滤器 客户端 数据库 MyBatis Mapper 业务服务 租户上下文 租户过滤器 客户端 解析租户ID 1. 从Token解析 2. 从域名匹配 MyBatis-Plus拦截器 自动添加 tenant_id 条件 请求结束清理 HTTP请求(携带Token/域名) TenantContextHolder.setTenantId(id) userMapper.selectList(query) SELECT * FROM user WHERE tenant_id = ? 查询结果 返回数据 响应结果 TenantContextHolder.clear()

4.2 上下文方案

ThreadLocal 的问题

  • ❌ 线程池场景下丢失上下文
  • @Async 异步任务中失效
  • ❌ 多租户数据安全风险高

TransmittableThreadLocal 的优势

  • ✅ 解决父子线程上下文传递
  • ✅ 线程池和异步场景安全
  • ✅ 阿里开源,生产验证
java 复制代码
public class TenantContextHolder {
    // 使用 TransmittableThreadLocal 存储租户 ID 和忽略标志
    private static final TransmittableThreadLocal<Long> TENANT_ID_HOLDER = new TransmittableThreadLocal<>();
    private static final TransmittableThreadLocal<Boolean> IGNORE_TENANT_HOLDER = new TransmittableThreadLocal<>();

    // 设置/获取当前租户 ID
    public static void setTenantId(Long tenantId) { /* ... */ }
    public static Long getTenantId() { /* ... */ }

    // 设置/获取是否忽略租户过滤
    public static void setIgnoreTenant(boolean ignore) { /* ... */ }
    public static boolean isIgnoreTenant() { /* ... */ }

    // 清理上下文,防止内存泄漏
    public static void clear() { /* ... */ }
}

4.3 Mybatis-Plus 插件

该插件的核心是实现 TenantLineHandler 接口,告诉 MyBatis-Plus 如何获取租户 ID、租户字段名以及哪些表需要忽略。

MyTenantLineHandler.java

java 复制代码
// MyTenantLineHandler.java
@Component
public class MyTenantLineHandler implements TenantLineHandler {

    // 从上下文中获取租户 ID
    @Override
    public Expression getTenantId() { /* ... */ }

    // 获取租户字段名(如 "tenant_id")
    @Override
    public String getTenantIdColumn() { /* ... */ }

    // 判断是否忽略特定表的租户过滤
    @Override
    public boolean ignoreTable(String tableName) { /* ... */ }
}

MybatisConfig.java

然后,在 MyBatis-Plus 的主配置中注册这个处理器。

java 复制代码
// MybatisConfig.java
@Configuration
public class MybatisConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 1. 多租户插件(必须在最前面)
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(myTenantLineHandler));
        // 2. 其他插件(如数据权限、分页)
        // ...
        return interceptor;
    }
}

4.4 SQL 拦截效果

原始 SQL:

sql 复制代码
SELECT * FROM sys_user;

实际执行 SQL(tenant_id=1 时):

sql 复制代码
SELECT * FROM sys_user WHERE tenant_id = 1;

4.5 Filter 实现

java 复制代码
// TenantContextFilter.java
@Component
@Order(1)
public class TenantContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
        try {
            // 解析租户 ID 并设置到上下文
            Long tenantId = resolveTenantId(request);
            if (tenantId != null) {
                TenantContextHolder.setTenantId(tenantId);
            }
            chain.doFilter(request, response);
        } finally {
            // 清理上下文
            TenantContextHolder.clear();
        }
    }

    private Long resolveTenantId(HttpServletRequest request) {
        // 核心解析逻辑:
        // 1. 从已认证信息 SecurityContext 中获取
        // 2. 尝试从请求头 Token 中解析
        // 3. 尝试从域名中解析
        // ...
        return tenantId;
    }
}

4.6 菜单权限实现

4.6.1 获取当前用户路由(核心)

MenuServiceImpl 中的 listCurrentUserRoutes 方法是整个菜单权限的核心,它精确处理了平台管理员和租户用户的不同场景,并整合了三层漏斗模型的过滤逻辑。

java 复制代码
// MenuServiceImpl.java
@Override
public List<RouteVO> listCurrentUserRoutes() {
    try {
        // 核心逻辑步骤:
        // 1. 手动控制租户上下文,临时忽略租户插件
        TenantContextHolder.setIgnoreTenant(true);

        // 2. 获取套餐菜单边界 (漏斗第一层)
        Set<Long> planMenuIdSet = resolveTenantPlanMenuIdSet(targetTenantId);

        // 3. 根据用户角色获取菜单 (漏斗第二、三层)
        List<Menu> menuList = getMenusByRoles();

        // 4. 使用套餐边界过滤菜单
        List<Menu> filteredMenuList = menuList.stream()
                .filter(menu -> planMenuIdSet.contains(menu.getId()))
                .collect(Collectors.toList());

        // 5. 构建路由并返回
        return buildRoutes(filteredMenuList);
    } finally {
        // 恢复租户上下文
        TenantContextHolder.clear();
    }
}
4.6.2 套餐菜单边界解析

resolveTenantPlanMenuIdSet 方法体现了"套餐未配则授权所有业务菜单"的兜底策略,确保了系统的健壮性。

java 复制代码
// MenuServiceImpl.java
private Set<Long> resolveTenantPlanMenuIdSet(Long tenantId) {
    try {
        TenantContextHolder.setIgnoreTenant(true);
        // ... 省略查询租户套餐和套餐菜单的逻辑

        // 兜底逻辑:若套餐未配置菜单,则返回所有业务菜单
        List<Long> fallbackMenuIds = this.list(new LambdaQueryWrapper<Menu>()
                        .select(Menu::getId)
                        .eq(Menu::getScope, MenuScopeEnum.TENANT.getValue()))
                .stream()
                .map(Menu::getId)
                .collect(Collectors.toList());
        return new HashSet<>(fallbackMenuIds);
    } finally {
        // ... 恢复上下文
    }
}

五、前端实现:租户切换

前端不参与租户隔离逻辑,只负责给平台租户管理员一个切换入口

5.1 前端切换组件

在布局组件(如 LayoutToolbar.vue)中,我们使用一个简单的下拉菜单组件来展示租户列表,并绑定切换事件。

html 复制代码
<template>
  <!-- 仅当用户可切换且租户列表多于一个时显示 -->
  <TenantSwitcher v-if="showTenantSwitcher" @change="handleTenantChange" />
</template>

<script setup lang="ts">
import { useTenantStore } from "@/store/modules/tenant";

const tenantStore = useTenantStore();

// 处理切换事件
function handleTenantChange(tenantId: number) {
  tenantStore.switchTenant(tenantId).then(() => {
    // 切换成功后刷新页面以应用新权限
    window.location.reload();
  });
}
</script>

5.2 状态管理与切换逻辑 (Pinia Store)

前端的租户状态管理和切换逻辑都集中在 src/store/modules/tenant.ts 中。它负责加载租户列表、持久化当前租户状态、以及处理切换操作。

typescript 复制代码
// src/store/modules/tenant.ts
import { defineStore } from "pinia";
import TenantAPI from "@/api/system/tenant";
import type { TenantInfo } from "@/types/api";
import { STORAGE_KEYS } from "@/constants";
import AuthAPI from "@/api/auth";
import { AuthStorage } from "@/utils/auth";

export const useTenantStore = defineStore("tenant", () => {
  const currentTenantId = ref<number | null>(null);
  const currentTenant = ref<TenantInfo | null>(null);
  const tenantList = ref<TenantInfo[]>([]);

  // 加载租户信息(由路由守卫调用)
  async function loadTenant() {
    /* ... */
  }

  // 设置当前租户并持久化
  function setCurrentTenant(tenant: TenantInfo) {
    /* ... */
  }

  // 切换租户核心逻辑
  async function switchTenant(tenantId: number): Promise<void> {
    // 1. (平台用户) 尝试获取新 Token
    await refreshTokenIfSupported(tenantId);
    // 2. 调用后端 API 切换租户
    const tenantInfo = await TenantAPI.switchTenant(tenantId);
    // 3. 更新并持久化 state
    setCurrentTenant(tenantInfo);
  }

  return {
    /* ... */
  };
});

六、租户配置指南

理解了技术架构后,我们通过一个完整的操作流程,来演示如何从零开始创建一个新租户并为其配置服务。

注意 :本指南的操作涉及 youlai-boot-tenant (后端) 和 vue3-element-admin (前端) 两个项目。

6.1 核心流程

  1. 开启前端多租户模式

    在开始所有操作前,请确保前端项目已启用多租户功能。打开 vue3-element-admin 根目录下的 .env.development.env.production 文件,将 VITE_APP_TENANT_ENABLED 开关设置为 true

    bash 复制代码
    # .env.development / .env.production
    
    # 多租户开关(true:开启 false:关闭)
    VITE_APP_TENANT_ENABLED=true

创建套餐为套餐分配菜单创建租户并关联套餐(可选)为租户微调菜单配置Nginx

6.2 创建租户套餐

套餐是商业化服务的基础,它定义了不同级别租户的功能边界(即最多能拥有哪些菜单)。

  1. 导航 :登录平台账户,进入 平台管理租户套餐

  2. 操作 :点击 新增 按钮,填写套餐名称(如:"基础套餐"、"高级套餐")。

  3. 配置菜单 :在套餐列表中,找到刚创建的套餐,点击 分配菜单 。在弹出的对话框中,勾选此套餐应包含的所有功能菜单。这是该套餐功能的"天花板"。

6.3 新增租户

创建租户时,需要为其关联一个预先定义好的套餐,并指定一个唯一的域名用于身份识别。

  1. 导航 :进入 平台管理租户管理
  2. 操作 :点击 新增 按钮,填写租户信息。
    • 租户套餐:选择先前创建的套餐。
    • 域名 :填写一个唯一的域名(如 demo.youlai.tech)。此域名是后端识别租户身份的关键,必须与后续 Nginx 配置保持一致。

6.4 更换租户套餐

当租户需要升级或降级服务时,平台可以为其更换套餐。

  1. 导航 :在 租户管理 列表中,找到目标租户,点击操作列的 更换套餐 按钮。
  2. 操作 :在弹窗中选择新的套餐并确认。

6.5 (可选) 为租户定制菜单

在套餐的基础上,平台可以为特定租户进行菜单微调,以满足个性化需求。

  1. 导航 :在 租户管理 列表中,找到目标租户,点击操作列的 套餐功能配置 按钮。
  2. 操作 :在弹出的抽屉中,你可以关闭该租户不需要的功能。注意,这里的可调范围不能超过该租户所在套餐的边界。

6.6 配置 Nginx 域名解析

这是让租户通过独立域名访问系统的最后一步,也是最关键的一步。

  1. DNS 解析 :确保你在步骤 2 中为租户设置的域名(如 tenant-a.youlai.tech)已经通过 DNS 解析指向了你部署前端应用的 Nginx 服务器 IP 地址。

  2. Nginx 配置 :修改 Nginx 配置文件,确保 server_name 包含泛域名或指定的租户域名,并且 必须 将原始 Host 头透传给后端 API 服务。

    bash 复制代码
    server {
        listen 80;
        # 泛域名 *.youlai.tech 会匹配所有 youlai.tech 的子域名
        server_name vue.youlai.tech demo.youlai.tech *.youlai.tech;
    
        location /prod-api/ {
            proxy_set_header Host $host;  # 关键配置:将原始请求的域名(Host)透传给后端
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://YOUR_BACKEND_API_ADDRESS/;
        }
    
        # ... 其他前端静态资源配置
    }

警告 :如果 Nginx 未正确配置 proxy_set_header Host $host;,后端将无法通过域名识别租户身份,导致所有请求都 fallback 到平台或其他默认租户,造成数据隔离失效或访问错误。


七、在线演示

可以通过以下步骤,亲身体验并验证平台与租户的权限隔离效果:

1. 平台管理员视角验证 (vue.youlai.tech)

  • 登录 :使用账号 admin / 123456 登录平台。
  • 验证平台菜单 :检查左侧菜单栏,应包含 租户管理系统配置 等平台专属菜单。
  • 验证租户切换
    • 点击页面右上角的 平台主租户 下拉框。
    • 选择 演示租户 或其他业务租户进行切换。
    • 切换成功后,页面会自动刷新。
  • 验证数据隔离
    • 切换到 演示租户 租户后,进入 系统管理 -> 用户管理 页面。
    • 你将看到属于 演示租户 租户的用户列表,而不是平台或其他租户的用户。这证明了数据行级隔离生效。

2. 业务租户视角验证 (demo.youlai.tech)

  • 登录 :使用账号 admin / 123456 登录。
  • 验证菜单边界
    • 检查左侧菜单栏,不应包含 租户管理 等平台菜单。
    • 检查右上角,没有租户切换的下拉框。
  • 验证数据隔离
    • 进入 系统管理 -> 用户管理 页面。
    • 你只能看到当前租户(演示租户)的用户,无法看到平台或其他租户的数据。

八、结语

多租户架构不仅仅是技术实现,更是对业务模型的深刻理解。本文介绍的"共享 Schema + tenant_id 行级隔离"方案,经过了大量生产环境的验证,是 SaaS 系统起步阶段的最佳选择

希望本文能为你构建自己的多租户系统提供清晰的思路和可落地的参考。

附:源码

相关推荐
东东5162 小时前
xxx医患档案管理系统
java·spring boot·vue·毕业设计·智慧城市
东东5163 小时前
学院个人信息管理系统 (springboot+vue)
vue.js·spring boot·后端·个人开发·毕设
一个响当当的名号3 小时前
lectrue9 索引并发控制
java·开发语言·数据库
进阶小白猿3 小时前
Java技术八股学习Day30
java·开发语言·学习
三水不滴3 小时前
Redis缓存更新策略
数据库·经验分享·redis·笔记·后端·缓存
m0_748229993 小时前
Vue2 vs Vue3:核心差异全解析
前端·javascript·vue.js
hhy_smile4 小时前
Class in Python
java·前端·python
小邓吖4 小时前
自己做了一个工具网站
前端·分布式·后端·中间件·架构·golang
qq_12498707535 小时前
基于Srpingboot心晴疗愈社平台的设计与实现(源码+论文+部署+安装)
java·数据库·spring boot·spring·microsoft·毕业设计·计算机毕业设计