Spring Boot + MyBatis-Plus 多租户实战:从数据隔离到权限控制的完整方案

39 阅读13分钟

基于 Spring Boot 3.5 + MyBatis-Plus,从零搭建共享数据库的多租户系统,覆盖 SQL 自动过滤、登录租户识别、套餐菜单权限、缓存隔离四大核心环节。

一、什么是多租户?

多租户(Multi-Tenancy)是 SaaS(Software as a Service,软件即服务)系统的核心架构模式。一套应用实例服务多个租户(客户/组织),各租户的数据相互隔离,互不可见。

常见的多租户隔离方案有三种:

  • 方案一:独立数据库 每个租户一个库,隔离最强,成本最高
  • 方案二:共享库 + 独立 Schema 每个租户一个 Schema,折中方案
  • ✅ 方案三:共享库 + 行级隔离 所有租户共用一张表,通过 tenant_id 字段区分

本文采用 方案三(共享数据库 + 行级隔离),利用 MyBatis-Plus 的 TenantLineInnerInterceptor(租户行拦截器)在 SQL 层面自动追加租户条件,对业务代码零侵入

二、整体架构

多租户的核心链路如下:

  1. 用户登录(携带租户编码)
  2. LoginController 校验租户,设置 TenantContext
  3. UserDetailsService 加载用户(SQL 自动追加 tenant_id 条件)
  4. JWT 生成(携带 tenantId + packageId
  5. 后续请求经 JwtAuthenticationFilter 解析 token,设置 TenantContext
  6. MyBatis-Plus TenantLineInnerInterceptor 自动过滤数据
  7. 缓存层通过 TenantAwareCacheManager 按租户隔离

项目结构

lanjii-framework/
├── framework-context/       # 上下文模块
│   └── TenantContext.java          # 租户上下文(ThreadLocal)
├── framework-mp/            # MyBatis-Plus 增强模块
│   ├── config/
│   │   ├── TenantConfiguration.java       # 多租户配置(注册拦截器)
│   │   ├── InterceptorConfiguration.java  # 拦截器组装
│   │   └── MetaObjectHandlerConfiguration.java  # 自动填充 tenant_id
│   ├── tenant/
│   │   ├── TenantHandler.java             # 租户行处理器
│   │   └── TenantProperties.java          # 配置属性
│   └── base/
│       └── TenantBaseEntity.java          # 租户实体基类
├── framework-security/      # 安全模块
│   ├── filter/JwtAuthenticationFilter.java  # JWT 过滤器(设置租户上下文)
│   ├── util/JwtUtils.java                   # JWT 工具(读写 tenantId)
│   └── model/AuthUser.java                  # 认证用户(含 tenantId)
└── framework-cache/         # 缓存模块
    ├── config/TenantAwareCaffeineCacheManager.java  # 本地缓存租户隔离
    └── config/RedisCacheConfiguration.java          # Redis 缓存租户隔离
​
lanjii-modules/module-tenant/
├── tenant-api/              # 对外接口
│   └── TenantApi.java
└── tenant-biz/              # 业务实现
    ├── entity/SysTenant.java          # 租户实体
    ├── entity/SysTenantPackage.java   # 套餐实体
    ├── service/TenantService.java     # 租户服务
    ├── service/TenantPackageService.java  # 套餐服务
    └── controller/TenantController.java   # 租户管理API

三、数据库设计

3.1 租户表 sys_tenant

CREATE TABLE sys_tenant (
  id           bigint       NOT NULL AUTO_INCREMENT COMMENT '租户ID',
  tenant_code  varchar(50)  NOT NULL COMMENT '租户编码(唯一标识,用于登录)',
  tenant_name  varchar(100) NOT NULL COMMENT '租户名称',
  package_id   bigint       NULL     COMMENT '套餐ID(关联 sys_tenant_package)',
  contact_name varchar(50)  NULL     COMMENT '联系人',
  contact_phone varchar(20) NULL     COMMENT '联系电话',
  status       tinyint      NOT NULL DEFAULT 1 COMMENT '状态(1-正常,0-停用)',
  expire_time  datetime     NULL     COMMENT '过期时间(NULL 表示永不过期)',
  create_time  datetime     NULL DEFAULT CURRENT_TIMESTAMP,
  update_time  datetime     NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  create_by    varchar(50)  NULL,
  update_by    varchar(50)  NULL,
  deleted      tinyint      NOT NULL DEFAULT 0,
  PRIMARY KEY (id),
  UNIQUE INDEX uk_tenant_code (tenant_code)
) COMMENT = '租户表';

字段说明:

  • tenant_code:租户唯一编码,用户登录时输入此编码标识所属租户,不填则以平台管理员身份登录
  • package_id:关联的套餐,决定该租户可使用哪些菜单和功能
  • status:用于平台管理员停用/启用租户,停用后该租户下所有用户无法登录
  • expire_time:租户到期时间,到期后同样无法登录,设为 NULL 表示永久有效

3.2 套餐表 sys_tenant_package

CREATE TABLE sys_tenant_package (
  id           bigint      NOT NULL AUTO_INCREMENT COMMENT '套餐ID',
  package_name varchar(50) NOT NULL COMMENT '套餐名称',
  menu_ids     text        NULL     COMMENT '关联的菜单ID(逗号分隔)',
  status       tinyint     NOT NULL DEFAULT 1 COMMENT '状态(1-正常,0-停用)',
  remark       varchar(500) NULL    COMMENT '备注',
  create_time  datetime    NULL DEFAULT CURRENT_TIMESTAMP,
  update_time  datetime    NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted      tinyint     NOT NULL DEFAULT 0,
  PRIMARY KEY (id)
) COMMENT = '租户套餐表';

字段说明:

  • menu_ids:逗号分隔的菜单 ID 列表,如 "1,3,49,50,52"。平台管理员在创建套餐时勾选菜单,系统自动拼接存储
  • 套餐是功能权限的边界:租户管理员只能在套餐范围内分配角色权限

3.3 业务表的 tenant_id 字段

⚠️ 约定:tenant_id = 0 表示平台管理员的数据,平台管理员可以管理所有租户。

所有需要租户隔离的表都添加 tenant_id 字段:

-- 用户表
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户ID(0-平台,>0-租户)'-- 角色表、岗位表、部门表、操作日志表... 同理

四、核心实现

4.1 租户上下文:TenantContext

使用 ThreadLocal 在请求线程内传递当前租户 ID:

public final class TenantContext {
​
    private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
​
    private TenantContext() {
    }
​
    /** 设置租户ID */
    public static void setTenantId(Long tenantId) {
        TENANT_ID.set(tenantId);
    }
​
    /** 获取租户ID */
    public static Long getTenantId() {
        return TENANT_ID.get();
    }
​
    /** 清除上下文(必须在 finally 中调用,防止线程复用导致数据串扰) */
    public static void clear() {
        TENANT_ID.remove();
    }
}

核心原理: 每个 HTTP 请求由一个线程处理,ThreadLocal 让我们在请求入口(Filter/Controller)设置 tenantId,后续所有代码(Service、Dao、SQL 拦截器)都能通过 TenantContext.getTenantId() 获取,无需层层传参。

⚠️ 务必在 finally 中调用 ​TenantContext.clear()。Tomcat 使用线程池,如果不清理,下一个请求可能复用到上一个请求的 tenantId,造成数据泄漏。

4.2 MyBatis-Plus 租户拦截器

4.2.1 配置属性

通过 application.yml 配置多租户行为:

lanjii:
  tenant:
    # 是否启用多租户
    enabled: true
    # 租户字段名
    column: tenant_id
    # 忽略租户过滤的表(全局共享数据,不按租户隔离)
    ignore-tables:
      - sys_tenant           # 租户表本身
      - sys_tenant_package   # 套餐表
      - sys_menu             # 菜单表(所有租户共享菜单定义)
      - sys_dict_type        # 字典类型
      - sys_dict_data        # 字典数据
      - sys_config           # 系统配置

对应 Java 配置类:

@Data
@ConfigurationProperties(prefix = "lanjii.tenant")
public class TenantProperties {
    /** 是否启用多租户 */
    private boolean enabled = false;
    /** 租户字段名 */
    private String column = "tenant_id";
    /** 忽略租户过滤的表 */
    private List<String> ignoreTables = new ArrayList<>();
}

字段说明:

  • enabled:总开关,设为 false 可完全关闭多租户功能,适用于单租户部署场景
  • column:数据库中租户字段的列名,默认 tenant_id
  • ignore-tables:这些表的 SQL 不会追加 tenant_id 条件。比如 sys_menu(菜单定义)是全局共享的,所有租户看到的是同一份菜单

4.2.2 租户行处理器 TenantHandler

@RequiredArgsConstructor
public class TenantHandler implements TenantLineHandler {
​
    /** 平台管理员租户ID */
    public static final Long PLATFORM_TENANT_ID = 0L;
​
    private final TenantProperties tenantProperties;
​
    @Override
    public Expression getTenantId() {
        // 从 ThreadLocal 获取当前租户ID
        Long tenantId = TenantContext.getTenantId();
        if (tenantId == null) {
            return new NullValue();
        }
        return new LongValue(tenantId);
    }
​
    @Override
    public String getTenantIdColumn() {
        // 返回配置的租户字段名
        return tenantProperties.getColumn();
    }
​
    @Override
    public boolean ignoreTable(String tableName) {
        // 判断当前表是否跳过租户过滤
        return tenantProperties.getIgnoreTables().stream()
                .anyMatch(t -> t.equalsIgnoreCase(tableName));
    }
}

工作原理: MyBatis-Plus 在执行 SQL 前会调用 TenantHandler 的方法:

  • getTenantId():返回当前租户 ID,拦截器将其拼入 SQL 的 WHERE 条件
  • getTenantIdColumn():告诉拦截器字段名是什么
  • ignoreTable():返回 true 则跳过该表

例如,一条简单的查询:

-- 原始 SQL
SELECT * FROM sys_user WHERE username = 'admin'-- 经过拦截器后(假设当前 tenantId = 1)
SELECT * FROM sys_user WHERE username = 'admin' AND tenant_id = 1

4.2.3 注册拦截器

@Configuration
@EnableConfigurationProperties(TenantProperties.class)
public class TenantConfiguration {
​
    @Bean
    @ConditionalOnProperty(prefix = "lanjii.tenant", name = "enabled", havingValue = "true")
    public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties tenantProperties) {
        TenantHandler tenantHandler = new TenantHandler(tenantProperties);
        return new TenantLineInnerInterceptor(tenantHandler);
    }
}

