最近在项目里把查询条件从手写XML切到MyBatis Plus,在Mapper自定义SQL里用@Param(Constants.WRAPPER) QueryWrapper接收查询条件,再通过${ew.customSqlSegment}自动拼接条件片段。看着没问题,一跑就翻车: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 = ?' at line 1。
当时第一反应是“我的 and 写错了?”排查半天才发现,真正的问题出在${ew.customSqlSegment}的实际表现上——这个片段并不是总有值,当 QueryWrapper 里没有任何条件时它会变成空字符串,后面再接AND xxx = ?就成了SELECT * FROM user AND status = ?这种非法SQL。这篇文章就把这个坑的来龙去脉、底层原理、正确修法,以及比这更隐蔽的几个相关坑一次讲清楚。适合所有在Mapper自定义SQL中接Wrapper的开发同学,尤其是改造老项目、把多条件查询从手写动态SQL迁到MP的朋友。
1. 先复现一遍:一个标准的踩坑现场
1.1 报错日志与最小代码
先看一个最简的Mapper接口方法:
public interface UserMapper extends BaseMapper<User> { List<User> selectUsers(@Param(Constants.WRAPPER) QueryWrapper<User> qw, @Param("status") Integer status); }XML里是这样写的:
<select id="selectUsers" resultType="com.example.demo.entity.User"> SELECT * FROM user ${ew.customSqlSegment} AND status = #{status} </select>Java侧调用时,我一开始很自然地这样写:
QueryWrapper<User> qw = new QueryWrapper<>(); qw.eq("name", "张三"); List<User> list = userMapper.selectUsers(qw, 1);此时ew.customSqlSegment的值是WHERE (name = ?),拼上后面的AND status = ?之后,最终SQL是:
SELECT * FROM user WHERE (name = ?) AND status = ?这条SQL能正常执行,查询结果也对。问题在于,业务里总有一个“不加任何条件查全部”的场景,比如前端没传筛选条件时:
QueryWrapper<User> qw = new QueryWrapper<>(); List<User> list = userMapper.selectUsers(qw, 1);这次ew.customSqlSegment返回的是空字符串,XML拼出来的SQL变成:
SELECT * FROM user AND status = ?MySQL解析到user后面直接遇到AND,语法报错。更烦人的是,这种错误只在特定入参下出现,常规数据、常规条件都正常,一旦有人不传条件就炸,定位起来非常难受。
1.2 SQL到底被拼成了什么
把日志打开看真实SQL,这个问题会非常直观。在application.yml里加一行:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl再次运行空条件场景,控制台会输出:
==> Preparing: SELECT * FROM user AND status = ? ==> Parameters: 1(Integer)Preparing一行已经清楚地告诉你了:SQL在user表名之后直接就是AND。这行SQL不属于“条件写错”的范畴,而是“连WHERE都没有”。要理解为什么会出现这种情况,必须知道customSqlSegment的拼接规则,这是后文重点。
1.3 为什么会犯这个错:被“自动拼接”误导的思维
我复盘自己踩坑的原因,核心在于把${ew.customSqlSegment}当成了“帮我拼好WHERE条件的完整片段”,下意识认为它会智能处理后续的AND。实际上MP的设计者并没有这么“贴心”——customSqlSegment只是把你Wrapper里的查询条件转换成SQL片段,并在有条件时补上WHERE关键字,它不会帮你管理后面再拼什么。
打个比方,你网购时快递盒上已经贴好了收货地址,你只需要把它放到包裹上;问题是如果你在包裹上手写一行“地址:”,快递员看到的就是“地址:地址:北京市...”。AND也是这样——customSqlSegment为空时,你的AND就成了一个没有WHERE可挂靠的孤儿。
2. 拆开看看:${ew.customSqlSegment} 背后的三个事实
2.1 事实一:Constants.WRAPPER 就是字符串 "ew"
很多第一次接触的人会对@Param(Constants.WRAPPER)感到陌生,其实Constants.WRAPPER是MyBatis Plus定义的一个常亮,值就是一个普普通通的字符串:
public interface Constants { String WRAPPER = "ew"; }所以下面两种写法完全等价:
@Param(Constants.WRAPPER) QueryWrapper<User> qw // 等价于 @Param("ew") QueryWrapper<User> qw既然值是"ew",那么在XML里就必须通过${ew.customSqlSegment}来引用,如果你一时手滑写成@Param("wrapper"),XML里就必须用${wrapper.customSqlSegment},否则MyBatis会告诉你找不到ew这个参数。这个点看起来基础,却是后面第五部分那几个隐蔽坑的来源之一,先记住“ew”是MP内定的名字。
2.2 事实二:customSqlSegment 自带 WHERE 前缀
customSqlSegment的生成逻辑可以简化为:
- Wrapper内部维护了一个条件链表,比如
eq("name", "张三")、ge("age", 18)这些调用都会往链表里追加条件节点。 - 当条件链表非空时,
getCustomSqlSegment()返回"WHERE " + getSqlSegment()。 getSqlSegment()返回的条件本体是带括号的,比如:
WHERE (name = ? AND age = ?)如果条件链表为空且没有额外的lastSql,getCustomSqlSegment()返回空字符串。
这解释了一个非常关键的认知:${ew.customSqlSegment}本身包含WHERE关键字。所以你在XML里写SQL时,前面是不能再用WHERE引导的。网上有人踩过另一种错误,在XML里写成:
SELECT * FROM user WHERE ${ew.customSqlSegment}当Wrapper里有一个条件时,最终SQL是:
SELECT * FROM user WHERE WHERE (name = ?)这显然也是错的。记住这个规则:${ew.customSqlSegment}是一个“带名牌的条件体”,你只需要把它无缝嵌入SQL,不必也不应该在它前面加WHERE。
2.3 事实三:${} 是直拼,#{} 是占位,不能替代
再强调一个基础但重要的点:为什么这里必须用${ew.customSqlSegment},而不能用#{ew.customSqlSegment}?
#{}是MyBatis的预编译参数占位符,它会生成?,然后由JDBC传值。但customSqlSegment是一段SQL结构,不是值。比如WHERE (name = ? AND age = ?)里包含关键字、括号、占位符结构,如果把它当值传进去,数据库只会认为你在比较一个名为customSqlSegment的字段与某个字符串相等,完全不是你要的效果。${}做的是字符串直拼,把内容原样嵌入SQL,也正是因为“原样嵌入”存在注入风险,才需要保证Wrapper里的内容来源可控,尽量通过apply(condition, format, value)这类带参方式传值,而不是把用户原始输入拼接进Wrapper。
3. 正确姿势一:在 XML 中做空值保护
3.1<where>+<if>的完整修法
回到开头那个报错场景,正确的修法并不是去调整AND的写法,而是要让AND不会出现在没有WHERE的场景里。最可靠的方式是用MyBatis的<where>标签配合<if>,在XML层面对customSqlSegment做空值保护:
<select id="selectUsers" resultType="com.example.demo.entity.User"> SELECT * FROM user <where> <if test="ew != null and ew.customSqlSegment != null and ew.customSqlSegment != ''"> ${ew.customSqlSegment} </if> <if test="status != null"> AND status = #{status} </if> </where> </select>这段XML的效果是这样的:
- Wrapper无任何条件,
status传了1:<where>内部只剩AND status = ?,MyBatis会智能去掉开头的AND,输出WHERE status = ?,SQL正常。 - Wrapper有条件,
status也传了1:内部拼成WHERE (name = ?) AND status = ?,第一段以WHERE开头,不会被去掉前缀,SQL正常。 - Wrapper无任何条件,
status也没传:<where>内部为空,什么都不输出,SQL变成SELECT * FROM user,功能上等价于全表查询,至少不会语法报错。
注意:
<where>标签只会去掉所有子元素拼接结果最开头的AND/OR,它不会帮你去掉重复的WHERE关键字。所以如果你不用<if>保护${ew.customSqlSegment},直接把customSqlSegment放进<where>,当它非空时会出现WHERE WHERE (...)的双WHERE错误,这一点和很多人的直觉相反。
3.2 判断条件里为什么是 customSqlSegment != ''
有同学会问:既然customSqlSegment在Wrapper无条件下返回空字符串,那我判断ew != null不就够了吗?
不够。因为调用方可以传一个非null的Wrapper对象,但里面可能一个条件都没加。比如new QueryWrapper<>(),它非null,customSqlSegment却是空串。如果只判断ew != null,这段空串依然会被当成有值的片段渲染进SQL,AND status = ?照样变成孤儿。所以正确做法是两层判断:先判ew != null,再判ew.customSqlSegment != null(防御性)以及!= ''。其中customSqlSegment理论上是不会返回null的,但多写一层不亏,尤其是团队里有人把Wrapper传空时。
3.3 这种修法的两个副作用
第一,如果业务含义是“没有条件时必须拒绝查询全表”,那上面的写法是兜不住的。建议在Service层先做条件判断,比如:
if (qw == null || qw.getCustomSqlSegment() == null || qw.getCustomSqlSegment().isEmpty()) { throw new IllegalArgumentException("至少需要一个查询条件"); }第二,当Wrapper和实体参数混合使用时,<where>的动态判断能兜住查询条件为空的情况,但你可能还会面临“同一个接口,条件一会儿来自Wrapper、一会儿来自普通参数”的代码洁癖问题。这时更建议把普通参数也翻译成Wrapper里的条件,统一用Wrapper传参,这样就彻底不用在XML里写普通参数的<if>了。
4. 正确姿势二:绕开自定义 SQL,回到 MP API
4.1 LambdaQueryWrapper 条件构建示例
很多人用自定义SQL接Wrapper,本质需求只是“根据一组可选条件动态查询”。这种场景其实根本不需要写Mapper自定义SQL,直接在Service层用LambdaQueryWrapper组合条件就行:
public List<User> listByCondition(UserQuery query) { LambdaQueryWrapper<User> qw = Wrappers.lambdaQuery(); qw.eq(StringUtils.hasText(query.getName()), User::getName, query.getName()) .eq(query.getStatus() != null, User::getStatus, query.getStatus()) .ge(query.getMinAge() != null, User::getAge, query.getMinAge()) .orderByDesc(User::getId); return userMapper.selectList(qw); }这种写法的优势在于:每个条件的condition布尔值直接决定是否参与查询,不存在“动态SQL拼错”、“WHERE缺失”、“AND衔接错误”这些低级问题。把查询条件从XML迁到Java侧之后,可读性也更高,过两三个月回头看代码还知道每个条件什么时候生效。
4.2 selectPage 分页场景同样用内置方法
分页也一样,推荐直接用MP的selectPage(Page, Wrapper):
Page<User> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<User> qw = Wrappers.lambdaQuery(); qw.eq(query.getStatus() != null, User::getStatus, query.getStatus()); IPage<User> result = userMapper.selectPage(page, qw);分页SQL由MP插件自动生成,不需要你在XML里手动写LIMIT,也不用担心customSqlSegment影响后续分页语句。
注意:如果项目已经引入了MP分页插件,自定义SQL里千万不要再手写
LIMIT语句,也不要通过wrapper.last("LIMIT 1")去控制条数。分页插件会在你的SQL后追加LIMIT ?,如果SQL里已经有LIMIT,拼接顺序通常都是错的。真要限制结果条数,可以在Wrapper后调用.last("LIMIT 1"),但这种写法要非常小心,它的字符串会被原样拼到SQL末尾,既不好维护,也容易和分页插件冲突。我更推荐按业务量级用Page对象或直接在SQL中显式写清楚。
4.3 什么时候才值得用 ${ew.customSqlSegment}
讲完绕行方案,也得说清楚什么时候确实绕不开。我的经验是两类场景:
- 多表JOIN查询:
selectList只作用于单表,多表JOIN需要自己写主查询SQL,此时把各表筛选条件放进同一个Wrapper,通过${ew.customSqlSegment}注入,是一个比较干净的方案。 - 存量SQL改造:老项目里有一堆复杂查询SQL,不想逐一拆成MP内置API调用,只希望把动态条件部分交给Wrapper,此时接
${ew.customSqlSegment}是性价比最高的方案。
做这两类改造时,XML里记得统一用上面的<where>+<if>模板,不要在多个地方各写一套拼接逻辑,否则条件一多又会出新的衔接问题。
5. 那些比“衔接AND”更隐蔽的坑
5.1 @Param 写错导致 Parameter 'ew' not found
刚开始用MP时,我有一个Mapper方法有两个参数,Wrapper没加@Param(Constants.WRAPPER),XML里却写了${ew.customSqlSegment},结果启动和调用时直接报:
org.apache.ibatis.binding.BindingException: Parameter 'ew' not found. Available parameters are [qw, status, param1, param2]原因在前面已经说过:Constants.WRAPPER的值是"ew",XML里用的是固定名字ew。如果方法参数上不写@Param("ew")或者@Param(Constants.WRAPPER),MyBatis默认会按参数变量名(比如qw)和param1、param2这样的名字建参数映射,XML里找不到ew自然报错。
解决的唯一标准,就是所有接收Wrapper的Mapper方法,参数必须显式标注@Param(Constants.WRAPPER)。别偷懒,也别相信“反正只有一个参数,MyBatis会自动绑定”之类的说法,显式标注能让后面维护的人一眼看懂。
5.2 wrapper 里用 last() 把 ORDER BY 拼死
customSqlSegment拼接AND的问题修好之后,又有人踩了另一个坑:在Wrapper里使用.last("ORDER BY id DESC"),然后在XML中后面再接普通AND条件或分页插件,最终SQL顺序被打乱,比如:
WHERE (name = ?) AND status = ? ORDER BY id DESC AND status = ?last()的本质是把一段SQL直接追加到条件片段末尾,它非常“生猛”,使用它的人必须确保没有任何内容还会拼到它后面。我见过最离谱的,是有人在last("LIMIT 1")后面还硬接一个AND,SQL变成LIMIT 1 AND ...,数据库直接无语。
这种场景推荐做法是:排序条件不要放last(),在业务层用qw.orderByDesc(User::getId);如果需要完整的SQL控制权,就别用last(),直接在自定义SQL里写清楚排序和限制条件,不要让Wrapper去管最后那段。
5.3 条件静默失效:eq(val=null) 的白白查询
比SQL语法错误更可怕的,是SQL明明不报错,但结果完全不对。常见的是这样写:
qw.eq("status", query.getStatus());如果query.getStatus()是null,MP的eq方法并不会自动忽略这个条件,它会生成status = ?,然后由MyBatis把null传入占位符。SQL层面上status = null永远不是true,于是你以为“条件没填就查全部”,实际查询结果却是干干净净的空列表。
这不是${ew.customSqlSegment}本身的问题,而是MP使用习惯问题。正确写法是显式传condition:
qw.eq(query.getStatus() != null, "status", query.getStatus());第一个布尔参数为false时,MP直接跳过这个条件。所有可选查询条件都应该按这个模式写,宁可多敲几个字,也不要给线上留“静默空结果”的地雷。
5.4 多个Wrapper参数混用时的绑定错乱
还有一个比较隐蔽的场景:一个Mapper方法同时接了业务QueryWrapper和另一个用于数据范围的Wrapper,比如:
List<User> selectUsers(@Param("business") QueryWrapper<User> businessQuery, @Param("range") QueryWrapper<User> rangeQuery);这种情况下,XML里写${business.customSqlSegment}和${range.customSqlSegment},看起来都能引用到,但两个Wrapper的条件会平行并列,你很难控制哪个条件在前哪个在后,一旦数据范围条件和业务条件存在括号优先级问题,SQL就会非常混乱。
我的建议是:一个Mapper方法最多接收一个Wrapper。有多个维度的条件时,在Service层用wrapper.and(w -> w...)或wrapper.and(true, w -> ...)把它们合并进同一个Wrapper对象,不要指望在XML里管理多个条件片的拼接顺序。MP的and(Consumer)方法会自动生成括号,保证嵌套条件层级正确。
6. 常见问题速查与排查建议
6.1 问题速查表
| 现象 | 原因 | 解决 |
|---|---|---|
SQL变成了SELECT * FROM user AND status = ? | customSqlSegment为空,AND没有WHERE可依附 | XML中用<where>+<if>判断customSqlSegment != '' |
SQL出现WHERE WHERE (name = ?) | 在XML里自己写了WHERE,又在${ew.customSqlSegment}前再加WHERE | 去掉手写的WHERE,让customSqlSegment自带WHERE |
| 报错:Parameter 'ew' not found | Mapper方法参数没有标注@Param(Constants.WRAPPER),或标注成了别的名字 | 统一用@Param(Constants.WRAPPER)接收Wrapper |
| 查询条件明明传了值,但结果为空 | eq(column, val)中val为null,MP拼接了column = null | 使用eq(condition, column, val)显式控制条件开关 |
| Wrapper里用了last(),后续拼接全部紊乱 | last()把原始SQL追加在条件末尾,破坏后续SQL结构 | 排序/限制条数交给SQL或orderBy,避免在Wrapper里用last() |
注解SQL中<if>用了&&导致解析失败 | XML/注解脚本中&&需要转义 | 使用OGNL支持的and逻辑运算符,或写成&& |
6.2 定位SQL拼接错误的三个技巧
第一,先看日志。Preparing那行SQL是最终送给数据库的完整语句,绝大多数拼接类问题在这一行都能直接看出来。但要注意,如果SQL很长且占位符太多,肉眼找错不容易,可以把SQL复制出来,手动把Parameters替换到占位符位置再检查。
第二,用Java直接打印customSqlSegment。在Service层写一句:
System.out.println(qw.getCustomSqlSegment());可以把Wrapper生成的条件片段完整打出来,快速确认条件是否有值、是否带了WHERE前缀。这个方法在排查“为什么SQL不对”时非常高效,比反复重启项目调试XML快得多。
第三,责任锁到最小测试用例。把出错的Mapper接口、XML片段和Service调用浓缩成一个单独的测试方法,固定入参后跑一次,观察日志输出和报错信息。很多人踩坑后直接在庞大业务流程里反复断点,效率极低,其实问题往往只出在拼接顺序上。
6.3 我最后的建议:什么项目该用、什么项目别碰
经历这次踩坑后,我给自己定了一个规矩:能用MP内置方法解决的查询,绝不为了“看起来优雅”去写${ew.customSqlSegment};只有多表JOIN或存量复杂SQL改造,才考虑在自定义SQL中接Wrapper,而且XML统一使用第一节那套<where>+<if>模板。
最后再分享一个小技巧:如果你实在要在XML里手动拼接一个带AND的普通条件,并且不想依赖<where>标签,可以这样处理:
<select id="selectUsers" resultType="com.example.demo.entity.User"> SELECT * FROM user ${ew.customSqlSegment} <choose> <when test="ew != null and ew.customSqlSegment != null and ew.customSqlSegment != ''"> AND status = #{status} </when> <otherwise> WHERE status = #{status} </otherwise> </choose> </select>这种写法把AND和WHERE的选择逻辑显式写出来,一点歧义都没有。虽然比<where>模板啰嗦,但对第一次接触这个机制的同事来说,理解成本反而更低。我自己现在维护的几个老项目里,这两种写法并存,选哪种取决于团队的熟悉程度;但所有新项目,我都会优先推荐直接用LambdaQueryWrapper在Service层把条件组装好,那才是真正省心的路。