芋道源码多租户实战:从请求上下文到SQL拦截的完整链路
2026/9/10 6:27:04 网站建设 项目流程

1. 从一次“诡异”的SQL报错说起:租户到底在管什么

先讲个我自己的经历。前两年用芋道源码(yudao)搭公司内部的SaaS平台,当时刚接手一个订单模块,本地联调一切正常,一上测试环境就报“tenant_id栏位不存在”。我第一反应是数据库脚本没执行干净,结果一查表结构,字段明明在。最后把MyBatis Plus的SQL日志打开,才发现执行的真实SQL里被自动拼了一段AND tenant_id = 1,而这个“1”是从请求上下文里带出来的——问题出在我在联调时用了一台没走租户过滤的服务节点,请求上下文里的租户ID没有正确写入。

那是我第一次认真去翻芋道源码里跟租户有关的那一整套逻辑。看完之后发现,所谓“程序控制租户”,本质上不是某个类做了什么神奇的事情,而是三个环节各司其职:请求进来时把租户ID放到上下文里,SQL执行时自动追加过滤条件,业务代码里通过注解或手动API对特殊场景做放行或干预。如果你正在用芋道做多租户SaaS,或者被“莫名其妙多出tenant_id”“异步任务查不到数据”这类问题折磨过,这篇内容应该能帮你把整条链路彻底理清楚。

这篇文章我会从架构设计讲到SQL拦截,再到业务代码里的控制手法,最后给一份踩坑排查清单。不管你是第一次接触芋道,还是已经在生产环境跑了很久,希望都能从中找到对应的答案。

2. 租户程序控制的整体架构:三个环节一条链路

2.1 为什么选择“共享表+行级隔离”

做多租户的方案,行业里不外乎三种:独立数据库、独立Schema(模式)、共享表加行级隔离。独立数据库的隔离性最好,但成本高、运维复杂,一个小型SaaS根本扛不住每来一个租户就建一套库。独立Schema比独立数据库轻一些,但跨租户统计、全局运营数据汇总又很麻烦。芋道选的方案是共享表加tenant_id行级隔离——所有租户的数据放在同一张表里,靠每一行数据上的租户ID来区分归属。

这个选择非常现实。对于绝大多数SaaS场景,几千上万个租户共用一个PostgreSQL或MySQL实例,每月成本可控、备份恢复方便、上线新功能只要发布一次代码,是性价比最高的路径。而“程序控制租户”这个命题,本质上就是在回答一个问题:如何在共享表的前提下,不靠业务开发人员手动写where tenant_id = ?,也能保证数据天然隔离

芋道的答案是在框架层做了两件关键的事:一是用ThreadLocal保存当前请求的租户ID,二是用MyBatis Plus的租户插件自动改写SQL。这两件事合在一起,就让业务开发几乎感觉不到租户的存在——你写select * from order where status = 1,到了数据库层自动变成select * from order where status = 1 and tenant_id = 8。这就是“程序控制”的最核心价值。

2.2 三个核心环节的分工

我习惯把这段租户控制链路拆成三个环节来理解。

第一个环节是请求上下文。每一次HTTP请求到达后端时,框架会从请求头、请求参数或者登录态里解析出当前操作的是哪个租户,然后把这个租户ID写进一个基于ThreadLocal的上下文类。为什么用ThreadLocal?因为它是线程私有的,同一个线程在处理请求的全过程里,任何时候都能快速拿到当前的租户ID,不需要层层传递参数。芋道里对应的核心类就是TenantContextHolder和相关的Context对象。

第二个环节是SQL拦截器。请求上下文只是把租户ID“带上”了,真正让数据隔离生效的是MyBatis Plus的TenantLineInnerInterceptor。这个内置拦截器会在SQL执行前进行解析和改写,自动把所有需要隔离的表查询加上tenant_id条件。你不需要在每个Mapper接口里手动传租户ID,也不需要写XML时记得加and tenant_id = #{tenantId},它替你把活干了。

