多租户和数据权限怎么共存?扒完拦截器注册链路,我找到 4 个隐蔽的坑

文章来源声明: 原文作者:她的男孩; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684102772082966582; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合用 MyBatis-Plus 搭建权限体系的 Java 团队。六个可带走的排查建议能直接省掉半天定位时间,尤其适合排查“列表总数对不上”这类静默问题。

> 这是"框架源码拆解"系列第三篇。前两篇分别拆了数据权限拦截器的 SQL 改写(681 行)、多租户 starter(1586 行)。第一篇文末留了个尾巴:这两个拦截器都要改 SQL,一起用的时候谁先谁后?这篇来还债。

一个我没写过的 COUNT SQL,报错了

先讲现象。

一个带分页的列表接口,配了数据权限。本地测试一切正常,上了有一套嵌套查询的真实数据集,接口 500。

异常信息指向 SQL 解析失败,位置是 SELECT COUNT( 的左括号。

问题是——这个 COUNT 语句我一行都没写过。 MyBatis-Plus 的分页插件自动生成的。

排查了一圈才发现,这条 count SQL 不是我写的,但要经过两个拦截器的解析:数据权限拦截器要用 JSqlParser 解析它、按配置改写;多租户拦截器也要解析它、追加租户条件。只要其中一个解析器在某种嵌套结构上翻车,整个查询就挂了。

顺着这条线往下扒,我发现"多租户 + 数据权限共存"这件事,表面上是两个插件各干各的,实际上是四个隐蔽的耦合点。下面按执行链路一个个拆。

先搞清执行模型:拦截器是串行流水线

MyBatis-Plus 的 MybatisPlusInterceptor 是个壳,真正的逻辑在它内部的 List<InnerInterceptor>。每次查询,beforeQuery按注册顺序依次调用每一个拦截器,每个拦截器都能读到同一个 BoundSql,也都能改写它。

改写的方式是:

PluginUtils.<span>MPBoundSql</span> <span>mpBoundSql</span> <span>=</span> PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(modifiedSql);

写进去之后,后面执行的拦截器读到的就是改写后的 SQL。所以顺序不是无所谓——它是数据流的依赖关系。

全景:这个项目里的真实注册顺序

<span>@Bean</span>
<span>public</span> MybatisPlusInterceptor <span>mybatisPlusInterceptor</span><span>()</span> {
    <span>MybatisPlusInterceptor</span> <span>interceptor</span> <span>=</span> <span>new</span> <span>MybatisPlusInterceptor</span>();

    <span>// 1. 先添加其他模块注册的拦截器(按优先级顺序)</span>
    <span>if</span> (innerInterceptors != <span>null</span> && !innerInterceptors.isEmpty()) {
        log.info(<span>"检测到 {} 个自定义拦截器,开始注册"</span>, innerInterceptors.size());
        innerInterceptors.forEach(inner -> {
            interceptor.addInnerInterceptor(inner);
            log.info(<span>"注册拦截器: {}"</span>, inner.getClass().getSimpleName());
        });
    }

    <span>// 2. 添加分页插件</span>
    interceptor.addInnerInterceptor(paginationInnerInterceptor());

    <span>// 3. 添加乐观锁插件</span>
    interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());

    log.info(<span>"MyBatis-Plus拦截器初始化完成,共 {} 个拦截器"</span>, interceptor.getInterceptors().size());
    <span>return</span> interceptor;
}

注意第 1 步:自定义拦截器是通过 List<InnerInterceptor> 注入进来的,也就是系统里所有实现了 InnerInterceptor 的 Bean,Spring 帮你收集好,你按收集到的顺序注册。

本项目里有两个:

