1. 先从一次真实的翻车经历说起
项目用的是若依框架做的二开,MyBatis-Plus 做 ORM,多模块 Maven 工程,封装好了一个公共的 BaseService,里面统一处理分页查询逻辑。项目上了 Lombok,开发期一直跑得好好的,结果到了联调阶段,其他模块同事调我这个公共查询接口,日志里连续滚出一长串异常:
code复制org.springframework.jdbc.BadSqlGrammarException:
### Error querying database. Cause: java.sql.SQLSyntaxErrorException:
You have an error in your SQL syntax;
check the manual that corresponds to your MySQL server version for the right syntax to use near 'COUNT()' at line 1
### The error occurred while executing a query.
### Cause: java.sql.SQLSyntaxErrorException:
You have an error in your SQL syntax;
check the manual that corresponds to your MySQL server version
看到"BadSqlGrammarException"很自然地想到 SQL 语法不对,但当时最让人困惑的是 SQL 里的 SELECT COUNT() 根本没有字段名。MyBatis-Plus 自带的物理分页插件,理论上会自动完成 COUNT 包裹,为什么生成的 SQL 里会出现一个残缺的 COUNT()?
这类问题定位起来不复杂,但坑是真的多。我把当天排查的过程、底层原理、几种可能的触发场景以及最终修复方案完整记录下来,给后面遇到同样报错的同学一个参考。特别是如果你也是基于若依这种多模块封装了公共 Service 的结构,那这篇文章应该能帮你少走不少弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 这个报错为什么会发生
2.1 分页插件在底层做了什么
MyBatis-Plus 的 PaginationInnerInterceptor 能帮开发者在执行分页时,自动拼接 LIMIT 语句,同时在查询总数时执行一条 COUNT 语句。正常场景下,我们调用 Page 对象后,框架会对原始 SQL 做一次内层包裹,类似:
sql复制SELECT COUNT(*) AS total FROM (原始SQL)
或者是直接把原 SQL 的主查询替换为 COUNT 聚合查询:
sql复制SELECT COUNT(*) FROM user WHERE ...
如果你用了 Page 对象传入 Mapper 方法,日志里会在正式查询数据之前,先打印一条 ==> Preparing: SELECT COUNT(*) AS total FROM ...,然后再执行真正的分页查询。
报错信息里的 COUNT() 括号里是空的,这显然不是框架默认逻辑生成的。框架默认逻辑不会故意生成一个没有参数的 COUNT,能导致它出现空括号,往往是因为开发者自己写了什么东西,让 SQL 解析器在拼接时裁剪出了问题,或者是原始 SQL 根本就不是一条标准可解析的查询语句。
2.2 最容易撞上的两类触发点
我当天排查的第一反应是检查自定义 SQL。项目里确实有一个统计用户自定义分组数量的方法,用了 ${} 拼接参数。${} 在 MyBatis 里是字符串直接替换,如果传入参数是一个子查询片段,可能拼进去之后 SQL 结构发生了变形。
当时日志里的完整 SQL 被打印出来后,我看到的是这么个形态:
sql复制SELECT COUNT() FROM (
SELECT * FROM user WHERE name IN (...)
) total
这里的 COUNT() 空括号说明 MyBatis-Plus 在尝试对原 SQL 做 COUNT 改写时,没有成功识别出主表或者是原有的 SELECT 子句被某些注解干扰了。顺着这个思路,第二个点就是 Mapper 注解里的写法问题。
比如有人会在 Mapper 接口上写这种 SQL:
java复制@Select("<script>" +
"SELECT ${selectColumns} FROM user " +
"WHERE status = #{status}" +
"</script>")
List<User> selectUserList(@Param("selectColumns") String columns,
@Param("status") Integer status);
selectColumns 如果传的是 *,在分页插件做 count 优化的时候,生成的 SQL 就可能出现异常。MyBatis-Plus 对这类"嵌入式字段"的解析能力有限,它会尝试通过数据库元数据去兜底,但兜底失败后就只能生成空的 COUNT 表达式。
2.3 根本原因归纳
从底层机制来看,BadSqlGrammarException: SELECT COUNT() 的原因可以浓缩成一句话:MyBatis-Plus 在执行 COUNT 优化时,试图从原始 SQL 中提取需要计数的目标,但由于 SQL 的动态片段存在无法预知的形态,最终生成的 COUNT 表达式不合法。
常见的触发场景包括:
- 原 SQL 使用了
${}直接拼接 SQL 片段,且拼接内容是一条完整的子查询或视图逻辑。 - Mapper 方法注解里写的是动态 SQL,字段列表来自外部传入,分页插件没法正确解析。
- 多表 join 查询时把 group by 写在了一个特殊位置,导致 COUNT 改写时被去掉 SELECT 字段而产生空括号。
- 另一种隐藏情况:方法返回值类型和 Mapper 泛型不匹配,分页插件在走
countOptimize时拿不到 JParser 需要的表信息。
其实这几种情况背后有一个共性:凡是破坏了 SQL 语义的可静态分析结构的写法,都有可能让分页插件的智能改写失效。
3. 分步排查的过程还原
3.1 第一步:打开 SQL 日志,确认生成的真实语句
在排查之前先做一件事:确认项目配置里打印了完整 SQL。如果你还没打印 SQL,可以临时改一下 application.yml:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
这样 MyBatis 会把预编译语句和参数都打印在控制台。日志非常关键,因为业务代码里写的 Mapper 方法 SQL 并不等于最终执行的 SQL。分页插件会拦截后做改写,错误往往发生在改写环节。
我的日志里看到 executor 先执行了一个错误的分页 COUNT,其中的 SQL 片段确实不是自己写的。原业务方法是这样的:
java复制@Select("SELECT ${ew.sqlSegment} FROM user")
List<User> selectBySegment(@Param(Constants.WRAPPER) Wrapper<User> wrapper);
业务侧调用的时候通过 QueryWrapper 传入了一个带子查询的条件:
java复制wrapper.inSql("id", "SELECT user_id FROM user_group WHERE group_name = 'VIP'");
当 QueryWrapper 里的 inSql 片段经过分页插件解析时,插件尝试把 select 字段裁剪掉并生成 count SQL,它的简化逻辑是把第一个 selectItem 替换成
