基于 Spring Boot 3.5 + MyBatis-Plus,从零搭建共享数据库的多租户系统,覆盖 SQL 自动过滤、登录租户识别、套餐菜单权限、缓存隔离四大核心环节。
一、什么是多租户?
多租户(Multi-Tenancy)是 SaaS(Software as a Service,软件即服务)系统的核心架构模式。一套应用实例服务多个租户(客户/组织),各租户的数据相互隔离,互不可见。
常见的多租户隔离方案有三种:
- 方案一:独立数据库 每个租户一个库,隔离最强,成本最高
- 方案二:共享库 + 独立 Schema 每个租户一个 Schema,折中方案
- ✅ 方案三:共享库 + 行级隔离 所有租户共用一张表,通过 tenant_id 字段区分
本文采用 方案三(共享数据库 + 行级隔离) ,利用 MyBatis-Plus 的 TenantLineInnerInterceptor(租户行拦截器)在 SQL 层面自动追加租户条件,对业务代码零侵入。
二、整体架构
多租户的核心链路如下:
- 用户登录(携带租户编码)
LoginController校验租户,设置TenantContextUserDetailsService加载用户(SQL 自动追加tenant_id条件)- JWT 生成(携带
tenantId+packageId) - 后续请求经
JwtAuthenticationFilter解析 token,设置TenantContext - MyBatis-Plus
TenantLineInnerInterceptor自动过滤数据 - 缓存层通过
TenantAwareCacheManager按租户隔离
项目结构
bash
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
sql
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
sql
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 字段:
sql
-- 用户表
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户ID(0-平台,>0-租户)'
-- 角色表、岗位表、部门表、操作日志表... 同理
四、核心实现
4.1 租户上下文:TenantContext
使用 ThreadLocal 在请求线程内传递当前租户 ID:
csharp
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 配置多租户行为:
yaml
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 配置类:
swift
@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_idignore-tables:这些表的 SQL 不会追加tenant_id条件。比如sys_menu(菜单定义)是全局共享的,所有租户看到的是同一份菜单
4.2.2 租户行处理器 TenantHandler
typescript
@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
-- 原始 SQL
SELECT * FROM sys_user WHERE username = 'admin'
-- 经过拦截器后(假设当前 tenantId = 1)
SELECT * FROM sys_user WHERE username = 'admin' AND tenant_id = 1
4.2.3 注册拦截器
less
@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中
拦截器组装(注意顺序):
java
@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():
租户基类
scala
@Data
@EqualsAndHashCode(callSuper = true)
public class TenantBaseEntity extends BaseEntity {
/** 租户ID(INSERT 时自动填充) */
@TableField(fill = FieldFill.INSERT)
private Long tenantId;
}
MetaObjectHandler 配置
kotlin
@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 登录流程
登录时需要识别用户属于哪个租户。前端登录表单包含一个可选的 租户编码 字段:
kotlin
@Data
public class LoginBody {
/** 租户编码(不填则以平台管理员身份登录) */
private String tenantCode;
@NotEmpty(message = "用户名不能为空")
private String username;
@NotEmpty(message = "密码不能为空")
private String password;
}
登录接口核心逻辑:
less
@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();
}
}
关键点:
- 不传
tenantCode,则tenantId = 0,以平台管理员身份登录 - 传了
tenantCode,查询sys_tenant获取tenantId,设置到TenantContext - 调用
authenticationManager.authenticate()时,内部会走到UserDetailsService,此时 SQL 已自动追加tenant_id条件,确保只查到该租户的用户
5.2 JWT 携带租户信息
登录成功后,将 tenantId 和 packageId 写入 JWT Token:
scss
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();
}
代码说明:
tenantId和packageId以自定义 claim 写入 Token- 后续每次请求,前端携带此 Token,后端解析出租户信息
5.3 JWT 过滤器恢复租户上下文
每个请求到达时,JwtAuthenticationFilter 从 Token 中解析 tenantId 并设置到 TenantContext:
ini
@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 中的租户感知
加载用户详情时,同时获取租户的套餐信息:
scss
@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 套餐如何限制菜单
当获取用户菜单树时,会根据平台/租户身份走不同的逻辑:
ini
@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 以逗号分隔的字符串存储,解析逻辑如下:
kotlin
@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 创建租户
创建租户时需要同时初始化默认部门和管理员用户:
less
@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 的实现:
ini
@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 校验租户有效性
登录前校验租户状态和过期时间:
kotlin
@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 控制权限:
less
@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 加上租户前缀:
typescript
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 缓存定义中的隔离开关
通过 CacheDef 的 tenantIsolated 属性控制每个缓存是否需要隔离:
arduino
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 跨租户操作
平台管理员管理租户时需要临时切换上下文:
ini
Long previousTenantId = TenantContext.getTenantId();
try {
TenantContext.setTenantId(targetTenantId);
// 执行操作...
} finally {
TenantContext.setTenantId(previousTenantId); // 恢复
}
9.3 异步任务
如果使用 @Async 或线程池,子线程不会继承父线程的 ThreadLocal,需要手动传递:
ini
Long tenantId = TenantContext.getTenantId();
executor.submit(() -> {
TenantContext.setTenantId(tenantId);
try {
// 执行异步任务...
} finally {
TenantContext.clear();
}
});
⚠️ 或者使用
TransmittableThreadLocal(阿里巴巴开源)替代ThreadLocal,可自动透传到线程池。
9.4 数据库索引
所有 tenant_id 字段建议添加索引,避免全表扫描:
scss
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 知识库等)。