这是"框架源码拆解"系列第三篇。前两篇分别拆了数据权限拦截器的 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.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(modifiedSql);
写进去之后,后面执行的拦截器读到的就是改写后的 SQL。所以顺序不是无所谓——它是数据流的依赖关系。
全景:这个项目里的真实注册顺序
@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 帮你收集好,你按收集到的顺序注册。
本项目里有两个:
// forge-starter-datascope
@AutoConfiguration
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) // 最高优先级
public class DataScopeAutoConfiguration {
// forge-starter-tenant
@AutoConfiguration
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10) // 高优先级
public class TenantAutoConfiguration {
于是实际执行链路是:
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 对象,写法都是:
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
mpBoundSql.sql(modifiedSql);
这意味着:
- 谁先跑,谁拿到的是原始 SQL
- 谁后跑,谁拿到的是前一个人改写后的 SQL
本项目的顺序是数据权限先、租户后。所以租户拦截器追加 tenant_id = ? 时,面对的 SQL 里已经有数据权限条件了。目前没问题,因为租户拦截器只关心表名和 WHERE 结构,不关心已有条件长什么样。
但反过来就有风险了。数据权限拦截器的改写逻辑是基于 mapperId 查配置的:
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 会重新走一遍整个拦截器链——也就是两个拦截器都要处理它。
数据权限这边怎么处理的?它得靠剥离后缀反查到原方法的配置:
// 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 必须能被两个拦截器同时稳定解析。这就是项目里那个自定义分页拦截器存在的原因:
/**
* 分页拦截器兼容实现。
*
* <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):
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):
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 条诀窍
- 改写类拦截器的顺序是语义依赖,用
@Order显式声明 + 写断言测试,别依赖@AutoConfigureOrder的副作用 - 分页插件必须收尾,租户/数据权限这类条件型拦截器排在它前面(官方硬性要求)
_mpCount是分页 count 查询的 MappedStatement 后缀,任何按 mapperId 找配置的拦截器都要剥离它,否则 count 上丢条件- count SQL 要能被所有解析型拦截器解析:嵌套深时把
COUNT(*)换成COUNT(1),成本最低 - 异常信息里必须带拦截器名 + mapperId,否则两个 fail-closed 拦截器的报错没法区分
- 上下文开关统一用 TTL + 保存恢复语义,普通 ThreadLocal + 直接 clear 在异步和嵌套场景下都是坑
小结
多租户和数据权限共存,真正麻烦的不是"两个功能都要实现",而是三个隐蔽的耦合:
- 顺序耦合:谁先改写 SQL,决定了后一个人看到什么
- 语句耦合:分页生成的 count 语句要完整穿越整条链路
- 容器耦合:上下文在跨线程、嵌套调用时的传递语义必须一致
把这三条对齐了,剩下的就是写业务。
这篇要是有收获,点个赞,我下一篇接着扒幂等 starter(1279 行,三种策略 + SpEL 动态 key + Redisson 分布式锁,坑也很多)。评论区聊聊你们拦截器顺序踩过的坑——"列表总数和实际数据对不上"这个,我赌不少人遇到过。
系列前作:
- 《数据权限拦截器源码拆解:SQL 改写》(第一篇)
- 《多租户隔离怎么落地?拆完 1600 行 Starter 源码》(第二篇)
项目相关:
- Gitee:gitee.com/ForgeLab/fo…
- 在线演示:www.dlforgelab.com:8084/forge/login… / 123456)