我第一次认真研究多租户,不是从概念文档开始的,而是线上出了一个数据串租户的事故。一个接口把A公司的订单数据返回给了B公司,虽然只影响了几个人,但那天的排查过程至今难忘。后来做SaaS平台、做低代码系统,也看过若依的多租户扩展,研究过dify社区版1.10多租户的工作空间设计,越来越确认一件事:不管底层选哪种隔离方案,多租户下的系统业务开发都绕不开三个核心问题——数据怎么隔、租户上下文怎么传、业务定制怎么做。这篇文章想把这些问题一次说透,也把我在实际项目里踩过的坑和沉淀的做法整理出来,给正在做多租户改造或新系统设计的后端开发、架构师一点参考。
1. 先搞清楚:多租户到底解决什么问题
1.1 从一栋写字楼说起
多租户最简单的理解,就是多个客户共同使用同一套系统,但彼此看不到对方的数据。可以想象成在一栋写字楼里办公:电梯、水电、保洁、园区网络都是共享的,但每家公司的办公室是独立的,门禁卡只能刷开自己公司的区域。软件里的租户就是这些入驻公司,系统实例是整栋楼,数据是各家的办公室;用户是楼里的员工,员工属于哪家公司,就只能进哪家公司的门。
这里特别容易混淆的是“租户”和“用户”。租户是一个组织或业务空间,用户是自然人,一个租户下可以有多个用户,一个用户也可能属于多个租户。在系统设计里,租户表、用户表、用户和租户的关联表都是基础数据,后面讲权限模型时会再展开。
1.2 多租户给业务开发增加的三个维度
多租户给业务开发增加的维度,我归纳成三个:数据隔离、上下文传递、业务定制。数据隔离解决的是“A租户不能看到B租户的数据”,落点是表结构、SQL、缓存、文件存储;上下文传递解决的是“一次请求从进入到返回,系统始终知道当前是哪个租户”,落点是拦截器、ThreadLocal、网关Header;业务定制解决的是“不同租户可以用不同的菜单、字段、参数、功能开关”,落点是配置体系和扩展点设计。
这三个问题不是孤立的。隔离做不好,系统随时可能数据泄露;上下文传不好,隔离就无从谈起;定制做得太死,租户就会抱怨系统不好用。多租户的业务开发之所以比普通系统复杂,就是因为任何一个业务模块,都要同时回答“这个数据属于哪个租户”“当前请求是哪个租户”“这个租户要的展示和逻辑是否和别人不同”。
1.3 若依和dify里的多租户,形态其实不太一样
热门开源项目的多租户实现,可以帮我们理解不同形态是怎么落地的。例如若依系列做多租户扩展时,最稳妥、也最常见的做法是在业务表增加tenant_id字段,配合权限框架在SQL层做数据过滤。它本质上属于共享数据库、共享表的模式,好处是改动小、好上手,适合快速给企业项目加租户能力。dify社区版到了1.10以后,多租户基本围绕工作空间(workspace)展开:一个工作空间对应一组资源和成员,成员在空间里有不同角色,API调用通过Key识别工作空间。它同样没有为每个租户拆分数据库,但用空间概念把用户、知识库、应用、文件这些资源串在了一条归属链上。
看这两个项目的价值,不在于争论谁的实现更好,而在于明白“租户”并不一定叫公司,也可以是工作空间、项目组、门店。关键是找对业务上的隔离边界,然后把这个边界贯穿到所有数据模型和操作链路里。
2. 租户隔离策略选型:别一上来就选最重的
2.1 三种主流隔离模式对比
多租户系统最底层的决策,就是数据怎么存。业内一般分成三种模式,也可以说是四个层级。我习惯用下面这个表来做团队内部讨论:
| 隔离模式 | 数据存储方式 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 独立数据库 | 每个租户一个库 | 隔离最强,恢复和备份清晰 | 成本高,运维量大,表结构变更要逐库执行 | 金融、医疗、合规要求高的场景 |
| 共享数据库、独立Schema | 每个租户一个Schema | 隔离中等,可单独迁移和备份 | 连接数管理复杂,跨租户统计麻烦 | 有一定合规要求但对成本敏感的B端系统 |
| 共享数据库、共享表 | 所有租户同一套表,用tenant_id区分 | 开发成本最低,表结构调整方便 | 隔离风险高,必须靠代码和规范兜底 | SaaS起步期、内部系统、工具类平台 |
还有把共享表按租户ID范围做分片或分库的,本质上是前两种的变体。真正动手前,建议把四种形态都画进评估表,逐项打过项目需求再做决定。
2.2 按业务阶段选隔离级别
很多人一谈多租户,第一反应是“每个租户一个独立数据库,最安全”。从工程上看这不一定是最优解。租户数量少而单租户体量大、字段差异极大、数据有强合规隔离要求时,独立库确实省心;但如果客户是一批刚起步的小公司,日活不高,每个租户一个库会带来大量空转资源和连接负担,而且表结构修改要逐个库去迁移,开发效率直线下降。
我一般会建议SaaS业务这样分阶段:早期用共享表+租户ID,靠SQL层自动过滤和数据约束兜底;当出现少数客户需要强隔离时,把这类客户升级到独立Schema甚至独立数据库,通过租户级别路由隔离;等客户规模再上去,再考虑分库分表。不要为了“未来可能很强隔离”而过度设计,多租户系统的演进空间应该一开始就留好,但不必一步到位。
2.3 开源项目里的隔离策略参考
回到开源项目。若依的多租户扩展通常以共享表为主,核心是在框架的数据权限层增加租户过滤;dify社区版的多租户则是用工作空间加成员关系来建模,底层以共享库为主,通过应用数据和知识库等表的归属字段完成隔离。两个项目都不约而同选择了共享表的起步方案,因为对社区版来说这是成本和易用性之间的平衡点。
如果你的业务正在选型,要注意一个容易忽略的问题:隔离策略不是只影响存储,还会影响业务流程。比如独立库模式下,一个跨租户的管理员操作要遍历所有库;共享表模式下,管理员反而容易通过单条SQL完成批操作,但也要注意别把不同租户的数据扫到一起。选择隔离级别时要连管理后台的设计、报表统计、租户平滑迁移一起考虑。
3. 租户上下文:让业务代码不感知租户的关键
3.1 租户上下文是什么
租户上下文,就是当前请求从进入到返回期间,“我是谁”的租户身份标记。它一般是一个租户ID,也可以带上租户类型、套餐等级、是否试用等附加信息。没有上下文的系统,查询时永远不知道该过滤什么,这就是很多项目串租户的开始。
你可以把租户上下文想象成进入园区时发的门禁卡:刷卡进楼后,到任何一层、任何一个房间,系统都能通过这张卡判断你属于哪家公司。它必须在进入系统时发放,并在离开时收回,否则下一刷可能刷出别人的身份。
3.2 请求链路里如何传递租户ID
最常见的实现方式,是在网关或过滤器层解析请求里的租户标识。有人习惯放在Header,比如X-Tenant-Id;有人习惯把它编码在JWT里;也有系统通过域名或子域名区分租户。无论哪种,落地时都要有一个全局的TenantContext来暂存当前请求的租户ID。
public class TenantContext { private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>(); public static void setTenantId(Long tenantId) { TENANT_ID.set(tenantId); } public static Long getTenantId() { return TENANT_ID.get(); } public static void clear() { TENANT_ID.remove(); } }然后在Filter里设置和清理:
public class TenantFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; try { String tenantId = httpRequest.getHeader("X-Tenant-Id"); TenantContext.setTenantId(Long.valueOf(tenantId)); chain.doFilter(request, response); } finally { TenantContext.clear(); } } }这里有两个细节必须注意:第一,不要只set不clear,否则在线程池复用的容器里,下一个请求可能读到上一个租户的ID;第二,公用接口、登录接口、健康检查接口要提前放行,不能用空的租户ID去查数据。建议在过滤器里做白名单判断,未匹配到的接口直接拒绝。
如果是微服务架构,网关解析完租户后,要把租户ID通过Header向下游传递,下游服务自己也要做一次校验。别轻信Header里的值,至少要做格式校验和租户有效性校验,防止构造请求模拟其他租户。
3.3 异步任务、消息队列、定时任务不能丢上下文
多租户上下文在同步请求里很好做,麻烦的是线程切换。服务里用线程池处理任务时,ThreadLocal默认不会从主线程传给子线程,这会导致异步逻辑里的租户ID为空。推荐两种处理方式:一种是使用阿里开源的TransmittableThreadLocal,做线程池变量的自动传递;一种是在提交任务时手动把租户ID传到任务里,在新线程重新设置上下文。手动方式虽然啰嗦,但在跨服务、跨系统的场景里更可控。
消息队列消费端也要类似处理。生产者发送MQ消息时,需要把tenantId作为消息头或业务字段一并发送;消费者收到消息后,在消费逻辑开始前调用TenantContext.setTenantId设置租户上下文,消费完再清理。定时任务往往是重灾区:调度平台可以给任务传参,从参数里取租户ID;一个任务要处理多个租户,就循环调用,每轮循环设置一次上下文,处理完立刻清理。
executor.execute(() -> { TenantContext.setTenantId(taskTenantId); try { doBusiness(); } finally { TenantContext.clear(); } });4. 数据隔离落到SQL层:自动改写与强制兜底
4.1 别让业务代码手写tenant_id
一些团队早期用最朴素的方式做隔离:每个Mapper的SQL都手动加and tenant_id=#{tenantId}。业务少的时候还能应付,业务一多,只要一个开发忘记加,或者多个WHERE条件拼接顺序出错,就会产生一个巨大的数据漏洞。手写tenant_id最大的风险不在写错,而在“漏写”。人不可能在每个查询里都保持同样的警醒。
所以要靠框架层面把租户过滤变成“默认行为”,业务代码里尽量不出现tenant_id,让它在ORM层自动拼接。这既能减少代码噪音,又能降低漏加条件的事故率。如果团队用的是MyBatis-Plus,推荐直接用官方提供的TenantLineInnerInterceptor,它的逻辑就是自动把租户条件拼到需要执行的SQL上。
4.2 自动填充租户ID与SQL自动改写
配合自动过滤,写入时的租户ID也要自动填充。MyBatis-Plus里可以通过MetaObjectHandler实现:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { Long tenantId = TenantContext.getTenantId(); this.strictInsertFill(metaObject, "tenantId", Long.class, tenantId); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }此外再注册租户SQL拦截器:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { @Override public Expression getTenantId() { return new LongValue(TenantContext.getTenantId()); } @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { return "sys_config".equals(tableName) || "sys_dict".equals(tableName); } })); return interceptor; }我建议对全表默认开启租户过滤,只有明确标注的系统配置表、全局数据字典、公共服务表才放进ignoreTable白名单。白名单一定是“最小集”,而不是为了省事把所有表都忽略。对于必须忽略租户过滤的Mapper方法,可以用注解明确声明,做到每次忽略都有据可查。
还需要注意一个细节:自动改写SQL虽然方便,但遇到子查询、join、union时容易出错。拦截器能否正确处理取决于框架实现,所以不要以为配好了就万事大吉,必须在压测和测试用例里覆盖多表关联场景。复杂SQL宁可拆成多次查询,也不要在一条SQL里绕晕拦截器。
4.3 表设计与索引里的租户维度
既然以tenant_id作为隔离标志,表设计时就要把它当作一等公民。所有业务主表、明细表、日志表都要有tenant_id字段,并且和业务唯一键一起建唯一索引。例如租户内的订单流水号不能重复,若无脑地给order_no建唯一索引,两个租户都生成单号ORDER-001,第二个插入就直接报唯一键冲突;改成tenant_id+order_no组合唯一索引,才符合业务语义。
涉及统计报表时,常用条件往往是tenant_id+时间范围,索引设计也要考虑这个组合。可以建立一个复合索引(tenant_id + created_at),让租户过滤先行。数据库的查询计划设计得越好,多租户场景下的资源抢占就越可控。另外,建议把视图、存储过程也纳入租户过滤设计,如果是动态拼接SQL的报表系统,要额外小心,避免租户ID只作用在第一层子查询。
这个环节最容易出的问题是“只给主表加tenant_id,关联子表忘了加”。一个订单头带订单明细,订单头有租户ID,订单明细没有,通过主表关联还好,一旦把明细表单独拉出来统计,数据就串了。经验做法是:所有业务表都带tenant_id,不依赖join父表来推断归属。
5. 多租户下的业务定制:权限、菜单、字段扩展
5.1 用户、租户、角色是什么关系
多租户系统的权限模型,我强烈建议采用“用户全局唯一,租户内分配角色”的结构。也就是说一个用户名可以在系统里注册一次,但TA在不同租户里可以有不同角色。若依的权限模型偏“用户-部门-角色”,加上多租户后,要处理的是“用户属于哪个租户,再在租户内维护部门、角色和菜单权限”;dify的模型则是账号加工作空间成员,账号全局唯一,在工作空间内分owner/editor等角色。两者的共同点在于,租户和用户是多对多关系,必须用关联表承载。
因此,基础表至少要包含:tenant(租户)、user(用户)、tenant_member(租户成员)、role(角色)、permission(权限)、tenant_role(租户角色关联)。业务代码在判断权限时,不能只问“用户有没有这个权限”,还要问“用户在当前租户下有没有这个权限”。很多越权漏洞,就是因为只校验了角色权限,没有校验租户上下文的归属关系。
5.2 租户级菜单、字典和参数配置
不同租户要的菜单结构、首页皮肤、流程模板往往不一样。如果每个租户都复制一份配置,后续系统升级就要逐租户改,维护量惊人。更推荐“默认配置+租户覆盖”的方式。以菜单为例:系统级菜单表里tenant_id为空表示通用菜单,tenant_id有值表示该租户的定制菜单;业务加载菜单时,先查该租户的定制菜单,再合并通用菜单。这样能覆盖80%的租户差异,又不用为每个租户建表。
数据字典、参数配置也可以做同样的设计:查询时优先取租户ID匹配的那一行,取不到再取租户ID为空的那一行。注意要控制默认为空还是租户覆盖,最好在配置中显式指定,否则代码里到处是if (tenantValue != null ? tenantValue : globalValue),可读性会很差。做一个通用配置查询工具类,一行方法拿到最终的解析值,所有业务模块复用。
5.3 字段级扩展和功能开关怎么做
租户A要记客户生日,租户B要记客户VIP等级,这种字段差异几乎每个SaaS项目都会遇到。我的建议是分三个档次。低层是预留几个通用扩展字段,比如ext1到ext5,临时顶一下;中层是主表加一个JSON字段,把不确定的扩展属性放进去;高层是真正的扩展子表,每行是tenant_id+业务主键+字段名+字段值。三者的取舍是:扩展字段最简单但有上限,JSON列最灵活但查询统计困难,扩展子表最正规但开发量最大。
功能开关也是租户定制的重要部分。可以在参数配置表里存一组布尔值,例如是否开启多级审批、是否显示库存预警、是否支持会员积分。业务代码里通过配置服务读取这些开关,而不是用一堆if判断租户ID。这样运营人员可以在后台单独配置某个租户的功能,不需要发版。这里的关键是:功能开关的变化要能及时刷新,缓存里一般要把tenantId作为键的一部分,避免租户之间串配置。
6. 多租户开发中常见的坑与排查实录
6.1 数据串租户的典型现场
这里写一个真实复盘。当时系统上线第二天,客服收到B租户反馈,在列表里看到了一批明显不属于自己的订单号。我第一时间查了操作日志和接口参数,发现请求的租户ID没问题,过滤条件也没问题,问题出在报表查询里用了一条手写SQL,只按org_id关联,完全没带tenant_id。这个场景非常典型:手写SQL绕过框架拦截,或者关联子表时依赖父表隔离,导致漏网之鱼。
修复步骤是:第一,立即下线问题报表;第二,在原SQL补上租户条件,短期内止血;第三,排查所有类似手写SQL,看是否有同样的漏过滤;第四,在租户SQL拦截器里把关键业务表加进强制处理表,禁止使用忽略注解。更重要的是,在测试环境中构造两个租户的数据,编写“租户A查询不到租户B数据”的用例,把这类回归测试纳入CI。
6.2 ThreadLocal泄漏与线程池复用
另一个高频问题是ThreadLocal在不同请求间串数据。典型场景:容器线程池里执行完A租户请求后没有清理,线程被还给池子,下一个B租户请求复用这个线程时,TenantContext.getTenantId()返回的还是A的ID,于是B租户的数据查询全被过滤成了A租户的数据,表现就是“数据越查越少”或“部分功能空白”。
排查要点是看日志里有没有租户ID和登录用户ID不匹配的记录,或者在过滤器中加一条调试日志,每次请求结束打印线程名和租户ID。解决方法很明确:过滤器的finally块里统一调用TenantContext.clear(),线程池提交任务时显式传租户参数,不依赖隐式传递。只要做到“请求结束必清理,线程切换必传参”,这类问题基本能根除。
6.3 缓存、MQ、定时任务的租户隔离
缓存是另一个容易忽略的地方。如果Redis的key只写成order:detail:1001,两个租户只要业务ID相同就会相互覆盖。经验做法是所有业务缓存key都要带上租户ID,比如tenant:123:order:detail:1001。CacheManager或RedisTemplate层面可以做KeyGenerator的逻辑,但最保险的还是业务代码中强制遵守key命名规范。
MQ消息必须携带租户ID,并且消费逻辑里像请求一样设置租户上下文。定时任务特殊之处在于没有外部请求,必须从任务参数或数据库中读取要处理的租户列表,然后逐个设置上下文去跑。之前我遇到过一个统计任务,把本来应该汇总整个租户的数据,由于缺失租户上下文的设置,统计到了全局默认租户,最后报表数据全错。给定时任务增加租户维度的日志,能显著提高排查效率。
6.4 多租户问题速查表
| 场景 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 列表数据串租户 | 手写SQL漏tenant_id,关联子表未过滤 | 查MyBatis SQL日志,确认是否存在不带tenant_id的SQL | 启用ORM层自动改写,删除固定忽略名单 |
| A租户请求查不到任何数据 | ThreadLocal中租户ID为残留旧值或空值 | 在过滤器入口和出口打印租户ID | finally清理,校验非法租户ID |
| 不同租户参数互相覆盖 | 缓存key没有租户维度 | 查看Redis key,确认是否共用前缀 | key加入tenant_id,配置查询结果按租户隔离 |
| 异步任务里数据越权或丢失 | 线程池没有传递上下文 | 排查异步方法getTenantId()是否为空 | 使用TransmittableThreadLocal或手动传参 |
| 定时任务统计错误 | 任务未按租户循环设置上下文 | 检查任务日志中的租户ID | 任务参数传递租户列表,循环设置并clear |
做多租户系统这几年,我最大的体会是:技术方案可以逐步演进,但上下文传递和SQL层兜底这两件事必须从第一天就做好。等业务量起来后再补,排查成本会指数级上升。另一个实用建议是建立一套多租户回归用例集,每次发版前用两个虚拟租户的数据跑一遍关键链路,把“数据串租户”变成测试阶段就能发现的问题,而不是线上事故。多租户本质上并不神秘,它要求的只是把“边界意识”内化到每一张表、每一条SQL、每一个异步任务里。希望这些踩坑经验能让你少走几步弯路。