资讯动态

多租户数据隔离实战:独立库路由 vs tenant_id 拦截器

发布时间:2026/10/18 15:25:22 来源:尧图企业网站定制
做 SaaS 躲不开多租户一套代码服务 N 个客户每个客户的数据必须严格隔离——A 客户绝不能看到 B 客户的商品、订单、会员。这事一旦出问题不是 bug是事故。隔离方案就三条路独立库、共享库独立 Schema、共享表加 tenant_id。选哪条直接决定后面几年的运维成本。这篇把我做的两套方案拆开讲——同一个产品线里ERP 用独立库CRM 用 tenant_id为什么不是一个方案走到底。一、先把三条路的账算清楚方案隔离性成本扩展性跨租户统计适用场景独立库每租户一个库最强物理隔离最高库/连接池/备份都翻倍加租户要建库、初始化数据难要跨库聚合租户少、单租户数据量大、有合规要求共享库 独立 Schema强逻辑隔离中中较难租户中等、想省连接共享表 tenant_id弱靠代码保证最低一张表装所有租户最强加租户只是插数据容易一条 SQL 就统计租户多、单租户数据量小怎么选看两个变量租户数量、单租户的数据体量和合规要求。租户不多几十个、但每个租户的商品、订单、门店数据都是几十万上百万条还有客户要求我的数据要能单独备份/迁移——独立库是唯一答案租户多、每个租户数据量小主要是会员、标签这类且都能接受逻辑隔离——共享表 tenant_id成本最低、扩展最顺二、我的项目里为什么两套并存我做的 ERP 和 CRM 是同一条产品线但选了不同方案系统选型理由ERP连锁零售独立库 动态数据源路由单客户数据量大商品、门店、订单、库存都在里面大客户明确要求数据物理隔离、可单独备份出问题时影响面可控CRM会员/标签共享表 tenant_id拦截器租户数量多、单租户数据量小大量跨租户的运营统计需求共享表一条 SQL 就出结果关键点这不是哪个方案更好而是哪张表适合哪个方案。同一个系统里其实可以混核心大表独立库公共配置表共享。三、方案 A 落地独立库 动态数据源路由原理一句话Spring 的AbstractRoutingDataSource会在每次获取连接时回调determineCurrentLookupKey()你返回哪个 key它就用哪个数据源。所以只要把当前租户放进 ThreadLocal路由就自动生效。先定义租户上下文publicclassTenantContext{privatestaticfinalThreadLocalStringCURRENTnewThreadLocal();publicstaticvoidset(StringtenantId){CURRENT.set(tenantId);}publicstaticStringget(){returnCURRENT.get();}publicstaticvoidclear(){CURRENT.remove();}}请求进来时切换数据源请求结束时必须清理ComponentpublicclassTenantDataSourceInterceptorimplementsHandlerInterceptor{OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler){StringtenantIdrequest.getHeader(X-Tenant-Id);DynamicDataSourceContextHolder.push(tenantId);TenantContext.set(tenantId);returntrue;}OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex){DynamicDataSourceContextHolder.poll();TenantContext.clear();}}项目里用的是 dynamic-datasourcepush()/poll()是它的线程栈式上下文支持嵌套切换。新租户接入时动态注册数据源即可不用重启publicvoidregisterTenant(StringtenantId,StringjdbcUrl,Stringusername,Stringpassword){DruidDataSourcedsnewDruidDataSource();ds.setUrl(jdbcUrl);ds.setUsername(username);ds.setPassword(password);ds.setMaxActive(20);DynamicRoutingDataSourcerouting(DynamicRoutingDataSource)dataSource;routing.addDataSource(tenantId,ds);}核心思路租户上下文放 ThreadLocal → 拦截器统一 push/poll → 动态数据源负责路由 → 新租户动态注册。四、方案 B 落地共享表 tenant_id 自动注入共享表最怕的就是哪条 SQL 忘了加tenant_id。靠人写是靠不住的得让框架自动加。MyBatis-Plus 提供了现成的租户插件BeanpublicMybatisPlusInterceptormybatisPlusInterceptor(){MybatisPlusInterceptorinterceptornewMybatisPlusInterceptor();interceptor.addInnerInterceptor(newTenantLineInnerInterceptor(newTenantLineHandler(){OverridepublicExpressiongetTenantId(){returnnewStringValue(TenantContext.get());}OverridepublicStringgetTenantIdColumn(){returntenant_id;}OverridepublicbooleanignoreTable(StringtableName){returnIGNORE_TABLES.contains(tableName);}}));returninterceptor;}配好之后selectById、update、delete都会自动带上tenant_id ?insert还会自动补字段值——业务代码里一行都不用写。ignoreTable维护的是不需要隔离的表系统字典、租户信息表、定时任务记录这类。核心思路隔离逻辑沉到框架层拦截器业务代码保持干净——这比在每个 Mapper 方法手写tenant_id可靠得多。五、这类方案的 4 个坑坑 1ThreadLocal 上下文丢失直接串库异步任务、定时任务、MQ 消费线程里租户上下文是拿不到的——因为这些线程不是 Web 请求线程拦截器压根没执行。结果是查询落到默认数据源或上一次残留的租户A 租户的任务操作了 B 租户的数据。解决上下文要显式传递定时任务按租户维度遍历执行XxlJob(tenantJob)publicvoidrun(){for(StringtenantId:tenantMapper.selectAllTenantIds()){try{DynamicDataSourceContextHolder.push(tenantId);TenantContext.set(tenantId);doBusiness();}finally{DynamicDataSourceContextHolder.poll();TenantContext.clear();}}}这里finally里的清理不能省。线程池是复用的上下文不清理下一个任务拿着上一个租户的上下文跑就是实打实的串库。坑 2数据源切换和事务的顺序反了Spring 事务在开启时就获取连接并绑定到当前线程。如果方法上的Transactional先执行、数据源在事务内部才切那这次切换对当前事务无效——事务已经拿着旧数据源的连接了。// 错的事务先开数据源切换在事务内白切Transactionalpublicvoidsync(){DynamicDataSourceContextHolder.push(tenantId);// 这里的 SQL 用的还是事务开启时的数据源}解决数据源切换必须在事务外层——放拦截器里请求进来就切好或者把切换和事务拆成两个方法、由外层切好再调内层事务方法。用注解式动态数据源时同理注解要加在事务方法的调用方上。坑 3租户多了连接池把资源吃光每个租户一个数据源就是每个租户一个连接池。连接池默认会预建连接minIdle租户数一多内存和数据库连接数同时暴涨——数据库的max_connections是有限的几十个租户各开 20 个连接很快就打满了。解决控住每个租户池的上限maxActive调小、minIdle设为 0 或 1并给空闲租户池做淘汰回收。宁可让冷租户首次请求慢一点也不能让连接数失控。坑 4租户拦截器也有漏网之鱼TenantLineInnerInterceptor覆盖不了所有情况手写的原生 SQLXML 里拼接、Select裸写不一定被正确改写尤其是union、复杂子查询代码里写了InterceptorIgnore(tenantLine true)的地方是主动跳过的用之前得想清楚跨租户的后台管理功能平台运营看所有租户需要临时关掉拦截别忘了关完要恢复解决关键查询加单元测试或接口测试兜底验证生成的 SQL 里真的有tenant_id条件跨租户功能用明确的开关比如独立的数据源或忽略注解别偷偷绕过。总结坑本质解决1 上下文丢失非请求线程没有租户上下文显式传递 finally清理2 切换晚于事务事务已绑定旧连接切换放事务外层3 连接池膨胀每租户一个池控上限 空闲池回收4 拦截器漏网拦截器改写不全覆盖ignoreTable SQL 断言选型心法先看两个数——租户数量和单租户数据量。租户少、数据大、有合规要求独立库租户多、数据小、要跨租户统计共享表加tenant_id。真要两套并存也没问题关键是让隔离逻辑统一收口在框架层拦截器或路由数据源而不是散落在每个业务方法里——散着写迟早有一条 SQL 忘加条件。你项目里的多租户是怎么做的评论区聊聊。相关阅读Spring 事务失效的 11 个真实场景——代码看着对数据就是不对。上面坑 2 的事务与数据源顺序问题就是其中一个变种。关注「今天写BUG了么」看更多

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