权限通了、列表有数,一点分页就炸Forge Admin 用 COUNT(1) 扛住复杂待办

客户环境里最冤的一类现场长这样:

  1. 菜单权限、接口权限都通了,Postman 不带 page 也能查出待办;
  2. 前端一开列表(pageNum=1&pageSize=10),接口直接 500;
  3. 堆栈停在 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(*) 改成近似计数。本坑里这三条常常全是噪音:

  1. 先优化 COUNT 性能

    堆栈若已是 ParseException,SQL 根本没进优化器。先让 count 能 parse、能执行,再谈千万行实测。社区那篇「COUNT 慢不是因为 *」说的是另一类问题,别和本文混诊。

  2. 怀疑前端 page 参数传错

    pageNum/pageSize 传错通常表现为空列表或页码错乱,很少直接在 SELECT COUNT( 左括号炸掉。可对照:同一 Mapper 方法,关掉分页或 searchCount=false(若你项目允许)时列表恢复,一开 count 就挂------基本锁定 autoCount 链路。

  3. 只改业务 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 的年代。

复现最小步骤(本地)

  1. 找一条带多层 EXISTS / 函数嵌套的列表 Mapper(流程待办、复杂报表均可);
  2. 用 MyBatis-Plus Page 正常查询,确认失败堆栈含 ParseException 与 COUNT(;
  3. 临时把分页拦截器换成 CountOnePaginationInnerInterceptor(或等价前缀替换),复跑;
  4. 把该 SQL 固化进 autoCountSql + CCJSqlParserUtil.parse 单测,防止以后升级 JSqlParser / MP 静默回归。

客户现场若不能改代码,可先用日志对比「业务 SQL」与「count SQL」两份文本:往往业务句能在客户端执行,count 句在同一客户端粘贴也会解析失败------这能把锅从「权限」和「脏数据」上摘下来。

给若依 / Jeecg / 自研后台的带走结论

  1. 权限通了 ≠ 分页通了。RBAC 过了只说明 Controller 进得去;分页还要过 autoCount + 租户 + 数据权限整条链。
  2. 列表有数 ≠ count 能解析 。先搜堆栈里的 ParseException 和 SELECT COUNT(,再决定要不要上深分页优化。
  3. 改 COUNT 表达式是兼容手段,不是性能神话。Forge 的切口是让 JSqlParser 4.9 在现有拦截器链上活下来;真慢再谈索引、覆盖索引、禁止无界 offset。
  4. 新增复杂 XML 时,单测要覆盖 count 。只测 selectList 会漏掉生产必经的 _mpCount。

Forge Admin 把这件事收敛在 forge-starter-orm:业务插件继续写复杂待办 SQL,ORM 层统一兜住分页 count 的解析兼容。源码与提交见 Gitee:gitee.com/ForgeLab/fo... CountOnePaginationInnerInterceptor 或 commit c74f989e)。

若你正在从若依/Jeecg 迁到可插拔后台,欢迎 Star / Issue;下一篇若继续写大众切口,会优先「打开就报错」类现场,而不是发版说明。

相关推荐
骑着蜗牛撵大象3271 小时前
豆包 Seed 驱动的记账智能体:从拍票抽取到月报生成的全链路落地记录
java·前端·javascript·结构化输出·豆包seed·多模态识别·记账系统
憧憬成为java架构高手的小白1 小时前
langchain4j手敲理解
java·langchain
10mAh2 小时前
【Java】ArrayList 遍历删除为什么报错?三种写法与漏删复现
java·开发语言·windows
thefool1122662 小时前
多线程 4
java
WeiXin_DZbishe3 小时前
基于微信小程序的本地闲置物品交易平台-计算机毕设 附源码【课程设计】26825
vue.js·spring boot·python·微信小程序·架构·php·课程设计
Rain的Java大神之路3 小时前
[特殊字符]8年Java老兵转型AI Agent:90%的人挂在同一个坑,根本不用学Python!(万字实战复盘,建议收藏)
java·数据库·人工智能·后端·python·spring·ai编程
高级程序源3 小时前
springboot本地生活服务平台33399-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·小程序·flask·课程设计
高级程序源3 小时前
springboot社区旧物回收管理系统25381-计算机课程设计、毕业设计
vue.js·spring boot·python·django·课程设计
高级程序源3 小时前
springboot大学生体测系统72170-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·django·课程设计