代码说明:

  • @ConditionalOnProperty:只有配置 lanjii.tenant.enabled=true 时才创建 Bean,保证开关灵活
  • 创建的 TenantLineInnerInterceptor 会被注入到 MybatisPlusInterceptor

拦截器组装(注意顺序):

@Configuration
public class InterceptorConfiguration {
​
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(
            ObjectProvider<TenantLineInnerInterceptor> tenantInterceptorProvider,
            BlockAttackInnerInterceptor blockAttack) {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 多租户插件(必须在第一个位置)
        tenantInterceptorProvider.ifAvailable(interceptor::addInnerInterceptor);
        // 防止全表更新与删除插件
        interceptor.addInnerInterceptor(blockAttack);
        return interceptor;
    }
}

⚠️ 多租户拦截器必须放在所有拦截器的最前面,确保 SQL 先被加上租户条件,再进行其他处理。

4.3 自动填充 tenant_id

新增数据时自动填充 tenant_id,业务代码无需手动 setTenantId()

租户基类

@Data
@EqualsAndHashCode(callSuper = true)
public class TenantBaseEntity extends BaseEntity {
​
    /** 租户ID(INSERT 时自动填充) */
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
}

MetaObjectHandler 配置

@Bean
public MetaObjectHandler metaObjectHandler(TenantProperties tenantProperties) {
    return new MetaObjectHandler() {
        @Override
        public void insertFill(MetaObject metaObject) {
            this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
            this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
​
            // 自动填充租户ID
            if (tenantProperties.isEnabled()) {
                Long tenantId = TenantContext.getTenantId();
                this.strictInsertFill(metaObject, "tenantId", Long.class,
                        tenantId != null ? tenantId : 0L);
            }
        }
​
        @Override
        public void updateFill(MetaObject metaObject) {
            this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
        }
    };
}