<span>// forge-starter-datascope</span>
<span>@AutoConfiguration</span>
<span>@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)</span>   <span>// 最高优先级</span>
<span>public</span> <span>class</span> <span>DataScopeAutoConfiguration</span> {

<span>// forge-starter-tenant</span>
<span>@AutoConfiguration</span>
<span>@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)</span>  <span>// 高优先级</span>
<span>public</span> <span>class</span> <span>TenantAutoConfiguration</span> {

于是实际执行链路是:

<span>SQL</span> 进入
   │
   ▼
┌──────────────────────────────────────────────┐
│ ① DataScopeInterceptor                       │
│  按 mapperId 查数据权限配置 → JSqlParser 改写  │
│  追加部门<span>/</span>行政区划等权限条件到 <span>WHERE</span>            │
└──────────────────────────────────────────────┘
   │ mpBoundSql.sql(改写后的 <span>SQL</span>)
   ▼
┌──────────────────────────────────────────────┐
│ ② TenantLineInnerInterceptor                 │
│  tenant 表 → 追加 <span>WHERE</span> tenant_id <span>=</span> ?         │
└──────────────────────────────────────────────┘
   │
   ▼
┌──────────────────────────────────────────────┐
│ ③ CountOnePaginationInnerInterceptor(分页)   │
│  改写 LIMIT <span>+</span> 推导 count 语句                  │
└──────────────────────────────────────────────┘
   │
   ▼
┌──────────────────────────────────────────────┐
│ ④ OptimisticLockerInnerInterceptor(乐观锁)  │
└──────────────────────────────────────────────┘

怎么验证这个顺序? 不用猜——启动日志里 注册拦截器: xxx 打出来的顺序,就是真实执行顺序。这也是我最推荐的一条排查手段:接口改写出问题,先看日志里拦截器的注册顺序。

顺带说个硬性规则:分页插件必须是最后一个改写类拦截器。MyBatis-Plus 官方文档明确要求多租户插件排在分页插件之前。原因是分页插件在推导 count 语句时会做 SQL 结构简化(剔除 ORDER BY、优化 JOIN),如果租户条件那时还没进 SQL,简化后的 count 语句可能就把这些条件丢了——表现就是"总数把这些数据算进去了,但列表里查不出来"。

下面是四个真正让踩坑的耦合点。

坑 1:顺序是隐式契约,没人用 @Order 保证

翻遍这两个 starter 的源码,你会发现一个尴尬的事实:

  • DataScopeInterceptor implements InnerInterceptor —— 没有 getOrder()
  • TenantLineInnerInterceptor(MyBatis-Plus 自带)—— 也没有

也就是说,执行顺序完全建立在"两个 @AutoConfigureOrder 数字大小"这个隐式契约上。这个契约没有任何测试保护(我专门搜过,项目里没有断言拦截器链顺序的测试),也没有在任何地方写下来。

一旦发生下面任意一种情况,顺序就会静默改变:

  • 有人给某个拦截器 Bean 加了 @OrderList 注入会按 @Order 重排)
  • 有人调整了 @AutoConfigureOrder 的值(比如为了修另一个启动顺序问题)
  • 某个 starter 的引入方式变了,导致 Bean 注册顺序变化

顺序变了不一定报错,可能只是"某些 SQL 的条件少加了",或者"count 数字对不上"。静默的错误比报错难查十倍。

建议:如果你们的拦截器链是有语义依赖的,用 @Order 显式声明,并写一个断言顺序的测试(比如注入 MybatisPlusInterceptor 断言 getInterceptors() 的类名顺序)。别依赖 @AutoConfigureOrder 这种启动层面的副作用。

坑 2:同一份 BoundSql,后跑的人读的是前一个人的成果

两个拦截器改写的是同一个 BoundSql 对象,写法都是:

PluginUtils.<span>MPBoundSql</span> <span>mpBoundSql</span> <span>=</span> PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(modifiedSql);

这意味着:

  • 谁先跑,谁拿到的是原始 SQL
  • 谁后跑,谁拿到的是前一个人改写后的 SQL

本项目的顺序是数据权限先、租户后。所以租户拦截器追加 tenant_id = ? 时,面对的 SQL 里已经有数据权限条件了。目前没问题,因为租户拦截器只关心表名和 WHERE 结构,不关心已有条件长什么样。

但反过来就有风险了。数据权限拦截器的改写逻辑是基于 mapperId 查配置的:

<span>String</span> <span>mapperId</span> <span>=</span> ms.getId();
<span>SysDataScopeConfig</span> <span>config</span> <span>=</span> dataScopeService.getDataScopeConfig(actualMapperId);
<span>if</span> (config == <span>null</span>) {
    handleUnconfiguredMapper(actualMapperId);
    <span>return</span>;
}

它是"按方法找配置",不看 SQL 内容,所以对 SQL 长什么样不敏感——这也是它能安心排在前面的原因。如果哪天你写了一个自定义拦截器,靠正则匹配 SQL 文本来干活,那它的位置就必须严格定义,因为它的输入取决于前面的人做了什么。

一句话总结:改写类拦截器要么按 mapperId(方法级)定位,要么显式声明顺序。靠 SQL 文本匹配的,一定会被顺序坑。

坑 3:count 语句要穿越整条链路,两个拦截器都得能解析它

这是文章开头那个报错的根因。

