ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis-Plus QueryWrapper 高频用法详解:从条件构造到动态查询实战

MyBatis-Plus QueryWrapper 高频用法详解:从条件构造到动态查询实战 我先说个真实的场景。去年我接手一个老项目数据层全是手写的 XML SQL光where条件就用if拼了三十多行每次加一个筛选条件都要改两处测试环境动不动就“参数不对”“多了一个 and”的报错。后来我把查询这块整体切到 MyBatis-Plus 的QueryWrapper代码量直接砍掉六成最关键的是“查询条件即代码”编译期就能发现字段名拼写错误。这篇文章就把我这一年来高频使用的QueryWrapper案例整理出来按“条件怎么写、为什么要这么写、踩过什么坑”的顺序讲清楚适合刚接触QueryWrapper的初学者也适合已经用了很久但想补全细节的开发者。1. 为什么查询条件要交给 QueryWrapper而不是继续堆 XML1.1 传统 SQL 拼接的三个痛点很多老项目里动态查询长这样select idselectUserList resultTypeUser SELECT * FROM user where if testname ! null and name ! AND name #{name} /if if testage ! null AND age gt; #{age} /if if testdeptId ! null AND dept_id #{deptId} /if /where /select这代码本身没问题但一旦条件超过五个你会发现三个痛点。第一where和if的组合虽然能自动去掉开头的AND但如果你在条件中间加了个choose或者嵌套if缩进一乱生成的 SQL 就非常难看排查问题全靠肉眼对字符串。第二一个列表页往往对应一个 Mapper 方法A 页面要三个条件B 页面要五个条件你就得复制出两个查询方法。第三数据库字段名在 XML 里是硬编码的Java 代码里也是硬编码的两处对不上只能靠运行时报错来发现。QueryWrapper解决的正是这三件事。它以 Java 代码的方式描述查询条件字段名写错会在编译期报错如果配合 Lambda 写法条件数量可以任意组合一个方法通吃所有筛选场景。1.2 QueryWrapper 在 MyBatis-Plus 中的定位MyBatis-Plus 提供了一套条件构造器顶层是Wrapper抽象类下面分成QueryWrapper、UpdateWrapper和LambdaQueryWrapper、LambdaUpdateWrapper。我们日常查询用到的基本都是QueryWrapper系列。QueryWrapper的本质是一个条件描述器你调用.eq(name, 张三)时它内部维护了一个条件数组最终在 MyBatis 执行时通过AbstractWrapper里的getSqlSegment()方法拼出WHERE name 张三这样的 SQL 片段。这个过程是参数化的不是直接拼接字符串所以能有效避免 SQL 注入。1.3 一个普通查询用 QueryWrapper 怎么写我举个例子。查“某部门下年龄大于 18 岁的用户按创建时间倒序只看前 10 条”QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(dept_id, 1001) .gt(age, 18) .orderByDesc(create_time) .last(LIMIT 10); ListUser users userMapper.selectList(wrapper);对比原来 XML 里的写法这个思路是直线型的——条件按顺序往下加读代码的人不需要在if之间跳来跳去。而且selectList接收Wrapper后MyBatis-Plus 会自动生成WHERE后的语句你没有必要再看 SQL 是什么样只要盯住 Java 条件本身即可。2. 高频条件逐个拆解eq、like、in、between 的用法与对应 SQL2.1 等值与比较条件eq、ne、gt、ge、lt、leQueryWrapper中最基础的就是一组比较方法。每个方法都对应一个 SQL 比较运算符方法对应 SQL示例生成 WHERE 片段eq.eq(name, 张三)name 张三ne.ne(status, 0)status 0gt.gt(age, 18)age 18ge.ge(score, 60)score 60lt.lt(create_time, 2024-01-01)create_time 2024-01-01le.le(level, 5)level 5这些方法都是重载的常见形式是eq(String column, Object val)其中列名直接传数据库字段名。如果你使用LambdaQueryWrapper则可以写成.eq(User::getAge, 18)由 MP 自动映射到数据库列名。我用一个复杂的筛选演示QueryWrapperUser wrapper new QueryWrapper(); wrapper.ne(status, -1) .ge(age, 18) .le(age, 60) .gt(last_login_time, 2024-06-01 00:00:00);对应 SQL 是WHERE status -1 AND age 18 AND age 60 AND last_login_time 2024-06-01 00:00:00多个条件默认用AND拼接。这是QueryWrapper的默认行为也是最常用的行为。如果你想改成OR需要显式调用.or()这点在后面的复杂场景部分我会详细讲因为很容易踩坑。2.2 模糊查询like、likeLeft、likeRight模糊查询是QueryWrapper里最容易被用错的地方。like方法默认是双向模糊也就是%关键词%这在大数据量表上会走全表扫描性能杀手。但业务场景又绕不开“搜索”所以正确了解这几个方法非常重要方法生成 SQL适用场景.like(name, 张)name LIKE %张%通用搜索字段在中间.likeLeft(name, 张)name LIKE %张以某关键词结尾左侧模糊.likeRight(name, 张)name LIKE 张%前缀匹配走索引的理想形态.notLike(name, 张)name NOT LIKE %张%排除包含关键词真实项目中搜索用户姓名、商品名称这种不需要右边模糊的需求我一般用likeRight原因很简单前缀匹配能用上索引。原来系统搜索用户时全用.like()用户量一过百万搜索接口经常超时改成likeRight后响应时间掉了几个量级。要注意的是QueryWrapper的like参数不会帮你自动转义%和_。如果前端传了一个%进来你会惊讶地发现查询结果变多了。处理方式是自己做一下转义String keyword rawKeyword.replace(%, \\%).replace(_, \\_); wrapper.likeRight(name, keyword);2.3 范围查询in、notIn、between范围查询我用得非常频繁尤其是列表页筛选。in生成IN (...)条件notIn是NOT IN (...)between则是闭区间。// IN 查询 wrapper.in(dept_id, Arrays.asList(1001, 1002, 1003)); // NOT IN wrapper.notIn(user_id, Arrays.asList(1L, 2L, 3L)); // BETWEEN 区间 wrapper.between(age, 18, 30);生成的 SQL 分别对应dept_id IN (1001, 1002, 1003) user_id NOT IN (1, 2, 3) age BETWEEN 18 AND 30in的空集合问题值得单独说。如果你把Collections.emptyList()直接传给inMyBatis-Plus 会生成IN ()在 MySQL 里直接报语法错误。所以传入前最好判空if (CollectionUtils.isNotEmpty(deptIds)) { wrapper.in(dept_id, deptIds); }between同样要注意参数类型数据库字段是datetime时建议直接用LocalDateTime对象不要传字符串避免日期格式解析出问题。2.4 空值判断isNull、isNotNull查询“没有绑定手机号的用户”或者“有邮箱的用户”就要用到空值判断。wrapper.isNull(phone); wrapper.isNotNull(email);生成的 SQL 是phone IS NULL和email IS NOT NULL。这个看起来很简单但有个隐性规则需要记清楚在 MySQL 中NULL和空字符串是两种东西。如果某字段允许为空但写入时部分数据落的是空字符串isNull就查不出来。遇到这种脏数据场景我一般配合eq(phone, )或者.and(w - w.isNull(phone).or().eq(phone, ))来处理。3. 动态条件的灵魂condition 参数是怎么帮你省掉一堆 if 的3.1 condition 参数的用法很多人用了一段时间QueryWrapper还在写这种代码if (StringUtils.isNotBlank(name)) { wrapper.eq(name, name); } if (age ! null) { wrapper.eq(age, age); }这个写法没错但完全可以更简洁。QueryWrapper的所有条件方法都提供了一个重载形式第一个参数是boolean condition只有为true时才会追加这个条件。QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(StringUtils.isNotBlank(name), name, name) .eq(age ! null, age, age) .ge(minSalary ! null, salary, minSalary) .likeRight(StringUtils.isNotBlank(keyword), username, keyword);这样一整串下来一个if都不需要。可读性更高而且条件是否生效一目了然。这个boolean condition参数可以接收任何结果为布尔值的表达式实践中最常见的来源是StringUtils.isNotBlank()和对象的非空判断。3.2 与前端查询参数对象配合的实战姿势上面前提是查询参数从哪来。日常项目中前端传参通常会封装成一个UserQueryDTO。你可以直接把这个 DTO 的属性拆出来构造条件public ListUser queryUserList(UserQueryDTO dto) { QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(StringUtils.isNotBlank(dto.getName()), name, dto.getName()) .eq(dto.getAge() ! null, age, dto.getAge()) .eq(dto.getDeptId() ! null, dept_id, dto.getDeptId()) .between(dto.getStartDate() ! null dto.getEndDate() ! null, create_time, dto.getStartDate(), dto.getEndDate()) .orderByDesc(create_time); return userMapper.selectList(wrapper); }你会注意到between的 condition 是“两个日期都不为空”。这种写法把“前端有没有传参”和“条件要不要加进去”完全绑定在了同一行省掉了大量冗长的if逻辑。3.3 关于 condition 的边界别忽略默认值一个很容易忽略的点是数值类型默认值是0不是null。如果你写dto.getStatus() ! null但前端传了一个status0条件依然生效。如果你的查询里 0 表示“全部”那这个条件就不该加此时判断要改成dto.getStatus() ! null dto.getStatus() ! 0。这也算是我踩过的一个暗坑——测试环境数据量少感觉不到问题上线后用户在页面上选“全部”列表反而查不出来了。4. 对象转 QueryWrapperEntity 和 Map 两个方向的转换细节4.1 直接把实体对象传给 QueryWrapperQueryWrapper有一个构造函数可以接收实体对象。传入后MyBatis-Plus 会把实体中非空的字段作为等值条件自动拼装User condition new User(); condition.setName(张三); condition.setStatus(1); QueryWrapperUser wrapper new QueryWrapper(condition); ListUser users userMapper.selectList(wrapper);生成 SQL 等价于WHERE name 张三 AND status 1这个方式非常适合“完全匹配”的场景比如按 ID 查询、按状态查询。但是有一个坑实体里会有一些你不想作为条件的非空字段。比如User有个remark备注字段有值但你不想查它。这种情况下直接传实体就不合适了。更隐蔽的坑是基本类型的默认值。如果User有个age是int类型默认值为0实体传进QueryWrapper后MyBatis-Plus 会把这个0也当成条件生成age 0。查出来的结果大概率是空列表。所以实体转 QueryWrapper 前务必把非包装类型的字段统一用Integer、Long代替int、long或者先对实体做一次字段清理。4.2 MP 底层是怎么处理实体字段的从源码上看QueryWrapper的实体构造最终走的是TableInfoHelper.getTableInfo(clazz)拿到表结构元数据再逐个字段检查值是否为空。非空则生成column {value}条件。它不区分“用户故意传了 0”还是“默认为 0”你要么把所有字段定义为Integer类型要么就不依赖实体构造改为自己逐条件写eq。我的建议是小表、条件固定且简单时实体构造很省事条件复杂、字段多时别偷懒用上面第三节的方式手写条件。4.3 从实体转成 QueryWrapper 后的二次调整即使你用了实体构造new QueryWrapper(condition)后面还是可以继续追加其他条件两个部分会合并User condition new User(); condition.setStatus(1); QueryWrapperUser wrapper new QueryWrapper(condition); wrapper.gt(age, 18) .orderByDesc(create_time); ListUser users userMapper.selectList(wrapper);最后生成的 WHERE 是status 1 AND age 18。这个特性我经常用用实体表达“固定条件”用链式方法表达“动态条件”两者配合很舒服。4.4 Map 转 QueryWrapper 的做法除了实体还可以把查询条件放在MapString, Object中用allEq方法批量转换MapString, Object params new HashMap(); params.put(name, 张三); params.put(status, 1); QueryWrapperUser wrapper new QueryWrapper(); wrapper.allEq(params, false);第二个参数false表示允许空值参与条件true则反之。不过实际项目中我很少用allEq原因是Map的 key 是硬编码字符串容易写错而且类型丢失数字会被当作Integer或BigDecimal偶尔会查不出数据。如果你确实需要动态传一堆查询条件更推荐显式写条件而非Map。4.5 通用的对象转 wrapper 工具思路如果你就是想要“传入一个 DTO自动生成 QueryWrapper”的通用工具我可以分享一个简化实践。基本原理是配合反射或者 MyBatis-Plus 的注解元数据把 DTO 中非空字段自动转成等值条件然后针对特殊字段手动指定查询方式。我曾封装过一个QueryWrapperBuilder用法类似于QueryWrapperUser wrapper QueryWrapperBuilder.build() .eqIfNotBlank(User::getName, dto.getName()) .likeRightIfNotBlank(User::getUsername, dto.getUsername()) .geIfNotNull(User::getAge, dto.getMinAge()) .leIfNotNull(User::getAge, dto.getMaxAge()) .orderByDesc(User::getCreateTime) .toWrapper();这个方法的核心就是condition参数加上 Lambda 字段引用不用每个查询都写一长串但又不失去字段名的编译期检查。后面第 6 节我会展开讲 Lambda 写法。5. exists 条件怎么写apply 的灵活与边界5.1 QueryWrapper 没有原生 exists 方法很多刚接触QueryWrapper的人会到处找.exists()实际上AbstractWrapper里并没有这个专有方法。实现 exists 条件主流方案是使用apply或last把自定义 SQL 片段塞进去。比如查询“存在有效订单的用户”QueryWrapperUser wrapper new QueryWrapper(); wrapper.apply(EXISTS (SELECT 1 FROM orders WHERE orders.user_id user.id AND orders.status 1)); ListUser users userMapper.selectList(wrapper);生成的 SQL 是SELECT * FROM user WHERE (EXISTS (SELECT 1 FROM orders WHERE orders.user_id user.id AND orders.status 1))5.2 apply 与 last 的区别以及防注入apply和last的区别在于它们都会把字符串内容拼接到 SQL 的指定位置区别是apply通常放在WHERE条件体内一般配合( ... )自成一段条件last则是“最后调用它就把内容拼到 SQL 末尾”比如LIMIT、FOR UPDATE多用于非 where 的片段。这里必须强调apply和last接收的是 SQL 片段如果片段里直接拼了用户输入会有 SQL 注入风险。apply支持占位符写法wrapper.apply(EXISTS (SELECT 1 FROM orders WHERE orders.user_id user.id AND orders.status {0}), status);注意占位符是{0}{1}这种格式不是?。MyBatis-Plus 内部会把参数安全绑定避免注入。我见过有人图省事直接${status}字符串拼接这是千万要避免的。5.3 用 in 子查询替代 exists 的场景严格来说exists和in在数据库优化器处理下各有优势但业务上很多“存在”类的需求完全可以用in子查询表达in在QueryWrapper里有原生方法QueryWrapperUser wrapper new QueryWrapper(); wrapper.inSql(id, SELECT user_id FROM orders WHERE status 1);inSql方法接收一段完整子查询 SQL。如果子查询包含外部条件注意这里很难写参数占位符所以我一般只在子查询是静态条件时使用inSql动态值更安全的方式是先查子查询列表再走in。还有一种折中方案先查出来再inListLong userIds orderMapper.selectList( new QueryWrapperOrder().eq(status, 1).select(DISTINCT user_id)) .stream().map(Order::getUserId).collect(Collectors.toList()); if (CollectionUtils.isNotEmpty(userIds)) { wrapper.in(id, userIds); } else { wrapper.last(AND 1 0); }这种场景适合子查询结果集小的情况。如果子查询结果集非常大比如几万条in的性能会崩不如直接用apply写exists让数据库做关联优化。5.4 exists 条件与表别名的配合exists子查询里经常会用到表别名。MP 默认主表别名是表名本身如果你在apply里要引用主表字段直接用表名即可。但如果外面套了多层子查询或者你通过from指定了别名那你需要保证apply里的字符串和最终 SQL 对得上。举个真实例子我曾经写过这样的查询——查“最近一个月有登录记录的用户”QueryWrapperUser wrapper new QueryWrapper(); wrapper.apply(EXISTS (SELECT 1 FROM login_log l WHERE l.user_id user.id AND l.login_time DATE_SUB(NOW(), INTERVAL 1 MONTH)));这段 SQL 里user.id中的user就是主表名。如果你的 Mapper 方法是Select(SELECT u.* FROM user u ...)这种自定义前缀那么apply里的引用要改成u.id。这个细节很容易被忽略多表联查时尤其明显。6. 复杂查询场景or 与 and 的优先级、排序、分组与 select 裁剪6.1 or 条件怎么用才不出错QueryWrapper默认所有条件用AND拼接一旦穿插.or()很多人就开始迷糊了。先看一个常见错误写法// 业务意图查 name 张三 或 email zhangsanexample.com且 status 1 QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(name, 张三) .or() .eq(email, zhangsanexample.com) .eq(status, 1);这段代码生成的是WHERE name 张三 OR email zhangsanexample.com AND status 1由于AND优先级高于OR这条 SQL 实际含义变成了“名字是张三或者邮箱是那个且状态是 1”明显与意图不符。正确写法是使用嵌套andwrapper.eq(name, 张三) .or(w - w.eq(email, zhangsanexample.com).eq(status, 1));生成的 SQLWHERE name 张三 OR (email zhangsanexample.com AND status 1)or(Consumer)这种写法会在or后自动加一层括号里面用.继续连接条件。这套规则我建议死记硬背只要or后面跟多个条件一律用or(w - ...)括号形式不要用裸.or().eq().eq()。6.2 复杂嵌套and 里面套 or反过来如果业务是“状态为 1 且年龄大于 18 或会员等级大于 3”写法则是QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(status, 1) .and(w - w.gt(age, 18).or().gt(vip_level, 3));生成的 SQLWHERE status 1 AND (age 18 OR vip_level 3)注意and方法也接受Consumer括号会自带上。这套and(Consumer)和or(Consumer)的组合是解决复杂条件的核心工具比无穷无尽的.and(w - w.and(w2 - ...))清晰多了。6.3 排序orderByAsc、orderByDesc 与多字段排序排序相关的有三个核心方法方法说明orderByAsc(String... columns)按字段升序orderByDesc(String... columns)按字段降序orderBy(boolean condition, boolean isAsc, String... columns)自定义是否升序多字段排序时可以传多个列名wrapper.orderByAsc(status) .orderByDesc(create_time);生成的 SQL 是ORDER BY status ASC, create_time DESC注意orderByAsc和orderByDesc都支持多个参数多个字段的排序方向可以混合上例就是先按状态升序相同状态再按创建时间降序。如果需求是“字段 A 升序、字段 B 降序”你只能像我上面一样分别调用两次不能指望单次orderBy里传两个方向。6.4 select 裁剪别把大字段查出来QueryWrapper还内置了查询字段裁剪能力通过select指定查询哪些列QueryWrapperUser wrapper new QueryWrapper(); wrapper.select(id, name, age) .eq(status, 1);生成的 SQL 是SELECT id, name, age FROM user WHERE status 1这个用法在列表页非常实用。比如列表只需要显示 id、标题、状态结果对象里却有一个content大文本字段不用select裁剪的话每条数据都把这个大字段查出来网络传输和内存占用都白白浪费。配合 DTO 接收时可以把结果映射到一个轻量 ViewObject 上。select还有一种进阶用法——排除某些列wrapper.select(User.class, info - !info.getColumn().equals(content));这种写法适合实体字段极多你想排除某几个字段的场景。不过实际项目中我更推荐显式列出需要的字段可读性更强select的意图一目了然。6.5 group by 和 having 的使用分组查询在QueryWrapper里对应groupBy和having。比如查每个部门的平均年龄和人数只显示平均年龄大于 30 的部门QueryWrapperUser wrapper new QueryWrapper(); wrapper.select(dept_id, AVG(age) AS avg_age, COUNT(*) AS cnt) .groupBy(dept_id) .having(AVG(age) {0}, 30);这里有个经验之谈select里用了聚合函数时QueryWrapper的返回类型往往不能直接映射到实体而是需要一个 Map 或专门的结果类接收。我的做法是在 Mapper 接口里定义一个返回 ListMapString, Object 的方法或者定义一个DeptStatVO结果类。QueryWrapper本身不关心结果映射它只负责生成 SQL。6.6 last 的正确使用边界last可以将任意 SQL 片段追加到语句末尾比如分页中有时候需要拼接LIMITwrapper.last(LIMIT 10 OFFSET 20);也会有人用它加FOR UPDATE做行锁wrapper.last(FOR UPDATE);last的威力大风险也大。它会直接拼在 SQL 末尾如果里面有用户输入就是注入点。我的使用原则只用静态 SQL 片段不拼接任何外部参数如果一定要带参数优先用apply的占位符或者先查再处理。7. Lambda 表达式从 QueryWrapper 到 LambdaQueryWrapper 的升级路径7.1 为什么推荐 LambdaQueryWrapperQueryWrapper里的字段名是字符串字符串的问题就是无法在编译期检查。我早期写.eq(userName, ...)数据库列名是user_name而不是user_name时MyBatis-Plus 会直接报Unknown column但这种错误通常要跑一次程序才能发现。LambdaQueryWrapper则是用User::getUserName这种方法引用来代替字符串编译期就验证了字段存在性同时 MP 会根据命名规则自动把 userName 转成user_name。如果你的实体字段上配置了TableField(nick_name)Lambda 写法也会自动识别注解里的列名。这种安全性和可维护性提升值得你切换。7.2 基本写法对照两类写法的区别很直观// QueryWrapper QueryWrapperUser qw new QueryWrapper(); qw.eq(name, 张三).likeRight(email, zhangsan); // LambdaQueryWrapper LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.eq(User::getName, 张三).likeRight(User::getEmail, zhangsan);后面接条件的方式几乎一致.ge(User::getAge, 18)、.between(User::getCreateTime, start, end)、.orderByDesc(User::getCreateTime)全部支持。7.3 LambdaQueryWrapper 的隐式坑用 Lambda 也有需要注意的地方。比如你写.eq(User::getName, ...)时如果实体字段名和数据库列名差异不是标准驼峰规则一定要确保实体字段上有TableField注解否则 MP 会按默认规则生成一个自以为是的列名。我之前遇到过实体字段叫uId数据库列叫uid不配注解时 MP 生成的列名直接报错。另外LambdaQueryWrapper 用Serializable作为方法引用的类型约束如果你自定义了非标准的 getter比如getNameWithPrefix()这种就不能作为字段引用因为它不是标准属性。实际上 MP 会自行解析方法引用不规范命名也会导致解析失败所以实体类里保持标准 getter/setter 很重要。7.4 什么时候仍需要 QueryWrapper说了这么多 Lambda 的好处是不是所有场景都该用 Lambda也不一定。QueryWrapper在两种场景下依然有优势一是动态拼接的表名或字段名来自配置或映射关系时字符串是唯一选择二是与Map结构的数据交互时比如查出来的结果直接放进Map或者条件是动态 MapQueryWrapper更方便。所以我项目的习惯是查询条件字段固定时用LambdaQueryWrapper字段名由外部配置动态决定时用QueryWrapper。8. Apply、last、condition 结合真实业务场景的完整拼装示例8.1 场景用户会员列表的组合筛选拿一个真实的后台用户管理页来演示完整拼装。页面筛选条件包括姓名模糊搜索、状态、部门、注册时间段、是否 VIP、按最近登录时间排序、分页。public IPageUserVO queryMemberPage(MemberQueryDTO dto, PageUserVO page) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.likeRight(StringUtils.isNotBlank(dto.getName()), User::getName, dto.getName()) .eq(dto.getStatus() ! null, User::getStatus, dto.getStatus()) .eq(dto.getDeptId() ! null, User::getDeptId, dto.getDeptId()) .between(dto.getBeginDate() ! null dto.getEndDate() ! null, User::getCreateTime, dto.getBeginDate(), dto.getEndDate()); // isVip 为 true 时查存在有效会员记录的用户 if (Boolean.TRUE.equals(dto.getIsVip())) { wrapper.apply(EXISTS (SELECT 1 FROM member_records mr WHERE mr.user_id user.id AND mr.expire_time NOW())); } wrapper.orderByDesc(User::getLastLoginTime); return userMapper.selectPage(page, wrapper); }这个例子里我混合用了condition参数、apply、orderByDesc覆盖了本文绝大多数知识点。尤其注意apply中user.id中的user这是因为 MP 默认主表名就是实体对应的表名没有指定别名。8.2 场景修复一个 SQL 拼接错误的过程演示这里还原我之前排查的一个问题。需求是“查所有经理级别的用户且城市为北京或上海”错误的原始代码是这样的QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(position, manager) .eq(city, 北京) .or() .eq(city, 上海);我当时一看生成的 SQLWHERE position manager AND city 北京 OR city 上海这就变成“所有上海的职位用户”都会查出来。要修正成“经理级别且北京或上海”正确写法是wrapper.eq(position, manager) .and(w - w.eq(city, 北京).or().eq(city, 上海));这个 bug 不仔细看根本发现不了因为返回结果看似“多了几条”逻辑上确实有些上海用户也是经理让人误以为没错。所以排查这类问题时我建议先在日志里把 SQL 打出来看WHERE后的括号位置是否符合业务意图不要只看结果集。8.3 常见问题的排查对照表我在群里和社区里经常看到类似的提问整理一个速查表遇到问题时可以直接对照表现常见原因处理方案查询结果为空但没有报错实体转 QueryWrapper 时带入了 0 值或默认值确认实体字段类型用包装类或改手写条件返回数据比预期多条or没有用括号包住使用or(Consumer)或and(Consumer)嵌套SQL 语法错误Unknown column字段名写错未用 Lambda 写法换成LambdaQueryWrapper或核对列名IN ()报错in传入空集合判空后再追加in条件搜索含%的内容结果异常未转义通配符对关键词做%、_转义apply拼接参数有注入风险直接字符串拼接参数使用{0}占位符或先查再in分页查询时last(LIMIT ...)导致 MP 分页失效手动加了 limit 和 MP 分页冲突用Page参数直接分页不要用last这个表格是我一年来整理的高频问题清单基本涵盖了大多数QueryWrapper的翻车现场。如果你在开发中遇到了其他诡异问题优先把 SQL 日志打出来对照生成的WHERE片段排查八成能快速定位。9. 常见报错与告警的排查思路9.1 报错Unknown column xxx in where clause这个报错的原因非常好判断QueryWrapper中字符串列名写错了或者实体字段上注解配置错误。如果是QueryWrapper写法检查列名是否为数据库真实列名如果是LambdaQueryWrapper检查对应实体字段是否有TableField注解以及注解值是否与表列名一致。9.2 报错Token AND not recognized这通常是apply或last里手动拼了AND导致语法错乱。MP 默认会拼接条件之间的逻辑关系你只需要在apply中写条件内容不要把开头的AND带进去。例如// 错误 wrapper.apply(AND EXISTS (SELECT 1 FROM orders WHERE orders.user_id user.id)); // 正确 wrapper.apply(EXISTS (SELECT 1 FROM orders WHERE orders.user_id user.id));9.3 告警Optimize the SQL with exists instead of in这个告警是 MySQL 的优化器提示通常在子查询结果集很大时出现。如果不影响性能可以先忽略但前提是子查询结果明确较小。若子查询可能返回上万条记录建议改用apply(EXISTS (...))方案。同理如果子查询的数据量不大in的可读性和索引利用并不差不必为了规避告警强行改 SQL。9.4 日志定位技巧开启 SQL 输出排查QueryWrapper问题最直接的方式就是看生成的 SQL。在application.yml里配置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样控制台会输出完整 SQL 和参数列表。我发现很多人一遇到查询结果不对就怀疑 MP 有 bug实际八成是自己条件写错。开启 SQL 日志后把生成结果和业务需求对照一眼就能看到是AND和OR的顺序问题还是条件漏加了。10. 性能影响与使用边界QueryWrapper 不是银弹10.1 QueryWrapper 生成的 SQL 是参数化的先说优点。QueryWrapper的条件最终会通过 MyBatis 的#{}预编译绑定参数也就是参数化 SQL这对防止 SQL 注入非常有帮助。与手写字符串拼接相比这是一个巨大的安全优势。同时参数化查询在 MySQL 中有利于查询缓存和计划复用对数据库压力也更小。10.2 滥用 QueryWrapper 可能导致的性能问题但QueryWrapper也容易被滥用主要集中在这几个方面。第一无条件查询。wrapper.eq(status, 1)后面忘了加其他条件或者condition判断写错导致全表查询。在数据量大的表上一个不带WHERE的selectList就能把数据库拖垮。我习惯在关键查询上加一个“强制限制行数”的保护逻辑比如.last(LIMIT 500)宁可多查一点也不能全表扫。第二大量like模糊查询。前面提过%关键词%无法走索引。如果业务一定要模糊搜索考虑引入搜索服务或全文索引不要在核心大表上频繁使用双向 like。第三循环内查询。很多人会把QueryWrapper写在for循环里一次查询变 N 次查询N 大一点数据库就崩了。这是代码层面的问题不是QueryWrapper的问题但它加剧了隐患。正确的做法是先算出所有需要的 ID 集合再用in一次查出。10.3 什么时候应该放弃 QueryWrapperQueryWrapper擅长的是“根据条件动态生成 WHERE”。但如果业务 SQL 涉及非常复杂的多表 JOIN、窗口函数、递归查询或者 SQL 需要精细控制每条子句的优化提示那QueryWrapper就不是好选择手写 XML 反而更清晰。我的判断标准是三句话条件数量少、结构扁平、以单表查询为主时用QueryWrapper多表关联复杂、SQL 要精细打磨时回退到 XML两者都处理不了时才考虑其他方案。记住QueryWrapper是为了提高开发效率和代码可维护性不是为了替代 SQL。10.4 最后分享一个我自己定的小规矩现在我写查询逻辑有两条硬性规定。第一非特殊需求一律用LambdaQueryWrapper让字段名在编译期被校验第二凡是apply、last这类能塞 SQL 片段的方法写完后必须自己检查一遍确保没有把任何外部输入直接拼进去。这两条规定帮我避免了一大半的线上事故。QueryWrapper用好了是利器用不好就是另一个藏着隐患的地方关键还是在使用时多想一步它背后生成的是什么 SQL那个 SQL 是我真正想要执行的吗。
返回列表