流程: save() 调用 MyBatis-Plus 触发 insertFill,从 TenantContext 获取 tenantId 写入,最终 SQL 执行

五、登录与认证集成

5.1 登录流程

登录时需要识别用户属于哪个租户。前端登录表单包含一个可选的 租户编码 字段:

@Data
public class LoginBody {
    /** 租户编码(不填则以平台管理员身份登录) */
    private String tenantCode;
​
    @NotEmpty(message = "用户名不能为空")
    private String username;
​
    @NotEmpty(message = "密码不能为空")
    private String password;
​
}

登录接口核心逻辑:

@PostMapping("/login")
public R<LoginInfo> login(@Validated @RequestBody LoginBody loginBody) {
​
    //  校验租户并确定 tenantId
    Long tenantId = TenantHandler.PLATFORM_TENANT_ID; // 默认为平台(0)
    String tenantCode = loginBody.getTenantCode();
​
    if (StringUtils.hasText(tenantCode)) {
        SysTenantVO tenant = tenantApi.getTenantByCode(tenantCode);
        if (tenant == null) {
            throw new BizException(ResultCode.BAD_REQUEST, "租户不存在");
        }
        if (tenant.getStatus() != 1) {
            throw new BizException(ResultCode.BAD_REQUEST, "租户已被禁用");
        }
        tenantId = tenant.getId();
    }
​
    // 设置租户上下文(后续 UserDetailsService 查用户时会自动过滤 tenant_id)
    TenantContext.setTenantId(tenantId);
    try {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginBody.getUsername(), loginBody.getPassword()));