分页插件生成 count 查询时,会构造一个新的 MappedStatement,id 是原方法名加 _mpCount 后缀。这条 count SQL 会重新走一遍整个拦截器链——也就是两个拦截器都要处理它。

数据权限这边怎么处理的?它得靠剥离后缀反查到原方法的配置:

<span>// 3. 处理分页count查询(方法名以_mpCount或_COUNT结尾)</span>
<span>// 需要根据原方法名查询配置</span>
<span>String</span> <span>actualMapperId</span> <span>=</span> mapperId;
<span>if</span> (mapperId.endsWith(<span>"_mpCount"</span>) || mapperId.endsWith(<span>"_COUNT"</span>)) {
    <span>// 去掉_mpCount或_COUNT后缀,获取原方法名</span>
    actualMapperId = mapperId.replaceAll(<span>"(_mpCount|_COUNT)$"</span>, <span>""</span>);
    log.debug(<span>"数据权限拦截器:检测到分页count查询,原方法: {}"</span>, actualMapperId);
}

如果少了这段,count 查询会因为"找不到配置"而走兜底策略,数据权限在总数上直接失效——列表显示的条数是全部数据,实际能翻到的只有权限范围内的。这是很典型的"数字对不上"问题,而且很容易被当成前端 bug。

租户这边不用特殊处理,因为它是按表名判断的,count SQL 里的表名还在,条件照样追加得上。

再往深一层:count SQL 必须能被两个拦截器同时稳定解析。这就是项目里那个自定义分页拦截器存在的原因:

<span>/**
 * 分页拦截器兼容实现。
 *
 * <p>MyBatis-Plus 默认生成 {<span>@code</span> COUNT(*)}。在包含较深嵌套条件的查询中,
 * 项目使用的 JSqlParser 可能无法解析该 count SQL(错误通常定位到
 * {<span>@code</span> SELECT COUNT(} 的左括号)。{<span>@code</span> COUNT(1)} 语义相同,且可被当前
 * 租户和数据权限拦截器稳定解析。</p>
 */</span>
<span>public</span> <span>class</span> <span>CountOnePaginationInnerInterceptor</span> <span>extends</span> <span>PaginationInnerInterceptor</span> {

    <span>@Override</span>
    <span>public</span> String <span>autoCountSql</span><span>(IPage<?> page, String sql)</span> {
        <span>String</span> <span>countSql</span> <span>=</span> <span>super</span>.autoCountSql(page, sql);
        <span>return</span> replaceCountStar(countSql);
    }

    <span>private</span> String <span>replaceCountStar</span><span>(String countSql)</span> {
        <span>if</span> (countSql == <span>null</span> || countSql.isEmpty()) {
            <span>return</span> countSql;
        }
        <span>String</span> <span>prefix</span> <span>=</span> <span>"SELECT COUNT(*)"</span>;
        <span>if</span> (countSql.regionMatches(<span>true</span>, <span>0</span>, prefix, <span>0</span>, prefix.length())) {
            <span>return</span> <span>"SELECT COUNT(1)"</span> + countSql.substring(prefix.length());
        }
        <span>return</span> countSql;
    }
}

COUNT(*) 改成 COUNT(1),语义完全一样,但解析器的解析路径不一样,后者能被稳定解析。这种改动放在自己项目里,不读源码根本看不出来为什么。

可以带走的一条:如果你也遇到"分页列表报 SQL 解析异常,位置在 SELECT COUNT(",先试把 count 语句从 COUNT(*) 换成 COUNT(1),比去改 JSqlParser 版本省事得多。

坑 4:两个"失败关闭"叠加,异常栈里看不出是谁拦的

这两个拦截器都是"失败关闭"(fail-closed)设计,也就是宁可报错不可放行。但它们的失败形态不一样:

拦截器触发条件失败表现
多租户strictMode 下缺租户上下文抛 `IllegalStateException`,信息是"租户隔离已启用,但当前上下文中没有租户ID"
数据权限未配置的 Mapper + DENY 策略抛 `SQLException`,信息是"Mapper 未配置数据权限,严格策略已拒绝执行"
数据权限SQL 改写失败走 `handleConfiguredFailure`,按策略决定抛还是放行

设计上都是对的。麻烦在于:两者都在 beforeQuery 里抛异常,调用栈几乎一样,日志里只看到 MyBatis 的 Executor 调用链。新手拿到这个异常,第一反应往往是"框架有 bug",而不是"我这条 SQL 缺了上下文/缺了配置"。

所以我给的建议是:异常信息里一定要带拦截器名和 mapperId。上面这两条信息写得都不错(都带了具体原因和 mapperId),排查时优先读异常 message 而不是盯着堆栈看。

