做了几年Java后端,参与过的项目里十个有八个都会碰多租户。有的用独立数据库,有的用独立Schema,有的像芋道源码(ruoyi-vue-pro)这样直接在共享表里用tenant_id做隔离。这三种方案各有各的取舍,但如果你是在国内Spring Boot生态里做SaaS系统开发,芋道这套“程序控制租户”的思路大概率是你最容易拿来抄作业的参考实现。
这篇文章不聊概念,直接把芋道源码里的多租户链路拆开:从请求进来、租户ID怎么塞进上下文,到MyBatis Plus拦截器怎么悄悄改SQL,再到哪些表刻意不隔离、定时任务里为什么拿不到租户ID、新表要接进来该改哪几个文件。适合正在用芋道二次开发的同学,也适合自己想从零实现一套多租户、准备抄框架思路的人。
1. 先从根上理解“多租户”到底在解决什么问题
1.1 共享一套系统,数据却不能互相看见
多租户的场景其实很日常:你开发了一套管理系统,A公司和B公司都要用,用户注册的时候各自登录,谁也不希望看到对方公司的客户、订单、合同。如果没有多租户机制,最简单的做法是把用户表加一个company_id字段,每次查询都手动带上WHERE company_id = ?。但只要你漏了一个地方,数据就串了。线上事故往往就是这么来的。
芋道源码的设计初衷就在这里:与其让每个开发者在写SQL时都记住要带租户条件,不如在框架层面统一拦截,让漏写的情况直接被拦截器“补上”。程序控制的核心就是——把租户隔离从“约定”变成“机制”。
1.2 三种隔离方案的取舍,芋道为什么选共享表
多租户的主流实现有三种:
- 独立数据库:每个租户一个库,隔离最彻底,但数据库实例成本高,迁移维护麻烦。
- 独立Schema:每个租户一套表结构,处于独立库和共享表之间,Oracle、PostgreSQL用得多,MySQL下Schema等于库,成本也不低。
- 共享表 + 租户ID字段:所有租户共用一张表,通过
tenant_id区分。成本最低、运维最简单,缺点是隔离强度完全依赖程序正确性。
芋道选了第三种。原因是这套框架面向的是中小型项目、快速交付的场景,独立库的成本和运维负担不现实。共享表方案的关键就在于“程序控制”必须做到滴水不漏:框架自动处理90%的SQL,剩下的10%通过排除表和手动API兜底。把最核心的机制做对,开发者的心智负担就小得多。
2. 芋道多租户模块的目录与核心组件
2.1 租户上下文:整个机制的心脏
如果打开芋道源码,在yudao-framework里找租户相关的包,最先要看的类是TenantContext。这个类封装了一个基于TransmittableThreadLocal的线程本地变量,保存当前请求所属的租户ID。
为什么用TransmittableThreadLocal而不是普通的ThreadLocal?因为业务系统里线程池太常见了:异步任务、MQ消费者、定时任务,如果你用普通ThreadLocal,一旦代码从主线程提交到线程池执行,租户ID就丢了。芋道用阿里开源的transmittable组件,就是为了让线程切换时租户上下文能自动传递。这个细节很关键,很多人排查多租户问题半天找不到原因,最后发现就是线程池把上下文搞丢了。
看一眼简化后的TenantContext核心方法:
public class TenantContext { private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>(); public static Long getTenantId() { return TENANT_ID.get(); } public static void setTenantId(Long tenantId) { TENANT_ID.set(tenantId); } public static void clear() { TENANT_ID.remove(); } }就这么简单,但它是整条链路的地基。没有这个上下文,后面的拦截器、SQL解析器全都拿不到租户ID。
2.2 程序控制的一体两翼:Web拦截器 + MyBatis Plus插件
芋道的租户控制不是靠某一个类单独完成的,而是一条完整的链路:
- 前端发起请求,在Header里带上
tenant-id。 - 后端的Web拦截器(
TenantSecurityInterceptor)拦截请求,从Header取出租户ID,调用TenantContext.setTenantId()写入上下文。 - 请求进入Service层,执行SQL时,MyBatis Plus的租户插件(
TenantDatabaseInterceptor)会自动读取TenantContext.getTenantId(),往SQL里拼接tenant_id = ?条件。 - 请求结束,拦截器的
afterCompletion里调用TenantContext.clear(),防止线程池复用导致租户串号。
Web拦截器负责“注入”,SQL插件负责“隔离”,两者配合,开发者在绝大部分业务代码里完全不用感知租户的存在。这就是芋道多租户设计最厉害的地方:把复杂度收敛在框架层,业务代码保持干净。
3. 第一层控制:请求链路里租户ID是怎么被塞进去的
3.1 前端传参方式和Header约定
芋道的前端在发起请求时,默认在请求头里放一个tenant-id。这套设计有一点很贴心——如果在application.yaml里关闭了多租户功能,前端根本不需要改代码,因为后端拦截器在租户功能关闭时直接跳过。
看芋道配置中心里这样一段配置:
yudao: tenant: enable: true ignore-tables: - system_tenant - system_tenant_packageenable控制多租户总开关。如果哪天项目不需要多租户了,直接改成false,所有租户逻辑全部失效,数据不再隔离。这也是“程序控制”的直观体现:租户功能本身是可以通过配置开关来动态控制的,而不是写死在代码里。
3.2 后端的拦截器如何接住租户ID
芋道的TenantSecurityInterceptor大体逻辑如下(简化版):
public class TenantSecurityInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 租户功能未开启,直接放行 if (!tenantProperties.getEnable()) { return true; } // 从请求头获取租户ID String tenantIdStr = request.getHeader("tenant-id"); if (StrUtil.isNotBlank(tenantIdStr)) { TenantContext.setTenantId(Long.valueOf(tenantIdStr)); } // 如果获取不到租户ID,可以根据配置决定是否抛异常 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); } }这里有几个值得借鉴的细节:
preHandle里校验“开启状态”是第一步,否则多租户开关关闭时,后面所有逻辑都不该执行。afterCompletion里清空上下文,这个动作不能省。Tomcat的线程池会复用线程,如果你不清空,下一个请求可能沿用上一个租户的ID,造成严重的数据越权。这个坑我见人踩过不止一次。- 如果请求没带租户ID,框架不会立刻报错,而是放行,让后续SQL执行阶段判断——因为有些接口(比如登录接口本身)不需要租户上下文。
3.3 自定义Header名称与白名单配置
实际接接入时,你可能不想用默认的tenant-id这个Header名,或者某些接口(比如微信小程序回调、第三方OpenAPI)根本没有租户概念,那就要配置白名单。芋道里可以通过自定义拦截器配置来加白名单路径,把它加到注册拦截器的addPathPatterns之外,或者配置忽略路径的集合。
我自己的项目里就遇到过一个场景:支付回调接口是支付平台调用的,不会带租户ID,如果这个接口进了租户拦截逻辑,就会因为拿不到租户上下文而在SQL解析时报错。解决办法很简单——把支付回调接口排除掉。而且这个接口操作的数据表本身不需要租户隔离,所以从请求入口就放行是对的。排查这类问题时记住一个口诀:请求入口能放行的,就不要拖到SQL层去解决问题。
4. 第二层控制:SQL拦截器如何自动拼接租户条件
4.1 MyBatis Plus的租户插件原理
这一层是整个机制的核心亮点。芋道没有在Service层写大量lambdaQuery带租户条件的代码,而是依靠MyBatis Plus的TenantLineInnerInterceptor,它会在底层拦截Executor执行的SQL,改写成带租户条件的语句。
举个例子,原本你写的Mapper方法:
selectUserList(Page<UserDO> page, UserDO user)生成的SQL可能是:
SELECT id, username, nickname, tenant_id FROM system_user经过租户插件改写后,自动变成:
SELECT id, username, nickname, tenant_id FROM system_user WHERE tenant_id = 1整个过程对Mapper和Service无感知。开发者的直觉感受就是:我什么都没写,数据就自动隔离了。
4.2 芋道如何实现TenantLineHandler
芋道实现了一个TenantLineHandler接口,这是MyBatis Plus租户插件的扩展点。核心逻辑有四个部分:返回当前租户ID、设置租户列名、判断表是否忽略、是否忽略更新删除语句。
看这个简化实现:
public class YudaoTenantLineHandler implements TenantLineHandler { @Override public Expression getTenantId() { return new LongValue(TenantContext.getTenantId()); } @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { // 排除表的判断 return tenantProperties.getIgnoreTables().contains(tableName); } }注意getTenantId()这里,它直接从TenantContext里拿值,而不是从参数或配置里拿,这样保证每一线程拿到的是自己请求的租户ID。
另外一个容易忽略的地方是getTenantIdColumn()返回的列名。虽然绝大多数表都叫tenant_id,但架不住有历史表叫dept_id或者belong_tenant这种名字。芋道这里做成可配置的,我的建议是:新表统一用tenant_id,不要为了标新立异搞特殊列名。因为MyBatis Plus拦截器是按列名匹配的,一旦列名不统一,你就必须为每一张表自定义规则,工程量瞬间上去。
4.3 哪些表刻意不隔离
框架里总有一些表是全租户共享的,比如租户表本身(system_tenant)、租户套餐表(system_tenant_package)、菜单表、字典类型表等。这些表如果在查询时也加tenant_id条件,反而会出问题——比如租户表连tenant_id字段都没有,SQL解析器会发现列不存在,直接报错。
芋道的处理是在配置里维护了一个ignore-tables清单:
yudao: tenant: ignore-tables: - system_tenant - system_tenant_package - system_menu - system_dict_type - system_dict_data这些表被加入忽略名单后,MyBatis Plus解析SQL时会跳过它们,不加租户条件。维护这个清单要谨慎,原则是:只有真正需要全局共享的表才放进去,业务数据表一律隔离,否则隔离就形同虚设。
4.4 超级管理员为什么能看到所有租户的数据
芋道还有一个隐藏逻辑:如果当前用户是超级管理员(通常租户ID为空或ID为特殊值),租户插件会放弃拼接租户条件。这不是Bug,而是故意设计的“上帝视角”。这样超级管理员在后台就能跨租户查看所有数据,方便运维排查问题。
实现方式是在TenantLineHandler里做判断:
@Override public Expression getTenantId() { Long tenantId = TenantContext.getTenantId(); if (tenantId == null) { // 超级管理员场景,返回一个不可能匹配的租户ID,或者直接返回null表示不隔离 return new LongValue(-1); } return new LongValue(tenantId); }这里的细节是:如果是超级管理员并且没有设置租户ID,你绝对不能让它拼接一个真实的tenant_id,否则就真的把所有租户的数据都查出来了——虽然超级管理员有这个权限,但风险太大。芋道返回一个不可能匹配的-1,让查询结果为空,再结合单独的“切换到某租户”接口进行定向查询。这个设计很值得学习:程序控制的核心原则是让“不安全”的状态默认返回空结果,而不是返回全量数据。
4.5 更新和删除操作的租户保护
MyBatis Plus的租户插件不仅管SELECT,还会管UPDATE和DELETE。否则就会出现一种严重的情况:一个租户的应用把另一个租户的数据删了。
芋道的处理方式是:UPDATE语句会自动加WHERE tenant_id = ?,DELETE语句也会自动加。但如果开发者在Mapper里写了自定义SQL,用了<script>标签的复杂更新语句,插件可能无法正确解析。这时候就需要开发者自己在SQL里显式加tenant_id条件,或者用框架提供的DataPermission注解进行兜底。我在实战中吃过一次亏:一个批量更新订单状态的语句,因为用了UPDATE order SET status = ? WHERE id IN (...)这种写法,MP插件解析时没有正确识别到表别名,导致租户条件没拼上。从那以后我给自己定了一条规矩:复杂SQL一定要自己带上租户条件,不要把命运完全交给插件。
5. 第三层控制:业务代码里如何拿租户ID和落库
5.1 从上下文获取租户ID的几种方式
虽然框架自动处理了SQL隔离,但有些场景开发者还是需要拿到当前租户ID,比如写日志、记录操作人、调外部系统时把租户ID透传过去。芋道里开发者可以直接调用:
Long tenantId = TenantContext.getTenantId();这里的方法名在不同版本可能有细微区别,早期版本是TenantContextHolder,后面的版本简化成了TenantContext。如果你在集成时发现类名对不上,去框架的framework-tenant包里找一下就行。
另外一个坑是:在Controller里取租户ID通常没问题,但如果你在@Async异步方法里取,就必须确认线程上下文有没有传递过去。芋道底层集成了TransmittableThreadLocal,线程池如果用对了,异步线程里能获取到;但如果异步方法是通过Spring的原生@Async线程池执行的,而线程池没有包装TtlRunnable,一样会丢。这就是一个典型的需要自己验证的场景,别想当然。
5.2 新增数据时,tenant_id到底要不要手写
很多刚接触芋道的人会问:往表里插入数据时,是不是必须自己setTenantId?
答案是不需要。MyBatis Plus的租户插件不仅处理查询,还会给INSERT语句自动填充租户ID。你插入一条记录,SQL会自动带上tenant_id字段并填入当前上下文中的值。所以业务代码里完全不用手动处理。
但有一个前提:这张表的租户字段列名必须是插件配置的tenant_id,而且这张表不能出现在忽略表清单里。如果你的表叫biz_data,字段叫tenant_no,那对不起,插件不认识,还得自己处理。所以我在项目里立了个约定:所有业务表第一个公共字段就是tenant_id,没得商量。
5.3 手动忽略租户:什么时候用、怎么用
有些特定场景确实需要绕过租户隔离,比如系统初始化、数据导入工具、跨租户的数据报表聚合。芋道提供了对应的API让开发者主动控制,常见方式有两种:
一是使用框架的TenantUtils.execute()方法,传入一个忽略租户的执行逻辑:
TenantUtils.executeIgnore(() -> { // 这里会暂时忽略租户条件 return userService.count(); });二是直接操作TenantContext,自己先clear()再执行,不过不推荐这么做,容易忘记恢复。
我在实际项目里用TenantUtils.executeIgnore()最多的地方是数据导入和任务调度:一个定时任务要扫描所有租户的到期订单,那肯定不能只查当前租户,这时候就需要忽略租户先拿全量数据,再按租户维度处理。这里再次强调那一点:忽略租户是极其危险的操作,我只建议在两类场景用——纯后台运维功能、以及你明确知道自己在干什么的批处理任务。
6. 多租户模块的常见问题与排查思路
6.1 新表接进来没隔离,数据串了
这是我见过最多的问题。开发新功能建了一张表,忘了加tenant_id字段,或者加了字段但没走框架的标准接口,结果测试时发现A租户能看到B租户的数据。
排查思路很简单:
- 先
DESC your_table;确认表里有没有tenant_id字段。 - 看
yudao.tenant.ignore-tables配置里有没有误加这张表。 - 看Mapper是否用的MyBatis Plus的
BaseMapper,如果用了@Select自定义注解,插件同样会解析,但如果用了${}拼接SQL,解析器很可能无能为力。 - 如果前三点都没问题,开SQL日志确认最终执行的SQL长什么样。
芋道的SQL日志打印在开发环境默认是开启的,看日志里有没有tenant_id = ?条件,一目了然。
6.2 定时任务和MQ消费者里拿不到租户ID
定时任务场景和MQ消费者场景非常相似:执行线程不是来自HTTP请求,所以Web拦截器根本没机会执行,TenantContext里是空的。如果这时候业务SQL执行了,租户插件拿不到租户ID,要么报错,要么查到空数据。
解决办法通常是在任务方法里手动指定租户,或者通过TenantUtils.execute(tenantId, taskBody)包一层。芋道的多租户工具类已经提供了这类方法。举一个实际例子:一个每天凌晨跑的电费账单任务,需要按租户逐个生成账单,代码结构大致是:
List<TenantDO> tenants = tenantService.getEnableTenantList(); for (TenantDO tenant : tenants) { TenantUtils.execute(tenant.getId(), () -> { billService.generateDailyBill(); return null; }); }这里TenantUtils.execute()里会先setTenantId,执行完再clear,确保任务内所有SQL自动带上正确的租户条件。
6.3 缓存数据跨租户串了
这个问题隐蔽性很强。框架里如果用了Redis缓存,并且缓存key没有带上租户ID,那么A租户写入的缓存数据,B租户可能直接命中读取到。这个问题的严重性和多租户串数据一样,属于高危事故。
芋道自身的缓存设计建议是:凡涉及租户业务数据的Redis Key,一定要把租户ID拼进去。推荐的做法是定义一个缓存Key的工具类,统一拼接租户维度:
public String buildKey(String key, Long tenantId) { return tenantId == null ? key : tenantId + ":" + key; }同时,在修改租户配置或者做数据清理时,要记得按租户维度删除缓存,否则也会出现脏缓存。我踩过的一次坑是:A租户改了菜单配置,Redis缓存刷新后,B租户刷新页面发现菜单变成A租户的了,根因就是缓存key没带租户ID。
6.4 如何调试租户SQL,快速定位问题报表
排查多租户问题最直接的手段就是打开MyBatis Plus的SQL日志。在application.yaml里配置:
logging: level: com.baomidou.mybatisplus: debug这样控制台会打印所有SQL,包括租户插件改写后的语句。看到WHERE tenant_id = ?就说明隔离生效了;没看到,就说明这张表漏了。
另外还可以配置MyBatis Plus的sql-injector和性能分析插件,得到SQL执行耗时。多租户SQL通常很简单,如果发现慢查询,大概率是tenant_id字段没有加联合索引。凡是经常出现在WHERE条件里的租户ID,记得和业务查询字段一起建组合索引,这个优化收益非常明显。
7. 实操复盘:新项目里接入芋道多租户的完整步骤
7.1 开启功能开关和配置忽略表
接入芋道多租户,第一步不是写代码,而是改配置。把所有配置项检查一遍:
yudao: tenant: enable: true ignore-tables: - system_tenant - system_tenant_package - system_menu - system_dict_data - system_dict_type注意:system_user表要不要忽略,取决于你的业务是否需要登录用户跨租户复用。芋道默认是忽略的,因为底层登录和Token体系是全局的,不走租户隔离。如果业务上希望A租户的用户不登录B租户的系统,那登录校验逻辑需要额外处理,不能简单靠租户插件解决。
7.2 新业务表接入租户的完整流程
新表接入,我总结了一套四步法,基本不会出错:
- 建表时加
tenant_id字段,默认值0或NULL,加普通索引(如果有联合查询,直接放在联合索引第一位)。 - 实体类里增加
tenantId字段,对应数据库的tenant_id列。 - 确认Mapper继承的是
BaseMapper<T>,这样框架标准CRUD都会走租户插件。 - 在配置的
ignore-tables清单里确认没有这张表。
完成四步后用两个租户账号分别登录,互相查一下数据,确认隔离生效。这个过程最多半小时,但能把90%的串数据问题提前堵住。
7.3 自定义排除逻辑:当“忽略表”不够用时
有时候忽略表名单不够灵活,比如同一张表,A接口需要隔离,B接口需要全局查。这种情况就不能单纯靠全局的ignore-tables配置了,需要自己处理。我提供两种思路:
- 第一种:在Mapper方法上写自定义SQL,自己控制
tenant_id条件,然后把这个表加进忽略名单,让插件不去碰这张表。这是最干净的方式。 - 第二种:使用
TenantUtils.executeIgnore()在方法体内部临时忽略租户,但前提是这张表不在全局忽略名单里,否则插件根本不会解析它。
方案一适合经常需要全局访问的表,方案二适合偶尔一次的特殊逻辑。我强烈建议优先方案一:把规则显式地写在SQL里,别人读代码时一看就知道这张表有特殊逻辑。方案二依赖上下文,稍不注意就会变成“静默错误”,等到出事了才后悔。
7.4 现有表接入后的存量数据迁移
如果项目之前没做多租户,表里已经积攒了很多存量数据,那么接入租户时最重要的不是代码,而是数据迁移。迁移要有清晰的策略:确定存量数据归谁所有,是默认给第一个租户,还是按机构归属重新划分,或者导入工具重新分配。
实操上我建议先写一个迁移脚本,把原有数据的事务和租户ID都重新刷一遍,然后跑一遍一致性检查脚本,确认没有孤儿数据。迁移顺序上,先迁基础表(用户、角色、菜单),再迁业务表,边迁边验。千万不要直接在生产库跑一条UPDATE ... SET tenant_id = 1完事,数据量大了会锁库,而且出了问题很难回滚。
还有一个细节:如果表里刚好有索引是(tenant_id, create_time)之类的组合索引,迁移完数据后记得重新统计并优化索引,否则老数据查询可能不走索引,慢查询一大堆。
最后分享一点个人心得
把芋道这套多租户机制完整跟读一遍之后,我最大的体会是:好的框架设计不是把所有逻辑堆在业务代码里,而是把通用规则沉淀到框架层,让业务开发者“无感”使用。但同时,“无感”也有反面——开发者太依赖框架,反而忘了多租户隔离的本质是数据权限,是安全底线。
建议每个要上多租户的团队,都强制看一遍TenantLineHandler和TenantContext这两个类,把租户隔离的边界在心里画清楚。最后再分享一个小技巧:不管用哪套方案,上线前务必写一个“串租户检查”的测试用例,用两个租户账号跑一遍核心链路,比任何代码Review都管用。多租户这种事,宁可测试多做一点,也别等到客户投诉了再补窟窿。