​
        AuthUser userDetails = (AuthUser) authentication.getPrincipal();
        LoginInfo loginInfo = loginService.generateLoginInfo(userDetails);
        return R.success(loginInfo);
    } finally {
        TenantContext.clear(); 
    }
}

关键点:

  1. 不传 tenantCode,则 tenantId = 0,以平台管理员身份登录
  2. 传了 tenantCode,查询 sys_tenant 获取 tenantId,设置到 TenantContext
  3. 调用 authenticationManager.authenticate() 时,内部会走到 UserDetailsService,此时 SQL 已自动追加 tenant_id 条件,确保只查到该租户的用户

5.2 JWT 携带租户信息

登录成功后,将 tenantIdpackageId 写入 JWT Token:

public String generateToken(String username, Long tenantId, Long packageId) {
    var builder = Jwts.builder()
            .subject(username)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + getExpiration()));
    if (tenantId != null) {
        builder.claim("tenantId", tenantId);
    }
    if (packageId != null) {
        builder.claim("packageId", packageId);
    }
    return builder.signWith(Keys.hmacShaKeyFor(getSecret().getBytes())).compact();
}

代码说明:

  • tenantIdpackageId 以自定义 claim 写入 Token
  • 后续每次请求,前端携带此 Token,后端解析出租户信息

5.3 JWT 过滤器恢复租户上下文

每个请求到达时,JwtAuthenticationFilter 从 Token 中解析 tenantId 并设置到 TenantContext

@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain) throws ServletException, IOException {
    String token = ServletUtils.getBearerToken();
​
    if (token != null && tokenService.validate(token)) {
        String username = jwtUtils.getUsernameFromToken(token);
        try {
            // 从 JWT 中提取 tenantId 并设置到 ThreadLocal
            Long tenantId = jwtUtils.getTenantIdFromToken(token);
            TenantContext.setTenantId(tenantId);
​
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (Exception e) {
            SecurityContextHolder.clearContext();
            TenantContext.clear();
        }
    }
    filterChain.doFilter(request, response);
}

这样,后续所有 Service、Dao 的 SQL 都会自动带上正确的 ​tenant_id 条件。

5.4 UserDetailsService 中的租户感知

加载用户详情时,同时获取租户的套餐信息:

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Long tenantId = TenantContext.getTenantId();
​
    // 获取套餐ID(非平台租户需要关联套餐)
    Long packageId = null;
    if (tenantId != null && tenantId > 0) {
        SysTenantVO tenant = tenantApi.getById(tenantId);
        if (tenant != null) {
            packageId = tenant.getPackageId();
        }
    }
​
    // 查询用户(SQL 自动追加 tenant_id 条件)
    SysUser user = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
            .eq(SysUser::getUsername, username));
    if (user == null) {
        throw new BadCredentialsException("用户名或密码错误");
    }