附赠发现:两个上下文容器的实现并不一致

扒到这儿发现一个有意思的细节,顺手记下来。

两个 starter 各有一个"跳过拦截"的上下文开关,但实现风格完全不同。

多租户的(TenantContextHolder):

<span>private</span> <span>static</span> <span>final</span> ThreadLocal<Long> TENANT_ID_HOLDER = <span>new</span> <span>TransmittableThreadLocal</span><>();
<span>private</span> <span>static</span> <span>final</span> ThreadLocal<Boolean> IGNORE_TENANT = <span>new</span> <span>TransmittableThreadLocal</span><>();

<span>public</span> <span>static</span> <span>void</span> <span>executeIgnore</span><span>(Runnable runnable)</span> {
    <span>Boolean</span> <span>oldIgnore</span> <span>=</span> IGNORE_TENANT.get();   <span>// 保存现场</span>
    <span>try</span> {
        IGNORE_TENANT.set(<span>true</span>);
        runnable.run();
    } <span>finally</span> {
        <span>if</span> (oldIgnore != <span>null</span>) {
            IGNORE_TENANT.set(oldIgnore);      <span>// 恢复现场(可嵌套)</span>
        } <span>else</span> {
            IGNORE_TENANT.remove();
        }
    }
}

数据权限的(DataScopeContextHolder):

<span>private</span> <span>static</span> <span>final</span> ThreadLocal<Boolean> SKIP_DATA_SCOPE = <span>new</span> <span>ThreadLocal</span><>();

<span>public</span> <span>static</span> <span>void</span> <span>executeWithoutDataScope</span><span>(Runnable runnable)</span> {
    <span>try</span> {
        skipDataScope();
        runnable.run();
    } <span>finally</span> {
        clearSkip();     <span>// 直接清掉,没有恢复现场</span>
    }
}

两个差异值得注意:

维度多租户数据权限
容器TransmittableThreadLocal普通 ThreadLocal
嵌套保存-恢复,可嵌套直接清除,嵌套会互相覆盖
线程池自动传递不传递

嵌套差异会带来一个具体的坑:如果外层已经 executeWithoutDataScope,内层再调用一次,内层结束时 clearSkip() 会把外层的标记也清掉——外层剩下的代码就又开始做数据权限过滤了。多租户那边的保存-恢复写法就没这个问题。

线程池差异意味着:异步任务里 executeWithoutDataScope 的跳过标记传不过去,而租户上下文的能传(TenantBusinessDataSourceTaskDecorator 里还有一份针对业务数据源的显式传递)。写异步逻辑时要记住这个差异。

这不是 bug,是两套代码在不同时间写的、风格没统一。但踩上就是要多花半天排查。

可以带走的 6 条诀窍

  1. 改写类拦截器的顺序是语义依赖,用 @Order 显式声明 + 写断言测试,别依赖 @AutoConfigureOrder 的副作用
  2. 分页插件必须收尾,租户/数据权限这类条件型拦截器排在它前面(官方硬性要求)
  3. _mpCount 是分页 count 查询的 MappedStatement 后缀,任何按 mapperId 找配置的拦截器都要剥离它,否则 count 上丢条件
  4. count SQL 要能被所有解析型拦截器解析:嵌套深时把 COUNT(*) 换成 COUNT(1),成本最低
  5. 异常信息里必须带拦截器名 + mapperId,否则两个 fail-closed 拦截器的报错没法区分
  6. 上下文开关统一用 TTL + 保存恢复语义,普通 ThreadLocal + 直接 clear 在异步和嵌套场景下都是坑

小结

多租户和数据权限共存,真正麻烦的不是"两个功能都要实现",而是三个隐蔽的耦合:

  • 顺序耦合:谁先改写 SQL,决定了后一个人看到什么
  • 语句耦合:分页生成的 count 语句要完整穿越整条链路
  • 容器耦合:上下文在跨线程、嵌套调用时的传递语义必须一致

把这三条对齐了,剩下的就是写业务。


这篇要是有收获,点个赞,我下一篇接着扒幂等 starter(1279 行,三种策略 + SpEL 动态 key + Redisson 分布式锁,坑也很多)。评论区聊聊你们拦截器顺序踩过的坑——"列表总数和实际数据对不上"这个,我赌不少人遇到过。

系列前作:

  • 《数据权限拦截器源码拆解:SQL 改写》(第一篇)
  • 《多租户隔离怎么落地?拆完 1600 行 Starter 源码》(第二篇)

项目相关: