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

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

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

先讲现象。

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

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

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

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

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

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

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

改写的方式是:

java 复制代码
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(modifiedSql);

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

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

java 复制代码
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

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

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

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

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

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

本项目里有两个:

java 复制代码
// forge-starter-datascope
@AutoConfiguration
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)   // 最高优先级
public class DataScopeAutoConfiguration {

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

于是实际执行链路是:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

java 复制代码
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(modifiedSql);

这意味着:

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

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

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

java 复制代码
String mapperId = ms.getId();
SysDataScopeConfig config = dataScopeService.getDataScopeConfig(actualMapperId);
if (config == null) {
    handleUnconfiguredMapper(actualMapperId);
    return;
}

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

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

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

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

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

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

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

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

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

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

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

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

    private String replaceCountStar(String countSql) {
        if (countSql == null || countSql.isEmpty()) {
            return countSql;
        }
        String prefix = "SELECT COUNT(*)";
        if (countSql.regionMatches(true, 0, prefix, 0, prefix.length())) {
            return "SELECT COUNT(1)" + countSql.substring(prefix.length());
        }
        return 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):

java 复制代码
private static final ThreadLocal<Long> TENANT_ID_HOLDER = new TransmittableThreadLocal<>();
private static final ThreadLocal<Boolean> IGNORE_TENANT = new TransmittableThreadLocal<>();

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

数据权限的(DataScopeContextHolder):

java 复制代码
private static final ThreadLocal<Boolean> SKIP_DATA_SCOPE = new ThreadLocal<>();

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

两个差异值得注意:

维度 多租户 数据权限
容器 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 源码》(第二篇)

项目相关:

相关推荐
福建佰胜张工32 分钟前
SMC 薄型气爪 MHF2-16D1 深度解析:平行开闭、低剖面结构在自动化装配中的选型与落地
java·服务器·安全·自动化
念何架构之路32 分钟前
go-grpc设计思想
开发语言·后端·golang
catoop35 分钟前
API Key 设计与安全生成方案:结构拆解、数据库设计与 Java 实现
java·数据库·安全
system_error_x1 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
驭渊的小故事2 小时前
Spring AI Alibaba 实战:从maven依赖治理到声明式工具调用
java·人工智能·spring
Elastic 中国社区官方博客2 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1882 小时前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn3 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su3 小时前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
X54先生(人文科技)3 小时前
社会嵌入式终身学习机器与修复时代-ELR架构和技术演化探讨
人工智能·深度学习·学习·架构·开源协议