​
    // 获取用户权限(考虑套餐范围)
    List<String> permissions = sysMenuService.getUserPermissions(
            user.getId(), user.getIsAdmin(), tenantId, packageId);
​
    // 构建认证用户对象
    return new AuthUser(
            user.getId(), user.getUsername(), user.getPassword(),
            user.getIsAdmin(), tenantId, packageId,
            authorities, user.getIsEnabled().equals(IsEnabledEnum.ENABLED.getCode()));
}

六、套餐权限控制

套餐是控制租户功能范围的核心机制。

6.1 套餐如何限制菜单

当获取用户菜单树时,会根据平台/租户身份走不同的逻辑:

@Override
public List<SysMenuVO> getUserMenuTree(Long userId, Integer isAdmin,
                                        Long tenantId, Long packageId) {
    boolean isPlatformTenant = tenantId == null || tenantId == 0;
​
    List<SysMenu> menuList;
​
    if (isPlatformTenant) {
        // 平台管理员:看到所有菜单
        if (IsAdminEnum.isAdmin(isAdmin)) {
            menuList = baseMapper.selectList(new LambdaQueryWrapper<SysMenu>()
                    .ne(SysMenu::getType, 3)  // 排除按钮类型
                    .eq(SysMenu::getIsEnabled, 1));
        } else {
            menuList = baseMapper.selectMenusByUserId(userId);
        }
    } else {
        // 租户用户:先获取套餐允许的菜单ID
        List<Long> packageMenuIds = tenantApi.getMenuIdsByPackageId(packageId);
        if (packageMenuIds.isEmpty()) {
            return Collections.emptyList();
        }
        if (IsAdminEnum.isAdmin(isAdmin)) {
            // 租户管理员:只看套餐范围内的菜单
            menuList = baseMapper.selectList(new LambdaQueryWrapper<SysMenu>()
                    .in(SysMenu::getId, packageMenuIds)
                    .ne(SysMenu::getType, 3)
                    .eq(SysMenu::getIsEnabled, 1));
        } else {
            // 租户普通用户:角色权限与套餐范围取交集
            menuList = baseMapper.selectMenusByUserId(userId);
            Set<Long> packageMenuIdSet = new HashSet<>(packageMenuIds);
            menuList = menuList.stream()
                    .filter(menu -> packageMenuIdSet.contains(menu.getId()))
                    .collect(Collectors.toList());
        }
    }
​
    return TreeUtils.buildTree(SysMenu.INSTANCE.toVo(menuList));
}

权限控制层次:

  • 平台管理员 ── 所有菜单
  • 租户管理员 ── 套餐范围内的所有菜单
  • 租户普通用户 ── 角色权限 和 套餐范围 交集

6.2 套餐中菜单 ID 的存取

套餐的 menu_ids 以逗号分隔的字符串存储,解析逻辑如下:

@Override
public List<Long> getMenuIdsByPackageId(Long packageId) {
    if (packageId == null) {
        return Collections.emptyList();
    }
    SysTenantPackage pkg = this.getById(packageId);
    if (pkg == null || pkg.getMenuIds() == null || pkg.getMenuIds().isEmpty()) {
        return Collections.emptyList();
    }
    return Arrays.stream(pkg.getMenuIds().split(","))
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .map(Long::parseLong)
            .collect(Collectors.toList());
}

七、租户管理

7.1 创建租户

创建租户时需要同时初始化默认部门和管理员用户:

@Override
@Transactional(rollbackFor = Exception.class)
public void saveNew(SysTenantDTO dto) {
    // 校验租户编码唯一性
    SysTenant exists = this.getByTenantCode(dto.getTenantCode());
    if (exists != null) {
        throw new BizException("租户编码已存在");
    }
​
    SysTenant tenant = new SysTenant();
    BeanUtils.copyProperties(dto, tenant);
    this.save(tenant);
​
    // 调用系统模块API创建租户默认管理员
    systemApi.createTenantAdmin(tenant.getId(), tenant.getTenantCode());
}

