ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

多租户系统开发实战:数据隔离、租户上下文与业务定制全解析

多租户系统开发实战:数据隔离、租户上下文与业务定制全解析 我第一次认真研究多租户不是从概念文档开始的而是线上出了一个数据串租户的事故。一个接口把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 ThreadLocalLong 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_idorder_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为残留旧值或空值在过滤器入口和出口打印租户IDfinally清理校验非法租户ID不同租户参数互相覆盖缓存key没有租户维度查看Redis key确认是否共用前缀key加入tenant_id配置查询结果按租户隔离异步任务里数据越权或丢失线程池没有传递上下文排查异步方法getTenantId()是否为空使用TransmittableThreadLocal或手动传参定时任务统计错误任务未按租户循环设置上下文检查任务日志中的租户ID任务参数传递租户列表循环设置并clear做多租户系统这几年我最大的体会是技术方案可以逐步演进但上下文传递和SQL层兜底这两件事必须从第一天就做好。等业务量起来后再补排查成本会指数级上升。另一个实用建议是建立一套多租户回归用例集每次发版前用两个虚拟租户的数据跑一遍关键链路把“数据串租户”变成测试阶段就能发现的问题而不是线上事故。多租户本质上并不神秘它要求的只是把“边界意识”内化到每一张表、每一条SQL、每一个异步任务里。希望这些踩坑经验能让你少走几步弯路。
返回列表