MyBatis-Plus QueryWrapper实战:从条件构造到嵌套避坑指南
先说点实在的。我这些年经手的项目只要用了 MyBatis-Plus几乎天天都在和 QueryWrapper 打交道。刚入行的同事经常拿着构造器文档问我这个 or 嵌套到底怎么个写法为什么我的条件查出来结果不对查出来的数据明明很多却感觉很卡是不是 wrapper 写坏了实际上大部分场景下QueryWrapper 都没有用对。这篇文章我把日常开发里反复用到的案例整理了一遍从最基础的条件拼接到对象自动转 wrapper 的进阶玩法再到我踩过的几个比较隐蔽的坑一次讲清楚。这文适合刚接触 MyBatis-Plus 的新人抄作业也适合写了两年还是只会 eq、like 的老开发换换思路。1. 为什么我建议你优先用LambdaQueryWrapper1.1 普通QueryWrapper和Lambda版的核心差异QueryWrapper 和 LambdaQueryWrapper 都能构造查询条件核心区别就是列名的写法。普通 QueryWrapper 写的是字符串QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(name, 张三);LambdaQueryWrapper 写的是方法引用LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getName, 张三);我强烈推荐用 Lambda 版本原因很现实。第一字符串列名没有任何编译期检查你手滑把name写成nmae编译照样通过等跑起来才知道报错。第二数据库字段一旦调整IDE 全局搜字符串根本搜不干净。第三Lambda 写法天然支持泛型实体类字段改名编译期直接报错逼着你改过来。还有一个肉眼可见的好处就是代码提示。你用User::getName这种写法IDEA 能自动提示实体类的字段写起来快很多也不需要来回切表结构文档。1.2 基础条件方法速查日常用到的条件方法其实就那十来个先把它们都列出来混个脸熟方法对应 SQL 片段场景eq精确匹配ne!不等于gt / ge/大于 / 大于等于lt / le/小于 / 小于等于likeLIKE %value%模糊查询likeLeftLIKE %value左模糊likeRightLIKE value%右模糊走前缀索引in / notInIN (...)/NOT IN (...)集合匹配isNull / isNotNullIS NULL/IS NOT NULL空值判断betweenBETWEEN v1 AND v2区间范围groupByGROUP BY分组统计havingHAVING分组后过滤orderByAsc / orderByDescORDER BY排序last直接拼接 SQL兜底方案慎用apply拼接 SQL 片段复杂函数and / orAND/OR条件组合nested( ... )嵌套条件这里要多说一句like 查询看着简单但实际使用要讲究。业务里如果只需要按前缀匹配比如查手机号前几位、订单号前缀一定要用likeRight这样数据库还能走索引。两边都带百分号的like是百分百全表扫描的数据量一大就等着慢查询告警吧。1.3 动态条件怎么写才不臃肿这是 QueryWrapper 最实用的特性之一动态拼条件。以前的写法是手动拼接 SQL或者用if标签在 XML 里写一堆判断。QueryWrapper 直接在方法层解决了这个问题所有条件方法都提供了condition这个重载。第一个参数传 boolean为 true 时才拼接这个条件。举个例子一个用户列表查询name 可能有、可能空age 也可能不传LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper .eq(StringUtils.isNotBlank(name), User::getName, name) .ge(age ! null, User::getAge, age) .between(startTime ! null endTime ! null, User::getCreateTime, startTime, endTime) .orderByDesc(User::getCreateTime);第一眼看上去很清爽没有一坨 if else。这个写法在分页列表接口里尤其常用前端传什么条件你就往里塞什么条件。但有一点要注意凡是这种“动态条件 查询接口”的地方建议在 mapper 层加个参数校验比如 limit 最大条数、时间范围跨度判断这些别让一个超大范围查询直接打穿数据库。这一节内容覆盖的是最基础的日常查询。接下来聊一个稍微绕一点、但项目里一定会遇到的东西。2. and/or组合与嵌套条件的正确姿势2.1 一个真实场景部门薪资组合查询直接讲 or 和 and 组合是最容易写错的我拿一个真实案例来说。需求是查技术部薪资大于 8000 的人或者产品部薪资大于 6000 的人。翻译成 SQL 是WHERE (dept 技术部 AND salary 8000) OR (dept 产品部 AND salary 6000)很多新手会这样写wrapper.eq(dept, 技术部) .gt(salary, 8000) .or() .eq(dept, 产品部) .gt(salary, 6000);看起来好像没问题但实际生成的 SQL 是WHERE dept 技术部 AND salary 8000 OR dept 产品部 AND salary 6000因为在 SQL 里 AND 优先级比 OR 高所以这条 SQL 其实等价于WHERE (dept 技术部 AND salary 8000) OR (dept 产品部 AND salary 6000)巧了这个例子碰巧结果是对的。但如果你换个条件问题就暴露了。需求改成技术部里面薪资大于 8000或者所有部门里名字叫“张伟”的。你的直觉写法wrapper.eq(dept, 技术部) .gt(salary, 8000) .or() .eq(name, 张伟);这条 SQL 实际执行的是WHERE (dept 技术部 AND salary 8000) OR name 张伟需求是啥技术部薪资大于 8000 的人加上全公司名字叫张伟的人。哦等一下这个需求其实和这条 SQL 语义是一样的。那我再换一个需求。需求查技术部里薪资大于 8000 或者名字叫张伟的人。注意这是同一个部门内的“或”整体条件是技术部 AND薪资 8000 OR 名字 张伟。WHERE dept 技术部 AND (salary 8000 OR name 张伟)如果还用刚才那种平铺写法wrapper.eq(dept, 技术部) .gt(salary, 8000) .or() .eq(name, 张伟);生成 SQL 就是WHERE dept 技术部 AND salary 8000 OR name 张伟这个结果就错了。它查出的是技术部里高薪的人加上全公司所有叫张伟的人。这就是 or 和 and优先级搞出来的坑。平铺写法里 or 是在“已有所有条件”的基础上做或根本没法表示“括号里的或”。这时候必须用 or 的嵌套写法。2.2 嵌套条件的两种写法MyBatis-Plus 提供了两种嵌套写法我用 Lambda 方式展示。第一种直接在 or 后面接 lambda 子条件LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getDept, 技术部) .and(w - w.gt(User::getSalary, 8000).or().eq(User::getName, 张伟));生成的 SQL 是WHERE dept 技术部 AND (salary 8000 OR name 张伟)这正好满足需求。这里and(w - ...)的意思是把括号里的内容整体用 AND 拼接上来括号内部又是一个完整的条件链可以继续用 or。第二种用 nested 方法也能实现类似效果wrapper.eq(User::getDept, 技术部) .nested(w - w.gt(User::getSalary, 8000).or().eq(User::getName, 张伟));nested 的作用就是生成一对括号。再回到最开始的部门薪资组合需求正确写法是wrapper.and(w - w.eq(User::getDept, 技术部).gt(User::getSalary, 8000)) .or(w - w.eq(User::getDept, 产品部).gt(User::getSalary, 6000));说白了只要条件里有“某个大括号里面既有 AND 又有 OR”就一定要用and(...)或者or(...)的 lambda 重载把括号语义显式表达出来。别图省事平铺写运气好碰巧对运气不好排查半天。2.3 打印SQL日志先看清再下结论我排查组合条件问题第一步永远都是看打印出来的 SQL。MyBatis-Plus 配置一下日志就行mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl输出效果类似 Preparing: SELECT id,name,dept,salary FROM user WHERE dept ? AND (salary ? OR name ?) Parameters: 技术部(String), 8000(Integer), 张伟(String)看到这条日志再去对比你脑子里预期的 SQL一眼就能发现括号是不是错位了。我在实际项目里见过太多人代码写完了不去看日志凭感觉认为没问题结果上游联调的时候被对方一句“你这条件不对吧”问懵。所以真的养成看 SQL 日志的习惯排查效率翻倍。3. 排序、分组、字段投影一个都不少3.1 orderBy和last的取舍排序用的就是orderByAsc和orderByDesc可以传多个字段wrapper.orderByDesc(User::getCreateTime) .orderByAsc(User::getId);等价于ORDER BY create_time DESC, id ASC。注意多次调用 orderBy 是会叠加的你不要担心后面覆盖前面。有些场景比如ORDER BY FIELD(status, 1, 3, 2)这种自定义排序规则普通 wrapper 方法表达不了有人会直接last(ORDER BY FIELD(status, 1, 3, 2))。这个我劝你谨慎。last方法会把传入的字符串原样拼到 SQL 末尾它不检查内容也不做任何防护。你要是把用户传入的参数直接拼进去这就是妥妥的 SQL 注入漏洞。而且 MyBatis-Plus 分页插件和last一起用的时候会出现 SQL 拼接顺序错乱的问题排序列被拼到分页语句外面直接报语法错误。能用orderByAsc、orderByDesc解决的坚决不要用last。实在要自定义排序把排序列做成枚举用白名单方式接收参数再用apply或last安全拼接String safeOrder FIELD(status, 1, 3, 2); wrapper.last(ORDER BY safeOrder);核心原则last里永远不要出现用户原始输入必须经过白名单校验。再来说排序里一个特别容易忽略的性能问题。排序字段如果没有索引数据库会先filesort数据量一大查询直接就慢了。我们项目里遇到过一次一个 500 万行的流水表按创建时间倒序查user_id 没索引查一次要一两秒。后来在(user_id, create_time)上建了联合索引查询时间降到几十毫秒。所以排序字段的选择要提前和表结构设计一起考虑不是光在代码里写个 orderBy 就完事。3.2 groupBy和having配合聚合函数分组统计也是老需求比如按部门统计人数和薪资总和。QueryWrapper 的写法QueryWrapperUser wrapper new QueryWrapper(); wrapper.select(dept, COUNT(*) AS cnt, SUM(salary) AS total_salary) .groupBy(dept) .having(COUNT(*) {0}, 2); ListMapString, Object result userMapper.selectMaps(wrapper);这里有两个关键点。第一个select方法里直接写了聚合函数和别名返回类型只能是ListMapString, Object因为聚合结果没法直接映射到实体类上。你拿这个结果集的时候cnt和total_salary在 map 的 key 里类型是 Long、BigDecimal 这些转换的时候注意一下。第二个having的参数用的是{0}占位符这是 MyBatis-Plus 的参数绑定机制会对参数做预编译处理。写having(COUNT(*) count)这种字符串拼接和last一样存在注入风险别这么搞。配合wrapper.select使用 group by字段选择上也要注意MySQL 的ONLY_FULL_GROUP_BY模式下select 出来的非聚合列必须都在 group by 里否则直接报错。写分组查询之前先确认一下数据库的 sql_mode。3.3 select控制查询列减少无谓开销默认情况下selectList 查出来的实体类带上所有字段。但是有些表字段特别多几十列其中还有 text 类型的大字段查询接口根本用不到白白占用 IO 和内存。这种场景可以用 select 方法限定返回列LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.select(User::getId, User::getName, User::getDept) .eq(User::getStatus, 1);生成的 SQL 就是SELECT id,name,dept FROM user WHERE status 1。用 Lambda 的select还有个额外好处MyBatis-Plus 会把非查询列在结果映射时留空不会因为查不到值报错。如果你是做报表查询或者列表接口优化的这个特性一定要用起来。尤其是那种老业务表字段越堆越多你只挑需要的列查性能提升立竿见影。4. 进阶实践把对象转成QueryWrapper4.1 为什么需要对象转条件这个点是我在某次看到同事代码时突然想起来的。他写了一个查询接口前端传了一个查询对象后端用 if 判断一个个把字段塞进 wrapper写了二十几行。等他下次再遇到类似的查询对象又复制粘贴改一遍。这样做的问题很明显字段一多代码全是重复的机械操作而且特别容易漏掉某个条件。有没有更优雅的方式直接传一个查询对象进去让框架自动把非空字段变成 QueryWrapper 条件。其实网上也有人管这个叫“对象转 querywrapper”本质就是通用查询对象到条件的自动转换。这个思路在后台管理系统的列表查询里非常好用几个查询条件就能抽象成一个 Query 对象整个项目共用一套转换逻辑。4.2 基于注解加反射的实现思路实现思路不复杂核心是三步定义注解、反射遍历字段、按注解类型生成条件。先定义一个注解用来描述字段转成什么条件Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface QueryField { // 条件类型默认等于 ConditionType type() default ConditionType.EQ; // 数据库列名默认用驼峰转下划线 String column() default ; // 是否忽略空值默认忽略 boolean ignoreNull() default true; }条件类型枚举public enum ConditionType { EQ, NE, LIKE, LIKE_LEFT, LIKE_RIGHT, GT, GE, LT, LE, BETWEEN, IN, ORDER_ASC, ORDER_DESC }然后写一个转换工具反射遍历对象字段拿到注解和值动态拼接 wrapper。这个工具是核心public class QueryWrapperUtil { public static T LambdaQueryWrapperT build(Object query) { LambdaQueryWrapperT wrapper Wrappers.lambdaQuery(); Field[] fields query.getClass().getDeclaredFields(); for (Field field : fields) { QueryField qf field.getAnnotation(QueryField.class); if (qf null) { continue; } field.setAccessible(true); Object value; try { value field.get(query); } catch (IllegalAccessException e) { throw new RuntimeException(e); } if (qf.ignoreNull() value null) { continue; } if (value instanceof String ((String) value).isEmpty() qf.ignoreNull()) { continue; } String column qf.column(); if (column.isEmpty()) { column camelToUnderline(field.getName()); } applyCondition(wrapper, qf.type(), column, value); } return wrapper; } private static T void applyCondition(LambdaQueryWrapperT wrapper, ConditionType type, String column, Object value) { switch (type) { case EQ: wrapper.eq(column, value); break; case NE: wrapper.ne(column, value); break; case LIKE: wrapper.like(column, value); break; case LIKE_LEFT: wrapper.likeLeft(column, value); break; case LIKE_RIGHT: wrapper.likeRight(column, value); break; case GT: wrapper.gt(column, value); break; case GE: wrapper.ge(column, value); break; case LT: wrapper.lt(column, value); break; case LE: wrapper.le(column, value); break; case ORDER_ASC: wrapper.orderByAsc(column); break; case ORDER_DESC: wrapper.orderByDesc(column); break; default: break; } } private static String camelToUnderline(String camel) { return camel.replaceAll(([a-z])([A-Z]), $1_$2).toLowerCase(); } }4.3 实现代码与使用示例写一个查询对象给字段加上注解public class UserQuery { QueryField(type ConditionType.EQ) private String name; QueryField(type ConditionType.LIKE) private String phone; QueryField(type ConditionType.GE) private Integer ageStart; QueryField(type ConditionType.LE) private Integer ageEnd; QueryField(type ConditionType.ORDER_DESC, column create_time) private String orderBy; }使用的时候一行搞定UserQuery query new UserQuery(); query.setName(张); query.setAgeStart(18); LambdaQueryWrapperUser wrapper QueryWrapperUtil.build(query); ListUser users userMapper.selectList(wrapper);这样 controller 层接收前端参数直接构建查询对象再转成 wrapper代码量减少大半。新增查询条件的时候只需要在 Query 对象上加字段、加注解不需要动业务逻辑。4.4 这个方案的边界与替代选择用反射实现对象转 wrapper 虽然好用但要清楚它的边界。反射有轻微的性能损耗但在查询接口这个场景一次查询几十次反射调用影响微乎其微。真正需要注意的是下面几点。第一排序字段、分页参数这类控制字段不要放进通用条件里最好单独拿出来处理不然前端传入一个恶意字段名反射直接映射到 DB 列名会有注入风险。第二这个方案适合简单等值、范围、模糊查询复杂嵌套 or 条件还是老实手写 wrapper别硬套。第三如果项目里参数校验很重你可以在注解上再加校验规则字段扩展性很好。比如加一个required属性允许某些字段即使为空也拼接IS NULL这个按业务需求自己加就行。如果不想自己写反射工具也可以看看一些开源项目里的实现比如一部分后台管理系统框架就内置了类似的QueryCondition注解。但是自己动手写一遍你才能真正理解 wrapper 的条件生成逻辑出了问题也知道去哪排查。5. 常见问题排查与经验速查5.1 高频坑点对照表文章最后一部分把我见过的高频问题整理成一张表。这些坑我基本都踩过有的坑在项目里发生过不止一次。问题原因解决方案查询结果和预期不一致or 和 and 优先级不清括号错位用 and/or 的 lambda 嵌套写法观察 SQL 日志条件构造器复用导致条件叠加wrapper 对象没有每次 new每个查询方法都 new 一个 wrapper不要成员变量复用SQL注入风险last 和 apply 里直接拼接字符串参数用{0}占位符或走白名单校验字段名变更后代码报错QueryWrapper 硬编码字符串列名改用 LambdaQueryWrapperin 条件数量过大SQL 超长列表查询塞了几万个 ID分批查或走临时表 joinlike 查询全表扫描两边都带百分号索引失效前缀匹配用 likeRight排序失效排序字段无索引filesort 耗时建联合索引控制排序字段分页数据不对分页插件 SQL 和 orderBy 冲突排序必须写在 wrapper 里不要用 last 拼select 后部分字段为 nullselect 只选了部分列接口需要哪些字段select 就选哪些5.2 几点独家心得再说几个文档里不会写的细节。第一个是条件构造器不要设计成公共静态方法反复调用。我见过有人在工具类里定义了一个commonWrapper()方法返回一个初始化好的 wrapper然后在多个查询里再往里加条件。因为 QueryWrapper 内部维护的是可变条件列表不是每次调用都重新创建一个干净的对象你这次往里面加的条件会残留在下次使用里导致查询条件越来越离谱排查起来非常痛苦。我的习惯是业务方法里直接Wrappers.lambdaQuery()条件按需加绝不复用。第二个是空值判断的坑。动态条件里condition参数为 false 的时候这个条件不会拼接那等于说这个查询条件被“悄悄忽略”了。有些业务场景是不允许被忽略的比如查某个状态等于某个值如果前端没传你期望的是不筛选但产品可能期望查默认状态。这里一定要和前端约定清楚哪些字段不传就是不过滤哪些字段不传必须有默认值。第三个是关于逻辑删除。MyBatis-Plus 配置了逻辑删除之后自动拼接的deleted 0条件是在最后追加的和 wrapper 条件不冲突但是last拼接的 SQL 是直接追加在整条 SQL 末尾的有概率出现在deleted 0之后。如果你在last里拼了LIMIT之类的语句可能和逻辑删除条件连起来产生语法问题。同样last要谨慎用。第四个是服务层判断条件是否命中。用 QueryWrapper 做 update 的时候一定先想清楚如果 wrapper 条件为空会不会把全表更新了。MyBatis-Plus 的 update 方法如果传入的 wrapper 没有条件默认会更新所有记录这个在真实环境下是非常危险的操作。我现在的习惯是在更新之前写一行代码判断 wrapper 是不是空条件是空就直接抛业务异常终止操作。5.3 最后再分享一个小技巧排查 QueryWrapper 问题的时候除了看 SQL 日志我还会本地直接写个单元测试把构造好的 wrapper 打印出来快速验证条件逻辑对不对。尤其是 or 嵌套这种容易出错的地方先用测试跑一遍再上环境联调能省下不少时间。就拿我这个项目来说凡是涉及多条件分页查询的接口我都会在测试里把条件完整构造好断言 SQL 字符串包含预期的片段。这个习惯帮我挡掉了好几次潜在的事故你也值得拥有。