createTenantAdmin 的实现:

@Override
@Transactional(rollbackFor = Exception.class)
public void createTenantAdmin(Long tenantId, String tenantCode) {
    Long previousTenantId = TenantContext.getTenantId();
    try {
        // 临时切换到新租户上下文
        TenantContext.setTenantId(tenantId);
​
        // 创建默认部门
        SysDept dept = new SysDept();
        dept.setTenantId(tenantId);
        dept.setParentId(0L);
        dept.setAncestors("0");
        dept.setDeptName(tenantCode + "总部");
        dept.setSortOrder(1);
        dept.setIsEnabled(1);
        dept.setLeader("admin");
        sysDeptService.save(dept);
​
        // 创建管理员用户
        String defaultPwd = sysConfigService.getConfigValue(SysConfigKeys.DEFAULT_USER_PWD);
        SysUser admin = new SysUser();
        admin.setTenantId(tenantId);
        admin.setUsername("admin");
        admin.setPassword(passwordEncoder.encode(defaultPwd));
        admin.setNickname(tenantCode + "-管理员");
        admin.setIsEnabled(1);
        admin.setIsAdmin(1);
        admin.setDeptId(dept.getId());
        sysUserService.save(admin);
    } finally {
        // 恢复原租户上下文
        TenantContext.setTenantId(previousTenantId);
    }
}

⚠️ 临时切换 ​TenantContext:创建租户数据时需要将上下文切到新租户,否则数据会被写入平台管理员的 tenant_id=0 下。操作完成后必须恢复原上下文。

7.2 校验租户有效性

登录前校验租户状态和过期时间:

@Override
public boolean checkTenantValid(Long tenantId) {
    SysTenant tenant = this.getById(tenantId);
    if (tenant == null) {
        return false;
    }
    // 检查状态
    if (tenant.getStatus() != 1) {
        return false;
    }
    // 检查过期时间
    if (tenant.getExpireTime() != null
            && tenant.getExpireTime().isBefore(LocalDateTime.now())) {
        return false;
    }
    return true;
}

7.3 RESTful API

租户管理接口支持标准 CRUD,使用 Spring Security @PreAuthorize 控制权限:

@RestController
@RequestMapping("/admin/tenant")
@RequiredArgsConstructor
public class TenantController {
​
    private final TenantService tenantService;
​
    @PreAuthorize("hasAuthority('tenant:list')")
    @GetMapping
    public R<List<SysTenantVO>> list(SysTenantDTO dto) {
        return R.success(tenantService.listTenants(dto));
    }
​
    @PreAuthorize("hasAuthority('tenant:add')")
    @PostMapping
    public R<Void> add(@Valid @RequestBody SysTenantDTO dto) {
        tenantService.saveNew(dto);
        return R.success();
    }
​
    @PreAuthorize("hasAuthority('tenant:edit')")
    @PutMapping("/{id}")
    public R<Void> update(@PathVariable Long id, @Valid @RequestBody SysTenantDTO dto) {
        tenantService.updateByIdNew(id, dto);
        return R.success();
    }
​
    @PreAuthorize("hasAuthority('tenant:remove')")
    @DeleteMapping("/{id}")
    public R<Void> remove(@PathVariable Long id) {
        tenantService.removeById(id);
        return R.success();
    }
}

八、缓存租户隔离

缓存层也需要按租户隔离,否则不同租户会读到对方的缓存数据。

8.1 本地缓存(Caffeine)

通过装饰器模式给所有缓存 Key 加上租户前缀:

