五月份给人才画像系统做多租户改造,前前后后踩了不少坑,整理一下。
背景:为什么必须做多租户
系统要同时服务多个业务方,每个业务方都有独立的人才数据,互相看不见。最朴素的做法是按业务线单独部署一套——但运维成本随业务方数量线性上涨,数据也彻底割裂,后面做汇总分析根本没得做。所以决定:一套系统,多租户共用,数据隔离。
三种隔离方案怎么选
当时摆在桌面上的是三种经典方案:
- 独立数据库:隔离最彻底,但成本最高,连接管理也麻烦
- 共享数据库、独立 Schema:中等隔离,Oracle 下 Schema 隔离可行,但改造成本高
- 共享数据库、共享表、行级隔离:所有租户数据在一张表里,靠租户标识字段区分
选型时主要考虑:业务方数量可控(不是互联网 C 端场景)、要保留跨租户汇总的可能、改造成本要低。最终选了方案三,共享表 + 行级隔离,租户标识 tenant_id 落到每张业务表上。
落地:MyBatis-Plus 租户插件
后端是 Spring Boot + Jeecg Boot + MyBatis-Plus,租户过滤直接用现成的 TenantLineInnerInterceptor,核心配置就一段:
@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(); tenantInterceptor.setTenantLineHandler(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 GLOBAL_TABLES.contains(tableName); } }); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor;}要点有两个:忽略表清单必须维护好(字典表、系统配置表不能加租户条件,否则所有租户都读不到基础数据);租户上下文要跟着请求生命周期走,用 Filter 从 JWT 里解析出租户 ID,塞进 ThreadLocal,请求结束记得清理——漏了清理,线程池复用会串租户,这是多租户最阴险的坑。
踩坑:一次想当然的返工
上线后某天,业务方反馈:列表页偶发变慢,慢的时候要两秒多。
查日志发现是租户量大之后,tenant_id 没走索引。表当初建的时候只给主键建了索引,tenant_id 是后来改造加的字段,全表扫描命中几十万行,再 filter 就慢。修复很简单:给 (tenant_id, create_time) 建联合索引。但教训值得记下来——加租户字段的时候,就应该同步评估索引,而不是等线上慢了才想起。
另一个容易漏的是手写 SQL 和存储过程。MyBatis-Plus 的拦截器只对它的 SQL 生成生效,我们系统里有不少手写 XML 和存储过程,这些地方要手动拼 AND tenant_id = ?,靠 Code Review 人工盯,容易漏。后来加了约定:所有手写 SQL 必须带租户条件,老代码里一次性补了一遍。
权限体系:租户之上还有一层
行级隔离解决的是”租户之间”,租户内部还有用户权限:角色、菜单权限、数据范围。Jeecg Boot 自带的权限体系够用,数据范围按部门过滤,和租户过滤是两套东西,别混在一起——租户过滤是底线,权限是业务规则,底线要拦在业务规则之前,这个顺序不能反。
小结
- 选方案三之前,先想清楚业务方数量级和有没有跨租户汇总需求,别一上来就独立库
- 租户拦截器省事,但忽略表清单、手写 SQL、索引、ThreadLocal 清理,每个都是雷
- 多租户改造成熟方案一大堆,真正花时间的永远是边界情况