多租户隔离失效,我踩过的 3 个坑

先说结论

多租户系统里数据串租户,十次里有九次不是拦截器写错了,而是它压根没被触发。

你把拦截器配好了,单测跑过,联调也正常。某天忽然发现,A 客户的后台里出现了 B 客户的数据。回头看代码------没人动过,配置还在,日志里也没有异常。

问题藏在一个隐蔽的地方:租户拦截器是挂在 SQL 解析链上的。它做的事情是「改写 SQL」------把

sql 复制代码
select * from animal where id = 1

改写成

sql 复制代码
select * from animal where id = 1 and tenant_id = 7

既然是改写,前提就是它能解析出你的 SQL 结构,才有地方插条件。

只要有一条路径绕过了它,隔离就是破的。 下面三个坑都是真实踩过的,看着是三件事,其实同一个根因的不同变体。

坑一:派生表没带出租户列,SQL 直接报错

有一次大屏的统计查询直接挂了,报错是:

sql 复制代码
Unknown column 'tenant_id' in 'where clause'

看 SQL 结构就明白了,那条查询长这样:

sql 复制代码
select t.* from ( ...子查询... ) t where ...

拦截器把租户条件注入到了最外层 的 WHERE,但派生表里没有把 tenant_id 这一列带出来------外层引用一个不存在的列,SQL 就炸了。

修法不是关掉拦截器,而是让子查询显式带出这一列 :两个统计查询的 select 里补上 tenant_id,带 group by 的那个同步把 tenant_id 加进分组,租户条件就顺着派生表生效了。

教训 :拦截器只按自己的规则往里插列名,它不知道你的子查询导出了哪些列。用了派生表,就得自己保证租户列被带出来。

坑二:有些表本来就不该被拦

有几类表天生没有租户概念------租户表本身、字典、公告、定时任务、导入模板......这些是全局共享的。

而拦截器默认不认人,见表就拦 。被拦的后果是查询直接空掉(页面一片空白,看起来像数据丢了),或者像上面那样报 Unknown column。

正确解法是维护一份显式白名单 ,把这些表逐条排除。我们项目里这份清单是硬编码在租户自动配置类里的,一行一行 add 出来,现在二十来张表。

它为什么必须是「显式清单」?因为每新增一张全局表,你都得主动做一次「它要不要带租户」的决定。这个决定不能省略,省了就出事。

最危险的做法是反过来:有人嫌一条条配太麻烦,干脆把拦截器关掉。一个表的麻烦,换来全库不隔离。

坑三:超管切了租户,却还能看到别家的数据

这一坑修得最重------一次性动了二十多个文件,把一批查询的租户隔离补齐。

当时暴露出来的问题有三个:

  1. 查询绕过 Service 直接走了 Mapper------上层负责租户处理的那一层被跳过,隔离自然失效;
  2. 管理员切换到某个租户之后,仍然能查到其他租户的数据------「切租户」这个动作本身没有真正约束住数据范围;
  3. 统计类查询用了派生表 ,拦截器处理不了,只能把租户条件显式写进 SQL。

还有一类更隐蔽的:没有 HTTP 上下文的线程。

比如设备数据采集线程------它跑在消息队列的回调里,压根没有登录会话,租户 ID 无从取起。这类场景必须先按业务反查出「这条数据属于哪个租户」,再手动切到那个租户去落库。写不对的话,数据会全部落到 tenant = 0(平台层),变成跨租户污染。

顺带说一下机制:租户 ID 通常靠 ThreadLocal 传递------请求进来时塞进当前线程,拦截器再从当前线程取出来拼进 SQL。而 ThreadLocal 的生命周期绑在线程上,一跨线程就断:

场景 后果
@Async / 线程池 新线程取不到租户 ID,条件没拼上,查出全量数据
定时任务(Quartz 等) 线程复用,残留上一个任务的租户 ID,不报错,静默写进错误的租户
Feign 远程调用 默认不传租户标识,下游服务等于没有租户上下文
消息队列回调线程 压根没有登录会话,全靠手动切

定时任务那条最阴------它不抛异常,只是默默把数据写到了别人家。

一次改二十多个文件的教训是:租户隔离不是「配好拦截器就完事」,它是每一处查询都要过一遍的纪律。漏一处,就是一次越权。

上线前扫这五条

  1. 开 SQL 日志全量扫一遍,有没有不带租户条件的语句;
  2. 挨个看派生表 / 子查询:租户列有没有被带出来;
  3. 白名单是否完整,且没有任何地方用开关关过拦截;
  4. 所有 @Async / 定时任务 / 线程池 / 消息回调入口,租户 ID 是否显式传递;
  5. Feign 请求头里带没带租户标识。

隔离这件事,配好了不等于生效了。它生效的前提是:每条路径都乖乖从拦截器底下过一遍。


(注:本文用「某 SaaS 平台」代指实际项目,表名、字段名、配置项均已替换,不含任何真实客户信息。)

相关推荐
IT_陈寒1 小时前
Java缓存踩坑记:明明设置了过期时间,怎么还是读到脏数据?
前端·人工智能·后端
用户8662978480081 小时前
数据库报错"坏块"别急着删库重来,试试 KingbaseES 的离线修复工具
后端
jose1 小时前
URL 里多写了一个端口,为什么请求还能调通?
java·后端
用户8356290780511 小时前
使用 Python 在 Excel 中识别并高亮重复数据与唯一数据
后端·python
CCblandy1 小时前
Agent 工具调用怎么编排?本机实测:并行只到 2.63 倍,慢的先发省 30ms,CPU 活加 worker 无效
人工智能·后端·python·性能优化
茉莉玫瑰花茶1 小时前
GO [ 标准库 ]
开发语言·后端·golang
用户8356290780511 小时前
使用 Python 在 Excel 中查找、替换数据并高亮显示
后端·python
Nturmoils3 小时前
本来只想给内网开个口子,Natapp 却多给了一道防护
后端