public class TenantAwareCaffeineCacheManager extends CaffeineCacheManager {
​
    @Override
    public Cache getCache(String name) {
        Cache delegate = super.getCache(name);
        // ...// 检查缓存定义是否需要租户隔离
        CacheDef cacheDef = cacheRegistry.get(name).orElse(null);
        boolean tenantIsolated = cacheDef.isTenantIsolated();
​
        if (tenantIsolated) {
            return new TenantAwareCache(delegate); // 装饰器
        }
        return delegate;
    }
​
    /** 租户感知的 Cache 装饰器 */
    static class TenantAwareCache implements Cache {
        private final Cache delegate;
​
        /** 对所有 Key 自动添加租户前缀 */
        private Object createTenantKey(Object key) {
            Long tenantId = TenantContext.getTenantId();
            String prefix = (tenantId != null) ? tenantId.toString() : "0";
            return prefix + ":" + key;
        }
​
        @Override
        public ValueWrapper get(Object key) {
            return delegate.get(createTenantKey(key));
        }
​
        @Override
        public void put(Object key, Object value) {
            delegate.put(createTenantKey(key), value);
        }
​
        // ... 
    }
}

工作原理: 假设缓存 key 为 "admin",租户 1 存的实际 key 是 "1:admin",租户 2 是 "2:admin",自然隔离。

8.2 缓存定义中的隔离开关

通过 CacheDeftenantIsolated 属性控制每个缓存是否需要隔离:

public class CacheDef {
    private final String name;
    private final Duration ttl;
    private final long maxSize;
    /** 是否按租户隔离(默认true) */
    private final boolean tenantIsolated;
​
    // 默认创建时 tenantIsolated = true
    public static CacheDef of(String name, Duration ttl) {
        return new CacheDef(name, ttl, 1000L, true);
    }
​
    // 指定是否隔离
    public static CacheDef of(String name, Duration ttl, boolean tenantIsolated) {
        return new CacheDef(name, ttl, 1000L, tenantIsolated);
    }
}

使用示例: 大部分缓存默认隔离。对于验证码等全局缓存,可以设为 tenantIsolated = false

九、注意事项

9.1 忽略表配置

以下表不应加入租户过滤:

  • sys_tenant:租户表本身不属于任何租户
  • sys_tenant_package:套餐是全局管理的
  • sys_menu:菜单定义全局共享,通过套餐控制可见范围
  • sys_dict_type / ​sys_dict_data:字典数据全局共享

9.2 跨租户操作

平台管理员管理租户时需要临时切换上下文:

Long previousTenantId = TenantContext.getTenantId();
try {
    TenantContext.setTenantId(targetTenantId);
    // 执行操作...
} finally {
    TenantContext.setTenantId(previousTenantId); // 恢复
}

9.3 异步任务

如果使用 @Async 或线程池,子线程不会继承父线程的 ThreadLocal,需要手动传递:

Long tenantId = TenantContext.getTenantId();
executor.submit(() -> {
    TenantContext.setTenantId(tenantId);
    try {
        // 执行异步任务...
    } finally {
        TenantContext.clear();
    }
});

⚠️ 或者使用 TransmittableThreadLocal(阿里巴巴开源)替代 ThreadLocal,可自动透传到线程池。

9.4 数据库索引

所有 tenant_id 字段建议添加索引,避免全表扫描:

CREATE INDEX idx_tenant_id ON sys_user(tenant_id);
CREATE INDEX idx_tenant_id ON sys_role(tenant_id);
-- 所有含 tenant_id 的表均需添加

十、总结

本文实现了一个完整的多租户方案,核心要点回顾:

环节实现方式
数据隔离MyBatis-Plus TenantLineInnerInterceptor 自动追加 SQL 条件
上下文传递ThreadLocal+TenantContext
登录识别前端传 tenantCode,后端查表获取 tenantId
Token 携带JWT 自定义 claim 存储 tenantId+packageId
请求还原JwtAuthenticationFilter 解析 Token 设置上下文
权限控制套餐菜单与角色权限交集
缓存隔离Key 前缀 tenantId:cacheName::key
自动填充MetaObjectHandler 在 INSERT 时填充 tenant_id

这套方案对业务代码几乎零侵入,只需要让实体类继承 TenantBaseEntity,配置好忽略表列表,就可以透明地支持多租户。

源码与在线体验

完整源码gitee.com/leven2018/l…

欢迎 Star ⭐ 和 Fork,项目包含本文涉及的所有代码(MCP 集成、多模型动态切换、RAG 知识库等)。

在线体验http://106.54.167.194/admin/index