先说我一个真实经历。上周同事发来一段代码让我看,Mapper接口里方法写的是List<User> selectCustom(@Param(Constants.WRAPPER) QueryWrapper<User> wrapper),XML 里是SELECT * FROM user ${ew.customSqlSegment} and status = 1。这个接口平时跑得好好的,但只要前端没传任何筛选条件,数据库就会抛出BadSqlGrammarException,报错位置永远停在AND status = 1前面。刚开始大家都怀疑是 SQL 写错了,但把status = 1去掉后又一切正常。折腾了半天,最后才发现问题出在${ew.customSqlSegment}的“可变性”上:这个片段不是普通参数,它可能带着WHERE关键字,也可能是一段ORDER BY,还可能是空字符串,我们却用固定写死的AND去衔接它,不出错才怪。
这其实是一个非常典型的 MyBatis Plus 自定义 SQL 组合查询问题。标题里的三个关键词——@Param(Constants.WRAPPER)、QueryWrapper、${ew.customSqlSegment}——单独拿出来大家都认识,组合在一起就容易踩坑。这篇文章我就把这个坑的前因后果、原理机制和几种稳定解法一次讲清楚,顺便整理一份排查速查表,给后面接手这类需求的同学少走点弯路。
1. 问题复现:一段让同事怀疑人生的SQL
1.1 典型错误写法长什么样
大多数人第一次接触${ew.customSqlSegment},都是被 MyBatis Plus 官方文档里那句“自定义 SQL 时可使用 QueryWrapper 自动拼接查询条件”吸引来的,于是很容易照猫画虎写出下面这种 Mapper 方法:
public interface UserMapper extends BaseMapper<User> { List<User> selectUserByStatus( @Param(Constants.WRAPPER) QueryWrapper<User> wrapper, @Param("status") Integer status); }XML 里的写法也自以为很聪明:
<select id="selectUserByStatus" resultType="User"> SELECT * FROM user ${ew.customSqlSegment} AND status = #{status} </select>逻辑上觉得,QueryWrapper 负责动态查询条件,我再用 AND 补一个固定的状态过滤条件,两者拼起来就完事了。问题是,${ew.customSqlSegment}不是“条件片段”,而是一个“可能包含 WHERE 或 ORDER BY 的完整片段”,这样粗暴拼接,几个典型翻车现场在所难免。
1.2 现场翻车日志与报错分析
当 wrapper 里没有设置任何条件时,ew.customSqlSegment是一个空字符串,上面的 SQL 在解析后变成:
SELECT * FROM user AND status = 1MySQL 直接报语法错误。实际日志类似这样:
### SQL: SELECT * FROM user AND status = 1 ### Cause: com.mysql.cj.jdbc.exceptions.MySQLSyntaxErrorException: 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 'AND status = 1' at line 1而当 wrapper 里只有排序条件时,又会变成:
SELECT * FROM user ORDER BY create_time DESC AND status = 1这在 MySQL 里同样是语法错误,而且报错位置在AND status = 1,看起来很像“排序后面不该加 AND”,容易把排查方向引到排序上,其实根子还是同一个:customSqlSegment不是稳定可拼接的条件片段。
1.3 为什么会走到这条弯路
说句公道话,这种写法不能全怪写代码的人。一个是 MyBatis Plus 的文档示例里,customSqlSegment基本都是单独使用,比如SELECT * FROM user ${ew.customSqlSegment},没人告诉过你这个片段在无条件下会是空串,在有排序时会是ORDER BY ...。另一个原因是,日常开发里大家习惯了用 MyBatis 的动态<if>拼条件,潜意识里默认${ew.customSqlSegment}也是一个“可插拔的 WHERE 段”,于是自然而然地用 AND 去续写。但实际上,一旦 wrapper 里没有任何实体条件,系统给你返回的就是个空串,此时任何后缀 AND 都会成为孤立的语法错误源头。理解到这里,问题的根源已经清楚,接下来我们去看一看 MyBatis Plus 内部到底是怎么生成这两个片段的。
2. 搞懂 Constants.WRAPPER、customSqlSegment 和 sqlSegment
2.1 @Param(Constants.WRAPPER) 到底是什么
在 MyBatis 里,Mapper 方法如果有多个参数,就必须用@Param给参数命名,否则 XML 里没法直接用参数名取值。MyBatis Plus 定义了一个常量:
public interface Constants { String WRAPPER = "ew"; // ... }所以@Param(Constants.WRAPPER) QueryWrapper<User> wrapper等价于:
@Param("ew") QueryWrapper<User> wrapper之所以推荐用常量而不是手写字符串,主要是避免拼写错误。你写"ew"写顺手了还行,一旦在某个 Mapper 里不小心写成"we",XML 里的${ew.customSqlSegment}立刻找不到参数,报错信息是Parameter 'ew' not found。我见过一个小团队,四个人对这个参数名有两种写法,集成测试的时候一晚上都在查这种低级问题。所以建议统一用Constants.WRAPPER这个常量,从根上消灭这类失误。
2.2 customSqlSegment 究竟是一段什么东西
在 AbstractWrapper 内部,QueryWrapper 会被拆分成多个片段:普通查询条件(normal)、分组(groupBy)、having、排序(orderBy)等。对外暴露了几个取片段的方法,其中最常用的两个是:
getSqlSegment():返回普通查询条件片段,不带 WHERE 关键字,通常以左括号开头、右括号结尾,例如(name = ? AND age = ?)。如果没有任何查询条件,返回空字符串。getCustomSqlSegment():返回一个“装饰过”的完整片段,规则是:- 如果有普通查询条件,返回
WHERE (name = ? AND age = ?); - 如果没有任何查询条件、但有排序条件,返回
ORDER BY create_time DESC; - 如果既没有查询条件也没有排序条件,返回空字符串。
- 如果有普通查询条件,返回
这里插一句,很多人以为customSqlSegment是“WHERE 段”,其实它是一个完整后缀,并不单指 WHERE。也正因如此,直接拿它去跟固定 AND 拼接,才会在不同 wrapper 状态间产生截然不同的结果。
2.3 两个片段的区别一张表看清楚
| 片段属性 | 空 wrapper | 只有条件 | 只有排序 | 条件+排序 |
|---|---|---|---|---|
${ew.sqlSegment} | 空串 | (name = ? AND age = ?) | 空串 | (name = ? AND age = ?) |
${ew.customSqlSegment} | 空串 | WHERE (name = ? AND age = ?) | ORDER BY create_time DESC | WHERE (name = ? AND age = ?) ORDER BY create_time DESC |
从这张表可以看出来,sqlSegment适合自己控制 WHERE 关键字的场景;customSqlSegment适合直接放到 SQL 末尾、什么都不做的场景。两者的用途完全不一样,混用就会踩坑。
2.4 MergeSegments 是如何决定返回值的
想深入一点的同学可以直接看 MyBatis Plus 源码,核心处理在com.baomidou.mybatisplus.core.conditions.segments.MergeSegments。当调用eq、like、orderBy等方法时,条件片段会被解析并归入不同的 Segment 列表;最后执行getCustomSqlSegment()或getSqlSegment()时,再根据当前列表状态决定输出内容。
我记忆里比较关键的逻辑是:
public String getCustomSqlSegment() { if (hasNormal()) { return "WHERE " + getNormalSqlSegment(); } if (hasOrderBy()) { return "ORDER BY " + getOrderBySqlSegment(); } return StringUtils.EMPTY; }所以才会出现“无条件时返回空串”的诡异行为。这也是为什么自定义 SQL 里只要涉及额外拼接,就要先考虑这个片段可能是空串或可能是 ORDER BY 段,而不能默认它一定是 WHERE 条件。
3. 正确衔接附加条件:四套可行方案
3.1 方案一:所有条件都进 Wrapper,别在 SQL 里留尾巴
最省心的做法,是把所有过滤条件,包括状态、时间、关键字等,全部在 service 层塞进 wrapper,然后 XML 只保留一个${ew.customSqlSegment}:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.lambda() .eq(Objects.nonNull(status), User::getStatus, status) .like(StringUtils.isNotBlank(keyword), User::getName, keyword) .orderByDesc(User::getCreateTime); List<User> userList = userMapper.selectCustom(wrapper);<select id="selectCustom" resultType="User"> SELECT * FROM user ${ew.customSqlSegment} </select>这种写法下,wrapper 空就返回SELECT * FROM user,有排序就返回排序,有条件就返回 WHERE,永远不需要在 XML 里二次拼 AND。它最大的优点是简单、稳定,也最容易让后期维护的人一眼看懂。缺点是有时业务方会硬塞给我一个“固定不变”的条件,比如status = 1,如果这个条件也被放入 wrapper,那 service 层每次都要重复写eq("status", 1),代码学会有些冗余。这种情况我会建议抽一个公共方法,在 service 工厂里统一处理好。
3.2 方案二:用 sqlSegment 配合<where>自己拼
如果你确认自己需要额外拼接一些固定条件,比如多表查询中关联表的字段也要过滤,那就不该再用customSqlSegment,而是要切换到sqlSegment。因为它不带 WHERE,正好可以塞进<where>标签里,让 MyBatis 替我们处理掉第一个 AND 的尴尬:
<select id="selectUserWithDept" resultType="User"> SELECT u.* FROM user u LEFT JOIN dept d ON u.dept_id = d.id <where> <if test="status != null"> AND u.status = #{status} </if> <if test="ew != null and ew.sqlSegment != null and ew.sqlSegment != ''"> AND ${ew.sqlSegment} </if> </where> </select>这里有个容易忽略的细节:${ew.sqlSegment}里已经带上了括号,比如(u.name = ? AND u.age = ?),所以外面用AND衔接是安全的。如果 wrapper 完全没有条件,sqlSegment是空串,<if>判断会跳过,上面的 SQL 就只剩下WHERE u.status = ?,不会出现WHERE AND。这一套组合的关键是:自己掌握 WHERE 关键字,让条件片段只负责条件本身。
不过它有个副作用:wrapper 的排序不会跟着出现,因为sqlSegment不负责排序。如果你确实要在这一条 SQL 里也支持 wrapper 传进来的排序,最常见的是把排序也放到 wrapper 之外交给分页插件处理,或者在 service 层用一个专门参数传排序列。我自己的习惯是:这种场景的排序由前端通过分页参数来控制,不塞进 QueryWrapper,避免和自定义 SQL 里的排序打架。
3.3 方案三:固定条件前置,再用 if 判断兜住空片段
如果你的 SQL 结构比较死,比如第一版需求就定了“必须有dept_id过滤,其他条件动态”,那还可以用固定 WHERE 前缀 + 动态判断的方式:
<select id="selectUserByDept" resultType="User"> SELECT * FROM user WHERE dept_id = #{deptId} <if test="ew != null and ew.sqlSegment != null and ew.sqlSegment != ''"> AND ${ew.sqlSegment} </if> <if test="ew != null and ew.customSqlSegment != null and ew.customSqlSegment != '' and (ew.sqlSegment == null or ew.sqlSegment == '')"> ${ew.customSqlSegment} </if> </select>这里的第二段判断是为了把仅排序的情况兜住:如果 wrapper 只有排序没有条件,sqlSegment为空,customSqlSegment就会是ORDER BY ...,此时放在<if>里安全输出。但这个写法维护成本高,如果你的项目里大量 SQL 都这么写,后面的人一定会骂街,我一般只在很少的遗留 SQL 改造里用。新代码优先选方案一或方案二。
3.4 方案四:Service 层兜底,SQL 保持简单
有时候问题不在 mapper XML,而在调用方。比如一个接口要支持多种筛选组合,但 mapper 已经写死成${ew.customSqlSegment}拼接,那 service 层完全可以把所有条件先组装成一个干净的 wrapper,再传给 mapper:
QueryWrapper<User> wrapper = Wrappers.query(); wrapper.and(StringUtils.isNotBlank(name), w -> w.like("name", name)); wrapper.and(StringUtils.isNotBlank(phone), w -> w.like("phone", phone)); wrapper.eq("status", 1);在 XML 里:
<select id="queryList" resultType="User"> SELECT * FROM user ${ew.customSqlSegment} </select>这样的思路本质还是方案一,只是强调“组装的过程放在 service 层”。如果你发现 mapper 参数里同时还要传name、phone这类零散字段,那说明设计已经偏离了 QueryWrapper 的初衷。尽量把动态条件都收进 wrapper 里,XML 的职责就退化成“执行这条 SQL”,而不是“拼装这条 SQL”。
为了帮你快速决策,我把这四种方案的适用场景和坑点放一起:
| 方案 | 核心写法 | 排序支持 | 适合场景 | 主要坑点 |
|---|---|---|---|---|
| 全进 Wrapper | XML 只留${ew.customSqlSegment} | 支持 | 绝大多数新需求 | service 层代码有点长 |
sqlSegment +<where> | <where>内用${ew.sqlSegment} | 不支持 | 需要拼接固定字段 | 排序要单独处理 |
| 固定前缀 + if 兜底 | WHERE 固定条件 + 两段 if | 可兜底 | 遗留 SQL 改造 | 可读性差 |
| Service 层兜底 | 本质是方案一 | 支持 | 团队规范统一 | 对开发习惯有要求 |
我个人的建议:优先方案一,或者方案二。后面两个更适合用来理解原理,不太适合作为团队日常规范。
4. 实战中还要注意的五个细节
4.1 多参数时别忘了 @Param
上面提到过Constants.WRAPPER的常量值为"ew",凡是使用${ew.customSqlSegment}的 Mapper 方法,参数列表里必须有一个名为ew的参数。如果你写了多参数方法却漏掉@Param,MyBatis 对参数命名会退化成param1、param2,此时 XML 里的ew根本找不到。报错现场长这样:
org.apache.ibatis.binding.BindingException: Parameter 'ew' not found. Available parameters are [param1, param2, ...]这个我用亲身经历提醒一下:即使你的方法看起来只有一个 QueryWrapper 参数,也建议加上@Param(Constants.WRAPPER),不要觉得自己在“多此一举”。等后面需求加一个参数进来,很多人会忘记同步改注解,问题会在你最没防备的时候出现。
4.2 排序和分页别跟 customSqlSegment 打架
当你在自定义 SQL 里用${ew.customSqlSegment},wrapper 里的orderBy是能正常拼接到句尾的。但如果你同时又在 XML 里写了固定排序,比如:
SELECT * FROM user ${ew.customSqlSegment} ORDER BY create_time DESC而 wrapper 里也设置了orderByDesc,最终 SQL 就会变成:
SELECT * FROM user WHERE (name = ?) ORDER BY create_time DESC ORDER BY create_time DESC两条ORDER BY同时出现,MySQL 直接给语法错误,或者更隐蔽的是,分页插件在生成 count 统计 SQL 时会尝试去掉 order by,看到这种 SQL 也会解析得很辛苦。所以规矩很简单:自定义 SQL 里的排序只能有一个来源。要么全部交给 wrapper,要么全部在 SQL 里写死,不能两边都排。用分页插件时,尽量把排序交给Page对象或 wrapper,让插件统一处理统计 SQL 的剔除逻辑。
4.3 千万注意 SQL 注入
既然${ew.customSqlSegment}是直接拼接 SQL 片段,那么 wrapper 里的“条件内容”就必须谨慎。eq("status", 1)这种字段名写死在代码里没问题,但如果是把用户传进来的字符串作为字段名或排序字段拼进去,就危险了。比如:
wrapper.orderByAsc(userInput); // 危险 wrapper.apply("date_format(create_time, '%Y-%m-%d') = " + userInput); // 更危险orderByAsc(userInput)中的userInput最终会原样出现在${ew.customSqlSegment}里,等于给 SQL 注入开了一扇门。正确姿势是:排序字段做一个白名单映射,用户传createTime,代码映射成create_time;自定义函数条件用apply的{0}占位符传参:
wrapper.apply("date_format(create_time, '%Y-%m-%d') = {0}", dateStr);这个坑平时不炸,一旦炸就是生产事故,务必防范。
4.4 空 wrapper 的处理千万别忽略
我见过不少同学写自定义 SQL 时,只在 XML 里写${ew.customSqlSegment},觉得 wrapper 反正会传进来,不会为空。但实际调用时,service 层可能因为某些分支构建了一个空的new QueryWrapper<>(),或者直接传了null。此时${ew.customSqlSegment}取到的是 null 还是空串,取决于 MyBatis 对 null 参数的解析,但无论哪种,都可能让整条 SQL 缺斤少两。
更稳妥的做法是在 XML 里加一层空判断:
<if test="ew != null and ew.customSqlSegment != null and ew.customSqlSegment != ''"> ${ew.customSqlSegment} </if>如果你再用方案二或方案三,也要习惯性地给ew.sqlSegment做同样判断。这个习惯能省掉很多“为什么传 null 就出错”的夜间排查。
4.5 别忽略 MyBatis-Plus 版本差异
@Param(Constants.WRAPPER)和${ew.customSqlSegment}从 MyBatis Plus 3.x 开始就是稳定的能力,但具体行为细节在早期和后期版本上有过微调。比如某些 3.4 之前的版本,customSqlSegment对“空条件 + 排序”的处理可能和现在略有差异。所以遇到奇怪行为时,先看一眼 pom 里 mybatis-plus 的版本,再去对一下源码。不要拿网上“3.5.1 亲测有效”的结论直接套到自己 3.3.1 的项目上。
我自己的团队现在统一用 3.5.x,项目里抽了一个工具类,专门根据晚不同的 SQL 片段情况做组合判断,避免每个 mapper XML 里都写重复的<if>逻辑。如果你也觉得维护多,可以考虑在 Wrapper 工具层做封装,而不是每次都手搓。
5. 高频报错与排查速查表
| 报错或现象 | 根本原因 | 排查要点 | 推荐解法 |
|---|---|---|---|
Parameter 'ew' not found | Mapper 方法没加@Param(Constants.WRAPPER),或参数名拼错 | 查方法定义、XML 里${ew.*}写法 | 统一用Constants.WRAPPER,方法参数和 XML 保持一致 |
SQLSyntaxErrorException: ... near 'AND status = 1' | wrapper 无任何条件,customSqlSegment为空串,SQL 里却固定拼了 AND 后缀 | 看打印 SQL,确认AND前是否有 WHERE | 改为方案一,或<where>+sqlSegment |
... near 'ORDER BY ... AND ...' | wrapper 只有排序条件,customSqlSegment为ORDER BY ...,后缀 AND 拼接错误 | 看 wrapper 是否只 set 了 orderBy | 把附加条件放进 wrapper,或改用sqlSegment |
where AND (...) | XML 里自己写了WHERE,又用${ew.customSqlSegment}拼接,它自带 WHERE | 打印 SQL,通常能看到两个 WHERE | 换成${ew.sqlSegment} |
| SQL 执行没问题但结果集不符合预期 | wrapper里有 OR 条件,后续拼接的 AND 改变了运算优先级 | 查看最终 SQL 的括号分布 | 用wrapper.and(...)显式分组,或 SQL 里加括号保护 |
| 分页查询 count SQL 出错 | 自定义 SQL 里既拼了 wrapper 的排序,又写了固定排序 | 看 count SQL 是否有多个 ORDER BY | 排序只保留一个来源,交给分页插件处理 |
这里多说一句,我在排查这类问题时最常用的手段,就是先把 MyBatis 的 SQL 日志打开,看到最终执行的 SQL 长什么样,很多问题一眼就能定位。比如空串导致的AND开头、双WHERE、双ORDER BY,在日志里都特别明显。与其猜 wrapper 内部状态,不如看拼出来的结果。
6. 我的一点收官心得
踩过这个坑之后,我给自己的规矩其实就三条。第一,自定义 SQL 里能用${ew.customSqlSegment}单独收尾的,就绝不去动“再补一个 AND”的念头;第二,如果确实要补固定条件,优先把条件挪进 wrapper,让 SQL 保持简单;第三,实在要在 XML 里拼条件,就老老实实用<where>配合${ew.sqlSegment},并记得处理空判断。
这套写法看起来不复杂,但实际项目中因为换人维护、需求变更,总是会有新的代码不经意间把“旧坑”重新挖出来。我后来在团队里加了一条约定:凡是自定义 SQL 用了 wrapper 参数的,XML 里不允许再出现手写的AND/WHERE直接衔接 ${ew.xxxSegment},除非经过 review 确认了空片段和排序场景都处理过。规则虽然有点死板,但确实帮我省下了不少排查时间。如果你也在用 MyBatis Plus 做复杂查询,不妨把这几条规则复制到你团队的规范里,也许明年这个时候,你就不会因为一句AND status = 1熬到凌晨了。