
导读接了个 SaaS 单子多家企业客户共用一套系统。第一版我直接在每张表加 tenant_id然后在 Service 里手动拼 where 条件结果漏改一张表两个客户的数据直接串了。后来用 MyBatis-Plus 的租户拦截器统一处理一套代码自动隔离所有租户数据。这篇把落地过程和踩的坑都记下来。qkl-boot 多租户落地MyBatis-Plus 拦截器 tenant_id一套代码隔离所有租户数据先说场景。客户要求一套系统卖给多家企业每个企业只能看到自己的数据。最简单粗暴的方案每张业务表加tenant_id字段查询时手动带上WHERE tenant_id 当前租户。这方案听起来没毛病坑在人总会漏。我第一版就是在 20 多张表里手动拼条件结果漏了简历附件表A 公司的求职者居然在 B 公司的列表里看到了自己的附件客户当场炸了。数据模型tenant_id 加哪索引怎么建业务表统一加tenant_id注意联合索引要把 tenant_id 放第一位否则隔离查询还是要全表扫CREATETABLEjob_(idBIGINTNOTNULLAUTO_INCREMENTCOMMENT主键,tenant_idBIGINTNOTNULLDEFAULT1COMMENT租户ID,titleVARCHAR(100)NOTNULLCOMMENT职位名称,salaryDECIMAL(10,2)DEFAULTNULLCOMMENT薪资,statusTINYINTNOTNULLDEFAULT1COMMENT状态 1招聘中 2关闭,PRIMARYKEY(id),KEYidx_tenant_title(tenant_id,title),KEYidx_tenant_status(tenant_id,status))ENGINEInnoDBCOMMENT职位表;tenant_id 放第一位tenant_id 业务字段的联合索引才能支撑租户内的业务查询。核心MyBatis-Plus 租户拦截器SQL 自动加条件手动拼 where 不可靠MyBatis-Plus 内置了TenantLineInnerInterceptor拦截器会在所有自动生成的 SQL 上自动追加租户条件ConfigurationpublicclassMybatisPlusConfig{BeanpublicMybatisPlusInterceptormybatisPlusInterceptor(){MybatisPlusInterceptorinterceptornewMybatisPlusInterceptor();interceptor.addInnerInterceptor(newTenantLineInnerInterceptor(newTenantLineHandler(){OverridepublicExpressiongetTenantId(){// 从上下文拿当前租户返回 SQL 条件值returnnewLongValue(TenantContext.getTenantId());}OverridepublicStringgetTenantIdColumn(){returntenant_id;}OverridepublicbooleanignoreTable(StringtableName){// 忽略表系统表、租户表本身不加租户条件returnsys_user.equals(tableName)||sys_tenant.equals(tableName)||sys_dict.equals(tableName);}}));returninterceptor;}}拦截器自动给 SELECT/UPDATE/DELETE 加tenant_id ?给 INSERT 自动填充tenant_id。前提是租户上下文必须在线程里传递publicclassTenantContext{privatestaticfinalThreadLocalHOLDERnewThreadLocal();publicstaticvoidsetTenantId(LongtenantId){HOLDER.set(tenantId);}publicstaticLonggetTenantId(){returnHOLDER.get()null?1L:HOLDER.get();}publicstaticvoidclear(){HOLDER.remove();}}登录时从 Token 里解析租户 ID 塞进 ThreadLocal请求结束记得clear()不然线程池复用会串租户。踩坑一自定义 XML SQL 绕过了拦截器数据又串了现象上线两周后A 公司的后台列表出现了 B 公司的职位。查日志WHERE里居然没有 tenant_id 条件。排查过程加租户拦截器后我以为万事大吉但排查发现问题出在 XML 里手写的 SQL。列表页有个复杂的多表联查职位 公司 收藏状态我是手写 XML 的MyBatis-Plus 拦截器对纯手写 SQL 默认不解析没开ignoreSqlSelect那种处理条件没加上。定位思路拦截器只管 MP 自动生成的方法selectById、selectList、wrapper 查询XML 手写 SQL 是漏网之鱼。最终解决把 XML 里所有查询改成强制带 tenant_id 条件并且写进代码规范自定义 SQL 必须显式传 tenantId。给 Mapper 方法加参数SELECT j.*, c.company_name FROM job_ j LEFT JOIN company_ c ON c.id j.company_id AND c.tenant_id j.tenant_id WHERE j.tenant_id #{tenantId} AND j.status 1踩坑二定时任务没有登录上下文租户 ID 全变成默认值现象每天凌晨 2 点的职位自动下架任务把所有租户的过期职位全下架了。排查过程定时任务在独立线程里跑TenantContext.getTenantId()拿到的是默认值 1第一个租户于是拦截器给 SQL 加的是tenant_id 1其余租户的职位没被处理更糟的是任务里有一段遍历租户的逻辑如果没设置上下文默认值直接污染了所有查询。定位思路ThreadLocal 只在线程内生效异步线程、定时任务线程都要显式设置上下文。最终解决定时任务开头遍历租户列表循环内设置上下文、执行、finally 清理Scheduled(cron0 0 2 * * ?)publicvoidautoCloseExpiredJob(){ListtenantIdstenantMapper.selectAllTenantIds();for(LongtenantId:tenantIds){TenantContext.setTenantId(tenantId);try{jobService.closeExpired(tenantId);}finally{TenantContext.clear();}}}同理MQ 消费、异步线程池都要在入口处显式设置租户上下文这是多租户最容易翻车的地方。可直接复用的要点业务表统一tenant_id联合索引把 tenant_id 放第一位。用 MyBatis-PlusTenantLineInnerInterceptor自动加租户条件别手写 where。XML 手写 SQL 是漏网之鱼必须显式传 tenantId写进代码规范。租户上下文用 ThreadLocal请求结束必须 clear防止线程池复用串租户。定时任务、MQ 消费、异步线程入口处显式设置租户上下文。忽略表配置要收敛sys_user、sys_tenant、sys_dict 等系统表别漏别多。上线前写个单测切租户 A 查询断言 SQL 里带tenant_id A。项目源码https://gitee.com/gzqkl/qkl-boot