第三个环节是业务代码的控制点。不是所有表和所有场景都需要租户隔离。系统配置表、字典表这种全局共享的数据,如果也被强加tenant_id条件,反而会出问题。芋道提供了一套机制来跳过自动拦截,比如@TenantIgnore注解,再比如手动设置上下文的API。这三个环节环环相扣:缺了第一个,后面的SQL拦截器拿不到租户ID;缺了第二个,数据隔离只是纸上谈兵;缺了第三个,全局数据和特殊场景会被误伤。

2.3 一个生活化的类比

如果你觉得这套链路有点抽象,可以想象一栋写字楼的进出管理。ThreadLocal里的租户ID就是你的工牌,进入大楼时(请求入口)门禁系统识别工牌,在你的通行记录上写下你属于哪家公司。SQL拦截器就是楼里的门禁闸机,你每进一间办公室,闸机都会自动校验工牌,不是这家公司的人根本进不去。而@TenantIgnore就像是会议室预约系统——有些公共区域(比如前台大厅、消防通道)本来就该对所有人开放,门禁系统知道要跳过这些地方。把这三层想明白了,芋道租户代码再复杂,你也能一眼定位它是在哪个环节做手脚。

3. 请求链路中的租户解析:上下文是怎么“上车”的

3.1 从Header、参数还是登录态取租户ID

租户ID本身不会凭空出现,它一定来自客户端。芋道常见的做法是从HTTP请求头里读取,比如自定义一个tenant-id的Header;也有从请求参数里读取的场景,还有在登录接口里发放Token时就携带租户信息,后续请求从Token中解析。这三种方式各有适用场景:

  • Header方式:适合前后端分离、网关统一鉴权的架构,客户端登录后把租户ID放在Header里,后端过滤器直接取用,干净清晰。
  • 请求参数方式:适合简单的联调场景,比如直接在URL后拼?tenantId=2,但生产环境不建议这么干,容易被篡改或漏传。
  • Token/登录态方式:适合需要在无状态下保持租户身份的场景,登录成功后JWT里就带着租户ID,请求进来先解析Token,再获取租户ID,安全性更高。

我实际用下来最推荐Header结合Token:登录时生成Token并把租户ID写进去,后续请求从Header中携带Token,后端解析Token获取租户ID,而不是直接信任客户端传上来的原始参数。这样即使有人手动篡改Header里的tenant-id,只要Token解析出来的租户ID不一致,也能通过校验逻辑兜底拦截。

3.2 过滤器/拦截器的实现细节

芋道在Web层通过自定义Filter或者HandlerInterceptor实现租户上下文的写入和清理。核心逻辑并不复杂,关键代码大致是下面这个思路:

public class TenantContextWebFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { // 1. 从请求头中获取租户ID String tenantIdStr = request.getHeader("tenant-id"); // 2. 校验是否为空、格式是否正确 if (StringUtils.hasText(tenantIdStr)) { Long tenantId = Long.valueOf(tenantIdStr); TenantContextHolder.setTenantId(tenantId); } try { // 3. 执行后续业务逻辑 chain.doFilter(request, response); } finally { // 4. 请求结束后必须清除上下文 TenantContextHolder.clear(); } } }

这里有一个特别容易被忽略的细节:为什么必须在finally中清除上下文。最直接的原因是ThreadLocal是线程绑定的,而Tomcat这类Web容器会复用线程处理请求。如果不清除,线程被下一个请求复用的时候,上一个请求遗留的租户ID就会污染下一个请求,导致A租户的用户查到了B租户的数据。这种问题极难排查,因为它“偶发”,只在并发压力大的时候出现。所以不管业务逻辑发生什么异常,finally里清空上下文都是必须养成的习惯。

还有一点需要注意过滤器的注册顺序。租户上下文过滤器要尽量靠前,最好在处理鉴权、业务拦截之前执行,否则后面的代码拿不到租户ID。芋道框架里通过FilterRegistrationBean控制顺序,自定义过滤器时如果和框架自带过滤器混在一起,一定要确认执行顺序符合预期。

3.3 登录接口、定时任务、MQ场景的上下文处理

登录接口有些特殊。用户还没登录的时候,请求里往往没有租户ID,或者租户ID是用户手动输入账号时选择的一个租户。芋道在登录接口的处理上通常采用“先解析租户,再执行登录逻辑”的顺序:要么使用默认租户ID,要么单独从请求参数中获取租户信息并写入上下文,登录成功后生成带租户ID的Token。

定时任务和MQ消费者则完全是另一类场景。没有HTTP请求,自然也就不会经过过滤器,所以Spring容器启动后,你的定时任务线程里ThreadLocal是空的。如果定时任务里直接调用了需要租户隔离的Service方法,最终执行的SQL可能不会拼接tenant_id条件,或者因为租户ID为空而查不到任何数据。

针对这类场景,芋道的常规做法是手动设置上下文:

public void runTask() { // 手动指定要处理的租户ID TenantContextHolder.setTenantId(1L); try { // 执行跨租户或指定租户的业务逻辑 orderService.queryTodayOrders(); } finally { TenantContextHolder.clear(); } }

这里要注意的是,同一个线程里如果有多个任务,一定要在finally里清掉,避免后续任务被污染。如果是并发执行多个租户的任务,建议每个任务单独开启一个线程,或者使用线程池时给不同任务设置不同的上下文,而不是在一个循环里反复set/clear——那很容易因为异常跳出导致上下文残留。

4. 数据库层:MyBatis Plus租户插件的核心原理

4.1 TenantLineHandler配置与工作原理

让租户控制真正落到数据库层的,是MyBatis Plus的TenantLineInnerInterceptor。它的工作方式是在SQL执行前,用JSqlParser对SQL语句做语法解析,再对解析后的语法树进行修改,把租户过滤条件追加到合适的位置。芋道通过自定义TenantLineHandler来决定两个关键问题:当前租户ID是多少,以及哪些表不需要加租户条件

public class YudaoTenantLineHandler implements TenantLineHandler { @Override public Expression getTenantId() { // 从上下文获取当前租户ID Long tenantId = TenantContextHolder.getTenantId(); if (tenantId == null) { throw new TenantException("租户ID不能为空"); } return new LongValue(tenantId); } @Override public boolean ignoreTable(String tableName) { // 判断该表是否需要走租户隔离 return TenantContextHolder.isIgnore() || tenantTables.contains(tableName); } @Override public String getTenantIdColumn() { return "tenant_id"; } }

这个Handler的写法有几个关键点。第一,getTenantId方法返回的是当前上下文的租户ID,如果为空,建议直接抛异常而不是静默返回null,否则后续SQL改写会得到一个无效条件,查找问题会非常耗时。第二,ignoreTable方法决定了哪些表跳过租户过滤,这里通常配置一个全局共享表的清单,比如系统配置表、字典表、租户套餐表。第三,tenant_id列名是可以自定义的,但如果项目里统一命名tenant_id,不用改这个方法。

4.2 SQL改写到底改了什么

理解租户插件,最好的方式就是看SQL执行前后的变化。假设我们写了一个查询:

SELECT * FROM order WHERE status = 1

经过租户拦截器改写后,实际执行的SQL会变成:

SELECT * FROM order WHERE status = 1 AND tenant_id = 8

这就是自动过滤。更复杂的场景是JOIN查询:

SELECT o.*, u.name FROM order o LEFT JOIN user u ON o.user_id = u.id

改写的逻辑会根据每张表的租户隔离规则分别处理。如果order和user都是租户表,改写后会变成:

SELECT o.*, u.name FROM order o LEFT JOIN user u ON o.user_id = u.id WHERE o.tenant_id = 8 AND u.tenant_id = 8

这里有个非常关键的认知:租户插件不是只给主表加条件,而是给所有参与查询的、且未忽略的租户表分别加条件。所以如果你在JOIN中没有处理好表别名、子查询、或是联表查询了两个不同租户的数据,改写后的SQL可能不是你想要的。

另外一个常见问题是子查询。比如:

SELECT * FROM order WHERE user_id IN (SELECT id FROM user WHERE status = 1)

如果order和user都需要租户隔离,改写后子查询也会被加上tenant_id条件。但如果子查询里用了一个全局共享的字典表,而字典表在忽略清单里,那么子查询就不会被加条件。理解这个逻辑,你在设计SQL时心里就会有数,不会等上了生产环境才发现某条统计SQL查出来的数据不对。

4.3 哪些表该忽略、哪些表该隔离

这个问题的答案直接决定了租户控制的边界。我见过有些项目为了省事,把所有表都加入租户隔离,结果连系统配置表也被隔离了,每个租户都要单独配一份数据字典,维护成本飙升。芋道官方文档里其实有比较清晰的分类逻辑,我根据自己的实践整理了一个常见的分类表:

表类型典型表是否需要租户隔离原因
租户基础表system_tenant、system_tenant_package租户信息是全局共享的,租户自己登录前也需要查
全局配置表system_config、system_dict_data配置和字典是系统级数据,所有租户共享同一份
用户权限表system_user、system_role、system_menu每个租户有自己独立的账号体系和权限树
业务数据表订单、商品、工单、审批流等核心业务数据必须按租户隔离
平台管理表操作日志、登录日志、异常日志可选如果日志需要给平台运营看全部租户,则忽略;如果租户需要看自己的日志,则隔离

这里特别提醒一句:“忽略表清单”不是配好就一劳永逸的。项目每新增一张表,都要先问一句“这张表的数据是租户私有的,还是全局共享的”。我就是吃过亏的:上线一个新报表模块,新增的报表配置表忘记加到隔离表名单里,导致所有租户查询时都没自动带tenant_id,客户A能看到客户B的报表模板配置。这种事故一旦发生,影响范围很难估量。

4.4 原生SQL、存储过程等“漏网之鱼”

MyBatis Plus的租户拦截器只能拦截它自己管理的SQL执行链路。如果你在代码里使用了JdbcTemplate直接执行原生SQL,或者调用存储过程,又或者使用了MyBatis的@Select注解但没有走标准Mapper代理,那么租户自动过滤就会失效。这不是芋道的问题,是所有基于ORM拦截器的多租户方案的天然边界。

碰到这类场景,我的建议是:能不写原生SQL就不写,能不用存储过程就不用。实在绕不开,就必须在SQL里手动拼上tenant_id条件,同时在代码里显式调用相关上下文获取当前租户ID。更稳妥的做法是在项目里做一个统一的数据访问层封装,所有手写SQL都走同一个工具方法,由工具方法替你处理租户条件,避免每个开发各写一套、标准不一。

5. 业务代码中的租户控制:注解与手动干预

5.1 @TenantIgnore注解怎么用、什么时候用

不是所有场景都希望SQL自动加tenant_id条件。假设你是平台管理员,要统计所有租户的总订单量,写了一个查询接口,希望查全量数据而不是只看某个租户的数据。这时候就可以在Service方法或者Mapper方法上标注@TenantIgnore,让租户拦截器跳过这个方法内的SQL改写。

@TenantIgnore public Long countAllOrders() { // 这里查的是所有租户的订单总数 return orderMapper.selectCount(null); }

使用这个注解时有几个容易踩的坑。第一,注解的作用范围是按方法粒度控制的,如果一个Service方法里既查全局表又查租户表,那么标注后租户表也不会自动加条件,需要你自己在SQL里显式处理。第二,要注意注解是否真的被Spring AOP代理捕获,如果你在同一个类内部通过this.method()调用被@TenantIgnore标注的方法,AOP代理不生效,注解也就不会起作用,你需要注入自身代理或者把方法拆到另一个Bean里。

第三种用法是结合调用场景动态跳过。芋道内部有一个TenantContextHolder.setIgnore(true)的机制,可以实现“当前线程内的租户过滤临时关闭”。它的使用场景比较窄,一般用于登录前的租户校验、系统初始化数据等灰阶段。用的时候务必在finally中恢复setIgnore(false),并记得把上下文清理干净。

5.2 手动设置租户上下文的坑与规范

手动设置上下文相比注解更灵活,但也更容易出问题。核心API是:

TenantContextHolder.setTenantId(10L); // 执行业务代码 TenantContextHolder.clear();

这里我踩过几个比较深的坑。

第一个是嵌套调用的问题。比如你在某个Service里先设了租户A,调用一个公有方法时,这个方法内部又设置了租户B,方法执行完把上下文清掉了,外层继续执行时拿到的租户ID就丢了。解决办法是在设置新值之前保存旧值,执行完后恢复,而不是直接clear:

Long oldTenantId = TenantContextHolder.getTenantId(); TenantContextHolder.setTenantId(newTenantId); try { // 业务逻辑 } finally { TenantContextHolder.setTenantId(oldTenantId); }

第二个是线程池复用的问题。使用了@Async异步方法或者自定义线程池执行任务时,子线程不会继承父线程的ThreadLocal值(默认情况下)。芋道有自己的一套线程池装饰器方案来传递上下文,但如果你项目里自定义了线程池,必须确保也做了上下文传递,否则异步方法查出来的数据可能是错的。

第三个是跨库事务的问题。多租户和Spring事务一起用时,租户上下文设置是在事务开启前还是事务执行中,会影响到MyBatis拦截器能否拿到正确的租户ID。通常建议在开启事务前就设置好上下文,避免代理方法内部先开启了数据库连接,再去设置租户ID时SQL已经解析完毕。

5.3 租户创建、套餐分配、初始化数据的控制流程

租户模块的“程序控制”还体现在租户全生命周期的管理上。一个新租户申请入驻时,后台会创建租户记录、分配一个租户套餐,然后为这个租户初始化基础数据:管理员账号、默认角色、权限菜单、示例数据等。这个过程的程序设计有两个关键点。

一是初始化数据必须使用目标租户的ID写入。很多开发者会在租户初始化时忘记设置租户上下文,导致写入的数据用的是默认租户ID(比如ID=1),结果租户创建成功后,登录进去发现什么都没有。正确的做法是在初始化Service方法中显式调用TenantContextHolder.setTenantId(新建的租户ID),再执行数据写入。

二是套餐与功能模块的绑定关系。芋道把可分配的功能权限、菜单等打包成租户套餐,创建租户时选定套餐,租户下的用户就拥有套餐对应的功能范围。这个是按照租户维度的授权控制,和行级数据隔离互为基础、又相互独立。数据隔离保证A租户看不到B租户的数据,套餐授权则控制同一租户下不同角色能操作哪些模块。这两者不要混为一谈。

6. 常见问题与排查技巧实录

6.1 问题速查表

把我在使用中和高频社区问题里见到的典型问题整理成一张速查表,方便大家直接对照:

现象根因解决方案
SQL莫名多出tenant_id导致语法错误查询中包含了子查询、联合查询,或使用了不支持的表结构开启SQL日志确认改写后的SQL;检查ignoreTable配置;必要时使用@TenantIgnore
查询结果为空,但数据明明存在租户ID为null,SQL被改写成tenant_id = null检查请求是否经过过滤器;手动调用TenantContextHolder.getTenantId()确认上下文是否为null
数据串租户(A租户看到B租户数据)表未加入租户隔离清单;线程池复用了未清除的上下文检查表隔离配置;确保过滤器finally清除上下文;排查异步线程上下文传递
登录接口报租户异常登录前还没有租户上下文,或租户校验逻辑里使用了租户隔离查询登录接口放在忽略列表;使用默认租户ID执行校验;用@TenantIgnore标注登录相关Service方法
@TenantIgnore不生效注解扫描路径不对;同类内部调用导致AOP代理不生效;方法粒度不匹配确认注解所在包被扫描;拆分Bean避免this调用;检查AOP配置
定时任务查不出数据定时任务线程中没有设置租户上下文在任务执行入口手动设置租户ID并finally清除
原生SQL或JdbcTemplate执行时没有租户过滤ORM拦截器无法拦截原生执行链路在SQL中手动追加tenant_id;封装统一数据访问工具类

6.2 定位租户问题的三板斧

遇到租户相关的问题,我最常用的排查顺序是三步。

第一板斧是打印上下文值。在业务代码入口处临时加一行日志,输出TenantContextHolder.getTenantId()当前的值。不要猜,先确认上下文里有没有值、值是不是正确。这一步能快速排除“上下文没设置”和“上下文被错误设置”两类问题。

第二板斧是打开MyBatis Plus的SQL日志。默认情况下MyBatis Plus会打印执行的SQL和参数,从日志里能直接看到最终执行的是不是带tenant_id条件的SQL。如果一个查询你预期它带条件但实际没带,问题通常出在忽略表清单或者注解上;如果一个查询你不希望它带条件但实际带了,则是表隔离配置过宽或者上下文值有误。

第三板斧是在线下复现时最小化场景。不要一上来就在几十张表的大查询里纠结,先写一个最简单的Mapper,查一张单表,看租户插件是否生效。如果单表都不生效,问题大概率在配置层;如果单表生效而复杂SQL不生效,再逐步往SQL里加JOIN、子查询,定位到具体的触发句法。

6.3 一个误配忽略表的真实案例

之前有个项目做订单报表,报表的查询SQL用了自定义的复杂视图。上线后运营反馈“报表里能看到所有租户的订单”,排查发现视图定义里join了三张表,其中一张订单明细表在ignoreTable清单里被误配置了。为什么会被误配?因为之前某个接口查询这张表时总是超时,开发者图省事直接把它加进了忽略清单,结果副作用就是所有涉及这张表的查询都不再按租户隔离。

这个案例给我的教训就是:忽略表清单的变更必须走评审,不能因为“查询慢”“报错了”就随手往忽略清单里加表。查询慢应该优化SQL和索引,报错应该定位SQL改写逻辑,而不是直接把租户隔离关掉。如果实在需要忽略,必须在代码注释里写清原因,并且由了解全局的模块负责人确认。

7. 与若依、dify社区版多租户方案的一个横向对比

多租户是个通用需求,除了芋道,市面上还有很多开源项目做了一样的功能。聊几个典型的对比,能帮你看清芋道方案的取舍。

若依(RuoYi)本身是一个老牌的快速开发框架,它的多租户实现方式和芋道非常相似,也是基于MyBatis Plus的租户插件,通过配置ignore表、在上下文中维护租户ID来实现行级隔离。差别在于芋道在租户API、租户套餐、租户初始化等方面封装得更完整,更像一个开箱即用的SaaS底座;若依则相对轻量,如果要支撑复杂的租户生命周期管理,需要自己补充不少代码。

dify社区版在1.10版本开始支持多租户,但它的技术栈是Python,走的路径和Java系完全不同。dify主要在应用层通过中间件和请求上下文来识别租户,再在数据库查询时显式带上租户过滤条件,而不是对ORM生成的SQL做隐式改写。这种做法从代码可读性来说更直白,但开发人员必须在每个查询里记得处理租户ID,漏掉一处就可能出问题。相比之下,芋道这种SQL拦截器方案对业务代码侵入更小,但对框架的理解成本更高。

方案技术栈租户实现方式对业务侵入性适用场景
芋道 / 若依Java + MyBatis PlusSQL自动拦截 + ThreadLocal上下文低,业务代码基本无感企业级快速开发、SaaS平台建设
dify社区版Python + Flask中间件 + 请求上下文 + 显式过滤中高,需要业务代码配合AI应用平台、工具类产品多租户

横向对比的意义不在于评判谁好谁坏,而在于理解“租户控制”在不同技术栈里可以有完全不同的实现路径。你如果将来要自己设计一套多租户方案,或者接手的项目不是基于芋道,也能很快类比迁移。

8. 最后分享两个小技巧

这篇文章写到这儿,核心链路已经讲透了。最后分享两个我在实际使用中总结的、不太好写进文档里的经验。

第一个技巧是:每新增一张业务表,就把它在“租户隔离清单”和“忽略清单”里过一遍。可以在建表脚本的注释里直接标记-- tenant: isolate-- tenant: ignore,然后在代码评审时对着清单核对。这个习惯说起来简单,但它能帮你把租户边界问题消灭在设计阶段,而不是等到生产环境数据错乱才去补漏。

第二个技巧是:排查线上租户数据错乱时,不要只盯SQL,先看上下文。很多时候问题不在地层,而在线程。用线程名和请求ID做关联,确认同一个请求的所有操作是否都在同一个线程里执行,是否有异步改写了上下文。我遇到过最隐蔽的一次故障,就是某个框架内部用异步线程处理日志,把父线程的上下文带到了子线程,结果子线程又触发了租户查询,导致其他租户的数据被打日志的人查出来了。

多租户是SaaS系统的地基工程,芋道把最麻烦的SQL改写自动化了,但边界定义、上下文治理、异常兜底,这些事情还是需要开发者自己心里有数。把这套控制逻辑彻底搞明白再去写业务代码,遇到诡异问题你会淡定很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询