
客户环境里最冤的一类现场长这样:
- 菜单权限、接口权限都通了,Postman 不带 page 也能查出待办;
- 前端一开列表(
pageNum=1&pageSize=10),接口直接 500; - 堆栈停在
net.sf.jsqlparser.parser.ParseException,位置往往是SELECT COUNT(的左括号。
很多 Spring Boot + MyBatis-Plus + 多租户后台是同一套骨架:分页插件自动生成 count,租户行、数据权限再改一遍 SQL。Forge Admin 在把待办候选组做成多层 EXISTS 之后,正好踩穿了这条链。更气人的是:同一接口,有的环境数据少、条件浅,测试绿得发光;一到客户库候选组又长又脏,第一页就炸------很像「只有生产才复现」的权限问题,其实是分页 count 的解析问题。仓库:gitee.com/ForgeLab/fo... ;修复提交 c74f989e78368df38bd28792de7cb19cb29ac8e4(2026-08-30)。
现象:不是慢,是炸
社区里已经有不少「COUNT(*) 并不比 COUNT(1) 慢」的实测文------那是性能 话题。我们这边是可用性 话题:count SQL 在拦截器链里解析不了,请求直接失败。
踩坑记录原文(code-copilot/memory/pitfalls/backend.md):
待办列表增加候选组关联和多层
EXISTS后,分页请求报
ParseException: Encountered unexpected token: "(",位置通常是SELECT COUNT(的左括号。原始列表 SQL 可以解析 ,但 MyBatis-Plus 分页自动生成的
SELECT COUNT(*) ...在租户/数据权限拦截器再次解析时失败。
也就是说:你用日志打出「业务 SELECT」觉得没问题,并不代表分页那条隐藏的 count 也能过完整条链。

根因:复杂 SQL × autoCount × 二次解析
1. 业务 SQL 变「深」了
待办要同时匹配:签收人、候选用户、候选角色/组织(candidate_groups 逗号串)。Forge 在 FlowTaskMapper.xml 里用多层 EXISTS + FIND_IN_SET + REPLACE 规范化分隔符,单测里复现的骨架如下(节选,完整见 CountOnePaginationInnerInterceptorTest):
sql
SELECT t.id, t.title
FROM sys_flow_task t
WHERE t.tenant_id = ?
AND (t.assignee = ?
OR (t.candidate_groups IS NOT NULL AND t.candidate_groups != ''
AND (EXISTS ( /* 角色/组织候选 */ ) OR EXISTS ( /* 组织候选 */ ))))
ORDER BY t.create_time DESC
按源码推演:这条 SELECT 在「只跑业务查询」时,JSqlParser 还能吃下;分页插件一包成 SELECT COUNT(*) FROM (...) 或优化后的 count 形态,再交给后面的拦截器,解析器就在 COUNT( 处翻车。
2. 拦截器不是「只注册一个分页」
MybatisPlusConfig 的注册顺序写得很直白:
java
// 1. 其他模块提供的 InnerInterceptor(租户、数据权限等)
innerInterceptors.forEach(interceptor::addInnerInterceptor);
// 2. 分页(Forge 已换成 CountOnePaginationInnerInterceptor)
interceptor.addInnerInterceptor(paginationInnerInterceptor());
// 3. 乐观锁
interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
租户侧 TenantAutoConfiguration、数据权限侧 DataScopeAutoConfiguration 只提供 Bean ,注释写明:由 ORM 配置统一 List<InnerInterceptor> 注入注册------这和若依/Jeecg「多插件挂到同一条 MybatisPlusInterceptor」是同构思路。
数据权限拦截器还显式认分页 count 后缀(源码):
java
if (mapperId.endsWith("_mpCount") || mapperId.endsWith("_COUNT")) {
actualMapperId = mapperId.replaceAll("(_mpCount|_COUNT)$", "");
}
按源码推演:count 语句会再次走数据权限改写,也就是 JSqlParser 至少还要再解析一次 。工程踩坑里写明:实际引入 jsqlparser:4.9(经 mybatis-plus-core:3.5.7 传递),同一解析器在别的场景还曾把 ORDER BY ... FOR UPDATE 重排出错------复杂表达式面前,解析器是脆弱环节,不是业务 SQL「写错了」那么简单。
表 1:COUNT(*) vs COUNT(1)(可收藏)
| 维度 | COUNT(*)(MP 默认 autoCount) |
COUNT(1)(Forge 兼容) |
|---|---|---|
| 行数语义 | 统计行数 | 统计行数(本场景等价) |
| 性能 | 通常不是慢的主因 | 不是加速银弹 |
| 本坑表现 | 深嵌套 SQL 经租户/数据权限再解析时失败 | 同条件可被当前解析链稳定 parse |
| 改法位置 | PaginationInnerInterceptor.autoCountSql |
子类只替换首个 SELECT COUNT(*) 前缀 |
| 适用 | 简单列表 | 含多层 EXISTS/函数/子查询的分页 |
改法:12 行兼容,不关优化
Forge 没有关掉 MyBatis-Plus 的 count 优化,也没有让业务 Mapper 手写 count。做法是继承分页插件,生成后再做一次前缀替换:
java
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) {
String prefix = "SELECT COUNT(*)";
if (countSql != null && countSql.regionMatches(true, 0, prefix, 0, prefix.length())) {
return "SELECT COUNT(1)" + countSql.substring(prefix.length());
}
return countSql;
}
}
类注释写明动机:COUNT(1) 语义相同,且可被当前租户和数据权限拦截器稳定解析 。MybatisPlusConfig 把原来的 PaginationInnerInterceptor 换成上述子类。单测用真实嵌套候选组 SQL 调用 autoCountSql,断言前缀是 SELECT COUNT(1),再用 CCJSqlParserUtil.parse(countSql) 保证解析通过。
执行记录补充:本轮未起 Admin/Flow 连真实 MySQL;编译与 CountOnePaginationInnerInterceptorTest 已过。上线后仍建议在用户环境看一眼分页 count、租户条件和执行计划。
表 2:上线前自检清单(可收藏)
| 检查项 | 怎么做 | 通过标准 |
|---|---|---|
| 业务 SQL | 日志打出列表 SELECT | 能执行、结果集合理 |
| 自动 count | 打开 MP/p6spy,找 _mpCount |
前缀为 COUNT(1) 或手写等价 count |
| 租户 | 确认 count 仍带 tenant_id 条件 |
无跨租户放大 |
| 数据权限 | 确认 _mpCount 映射回原 mapper 配置 |
权限范围与列表一致 |
| 回归 | 复制最深的那条 EXISTS SQL 进解析单测 | CCJSqlParserUtil.parse 不抛 |
| 别误诊 | 别只拿 EXPLAIN 对比 * 与 1 |
本坑是 ParseException,不是慢查询 |

排查时最容易走偏的三条路
现场一来,很多人第一反应是去调慢查询------EXPLAIN、覆盖索引、把 COUNT(*) 改成近似计数。本坑里这三条常常全是噪音:
-
先优化 COUNT 性能
堆栈若已是
ParseException,SQL 根本没进优化器。先让 count 能 parse、能执行,再谈千万行实测。社区那篇「COUNT 慢不是因为 *」说的是另一类问题,别和本文混诊。 -
怀疑前端 page 参数传错
pageNum/pageSize传错通常表现为空列表或页码错乱,很少直接在SELECT COUNT(左括号炸掉。可对照:同一 Mapper 方法,关掉分页或searchCount=false(若你项目允许)时列表恢复,一开 count 就挂------基本锁定 autoCount 链路。 -
只改业务 XML 的 SELECT 列表
分页插件会基于原 SQL 生成 count;你把列裁短、加别名,未必改变 count 外包一层后的解析路径。Forge 的修复点放在 ORM 分页拦截器 ,而不是每个 Flow XML 手写
select count,就是为了避免 N 个列表各写一套兼容。
按源码推演:数据权限对 _mpCount 会剥后缀再查配置,说明框架作者已经默认「分页 count 与列表权限必须同源」。你若在自研拦截器里漏处理 _mpCount,还会得到另一种冤案------列表按部门过滤了,总数却是全表,前端分页器直接发疯。那是「数不对」;本文是「直接 500」。两种都要在拦截器设计时一起测。
和若依 / Jeecg 同构,但不写发版稿
近三个月掘金上,「若依 + MyBatis-Plus,分页为什么悄悄失效了?」明显比 Jeecg 版本公告更吃搜索。悄悄失效多半是插件未挂上、Pager 与 MP 抢改 SQL;我们这篇是挂上了、SQL 更深之后,count 二次解析失败。关键词可以蹭若依/Jeecg,故事必须用自己的提交说话。
Forge 这边可核对的事实链:
- 变更动机写在执行日志:
flow-task-list-performance归档目录,2026-08-30「分页 count 解析兼容修复」; - 代码落在
forge-starter-orm的CountOnePaginationInnerInterceptor+MybatisPlusConfig; - 回归是「嵌套候选组 SQL → autoCountSql → CCJSqlParserUtil.parse」一条龙,而不是「感觉列表快了」。
如果你在若依二开里也叠了租户行插件 + 自定义数据权限 + 超长 XML,建议原样借这张表做一次演练:把最复杂的那条列表 SQL 丢进单测,强制走 autoCountSql,看当前依赖的 JSqlParser 是否还认 COUNT(*)。不认,就不要在每个业务模块打补丁,收到 starter 层一次。
为什么只替换「首个」前缀
实现里用 regionMatches 只动开头的 SELECT COUNT(*),不全局替换字符串。按源码推演有两层考虑:
super.autoCountSql的产物形态稳定时,count 表达式出现在语句最前,前缀替换足够;- 业务 SQL / 子查询里万一出现注释或字符串中的
COUNT(*),全局替换可能误伤(虽少见,但 starter 级代码应保守)。
这不是对 MyBatis-Plus 源码的 fork,而是 12 行子类。升级 MP 大版本时,优先复跑那条嵌套 SQL 单测;若上游 autoCount 输出格式变了(例如不再以该前缀开头),测试会先红,再决定是否改替换策略。
执行日志也写明:本修复保留 原有 count 优化,只改表达式;租户隔离与数据权限改写路径不关。换句话说,不要为了躲解析器把 PaginationInnerInterceptor 整段卸掉------那会让若依式后台瞬间回到手写 limit 的年代。
复现最小步骤(本地)
- 找一条带多层
EXISTS/ 函数嵌套的列表 Mapper(流程待办、复杂报表均可); - 用 MyBatis-Plus
Page正常查询,确认失败堆栈含ParseException与COUNT(; - 临时把分页拦截器换成
CountOnePaginationInnerInterceptor(或等价前缀替换),复跑; - 把该 SQL 固化进
autoCountSql+CCJSqlParserUtil.parse单测,防止以后升级 JSqlParser / MP 静默回归。
客户现场若不能改代码,可先用日志对比「业务 SQL」与「count SQL」两份文本:往往业务句能在客户端执行,count 句在同一客户端粘贴也会解析失败------这能把锅从「权限」和「脏数据」上摘下来。
给若依 / Jeecg / 自研后台的带走结论
- 权限通了 ≠ 分页通了。RBAC 过了只说明 Controller 进得去;分页还要过 autoCount + 租户 + 数据权限整条链。
- 列表有数 ≠ count 能解析 。先搜堆栈里的
ParseException和SELECT COUNT(,再决定要不要上深分页优化。 - 改 COUNT 表达式是兼容手段,不是性能神话。Forge 的切口是让 JSqlParser 4.9 在现有拦截器链上活下来;真慢再谈索引、覆盖索引、禁止无界 offset。
- 新增复杂 XML 时,单测要覆盖 count 。只测
selectList会漏掉生产必经的_mpCount。
Forge Admin 把这件事收敛在 forge-starter-orm:业务插件继续写复杂待办 SQL,ORM 层统一兜住分页 count 的解析兼容。源码与提交见 Gitee:gitee.com/ForgeLab/fo... CountOnePaginationInnerInterceptor 或 commit c74f989e)。
若你正在从若依/Jeecg 迁到可插拔后台,欢迎 Star / Issue;下一篇若继续写大众切口,会优先「打开就报错」类现场,而不是发版说明。