MyBatis-Plus QueryWrapper实战:从动态条件到Lambda写法,告别SQL拼接 📅 发布时间:2026/9/9 19:16:59 👁 浏览次数: 先问大家一个问题你写后管系统用户列表的时候是不是还在if判断里一个条件拼一个and条件少还好前端筛选字段一多接口里全是StringBuilder和if判断看一次脑壳疼一次。我早年就是这么干的直到把QueryWrapper彻底用明白才发现这玩意儿能把条件构造从“手写拼接”变成“链式API调用”代码量直接缩一半还不容易出错。这篇就专门聊QueryWrapper的常用案例从最简单的等值查询到动态条件组合、对象转QueryWrapper、Lambda写法、报表统计再到我实际踩过的几个坑全部用真实业务场景来讲。内容适合刚接触MyBatis-Plus的新人也适合写了几个月Wrapper但一直靠CV的进阶用户——看完你至少能把手上的用户列表、订单报表重构得让同事看一眼就觉得“这人有东西”。1. QueryWrapper到底解决了什么问题1.1 没有QueryWrapper之前条件查询是怎么写的我先还原一个场景后台管理系统里有个用户列表页筛选条件有用户名、邮箱、手机号、状态、注册时间范围大概率还要分页。不带QueryWrapper的时候Service层基本长这样public ListUser queryUsers(String username, String email, Integer status, Date startTime, Date endTime) { StringBuilder sql new StringBuilder(SELECT * FROM user WHERE 1 1 ); if (StringUtils.isNotBlank(username)) { sql.append(AND username LIKE %).append(username).append(% ); } if (StringUtils.isNotBlank(email)) { sql.append(AND email ).append(email).append( ); } if (status ! null) { sql.append(AND status ).append(status); } if (startTime ! null) { sql.append(AND create_time ).append(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss).format(startTime)).append( ); } if (endTime ! null) { sql.append(AND create_time ).append(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss).format(endTime)).append( ); } sql.append(ORDER BY create_time DESC); return jdbcTemplate.query(sql.toString(), userRowMapper); }这么写有两个致命问题。第一SQL注入风险摆在那用户提交的搜索内容一旦带个; DROP TABLE user; --画面不堪设想。第二业务一复杂SQL拼接逻辑和业务代码完全耦合在一起改一个排序规则、加一个筛选条件都要动这一坨代码测试回归范围还跟着变大。当时团队里每换一个人维护这个模块都至少要骂一次“这谁的代码”。1.2 MyBatis-Plus的QueryWrapper是什么QueryWrapper是MyBatis-Plus提供的一个条件构造器它干的事情就是把“要拼的SQL条件”用Java方法调用的方式表达出来框架负责把这些方法调用翻译成安全的SQL片段再拼到最终执行的SQL里。核心好处有三个防注入参数全走预编译#{param}占位符前端传什么都是当字符串处理不存在注入问题。可读性强条件逻辑是一行一行链式调用看下来的代码即文档。动态SQL友好每个条件方法都支持传入一个condition布尔值只有条件成立时才真正拼接完美替代那一堆if判断。它和MyBatis XML里写where标签本质上是同一套理念但QueryWrapper的写法更轻量尤其适合多条件筛选列表这种CRUD密度很高的场景。接下来我就按日常使用频率把这些API一个个拆开讲。2. 最常用的条件方法等值、模糊、范围与排序2.1 eq、ne、gt、ge、lt、le六兄弟怎么用、什么时候用这六个方法分别对应SQL里的、!、、、、。业务里最典型的是eq比如用户详情、订单详情这类按某个唯一键查记录或者列表页按状态精确过滤// 查询id为1024的用户 QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(id, 1024); User user userMapper.selectOne(wrapper); // 查询所有状态为1的订单 QueryWrapperOrder wrapper new QueryWrapper(); wrapper.eq(status, 1); ListOrder orders orderMapper.selectList(wrapper);ne使用的是!语义我用的频率不算高主要在“排除某个状态”的时候出现比如查除了“已删除”之外的所有数据wrapper.ne(deleted, 1);gt、ge、lt、le则天然适合数值和日期范围场景。比如“查询余额大于100元的用户”“查询积分低于等于0的用户”wrapper.ge(balance, 100); wrapper.le(points, 0);日期范围也很常见比如查最近7天注册的用户关键是把时间边界算对。ge是大于等于配合当天00:00:00作为起始点lt配合明天00:00:00作为结束点能保证不重不漏。这里我提一句自己踩过的小坑如果你用le加当前时间去查“今天及之前”的数据当天的23:59:59就会被漏掉因为时间精度到毫秒的时候23:59:59和当前时间之间差着毫秒数。所以日期范围的右边界我一般统一用lt加“下一天的零点”而不是le加“今天的当前时刻”。2.2 like、likeLeft、likeRight模糊匹配的三种姿势后台搜索框最常见的需求就是“根据用户昵称模糊搜索”。QueryWrapper直接给like方法默认是在字段两边都加%// WHERE nickname LIKE %技术% wrapper.like(nickname, 技术);但你要注意like是全模糊如果用户输入的关键词很短比如搜个“张”最终SQL是LIKE %张%在千万级数据表上这列没索引的话基本就是全表扫描。所以实际业务里面像手机号搜索这种前缀规则很明确的场景我更推荐用likeRight——只拼右模糊走索引的机会大得多// WHERE mobile LIKE 138% wrapper.likeRight(mobile, 138);反过来likeLeft是“%关键词”语义是后缀匹配这种场景业务上很少用但在某些日志编号、批次号查询里还是能碰到的。比如查“批次号以20250112结尾的数据”就可以用wrapper.likeLeft(batch_no, 20250112)。这里还有一个特别需要注意的转义问题。MySQL的LIKE语句里%和_是通配符如果用户搜索内容本身含这两个字符比如搜商品名“100%纯棉”直接用like方法生成的SQL会把%当通配符结果就是查出大量不相关的记录。MyBatis-Plus的like方法其实没有自动转义通配符这是很多人容易忽略的细节。我一般会在工具类里做一次过滤处理public static String escapeLikeKeyword(String keyword) { if (StringUtils.isBlank(keyword)) { return keyword; } return keyword.replace(\\\\, \\\\\\\\) .replace(%, \\\\%) .replace(_, \\\\_); }然后在调用like之前先把关键词过滤一遍成本很低但能避免线上不少“看起来莫名其妙”的数据问题。2.3 in、between、isNull多值和空值的处理“查一批ID的数据”是后台高频操作比如批量导出勾选的50条订单。in方法最直接ListLong ids Arrays.asList(1L, 2L, 3L, 100L); wrapper.in(id, ids); // WHERE id IN (1, 2, 3, 100)需要提醒的是ids如果是空集合MyBatis-Plus会生成一条恒假的SQL片段id IN ()在某些数据库会有兼容问题MP内部分情况做了处理但我个人建议调用前先自己判断下集合不为空否则该条件会导致查不出任何数据反而掩盖了前端参数传递的问题。between用于范围闭合查询比如按创建时间筛选、按金额区间筛选wrapper.between(create_time, 2025-01-01 00:00:00, 2025-01-31 23:59:59); // 等价于WHERE create_time BETWEEN 2025-01-01 00:00:00 AND 2025-01-31 23:59:59这里我要说个经验BETWEEN的边界是包含的所以结束时间如果只想包含到1月31日当天别用2025-01-31 00:00:00得用23:59:59或者干脆把结束时间传成“2月1日零点”配合lt当右边界统一好规则后面才不会踩“跨天数据查不到”的坑。isNull和isNotNull处理空值判断比如查“还没有填写微信号的用户”wrapper.isNull(wechat_no); // WHERE wechat_no IS NULL有时候需求是“微信号没填的查出来填了的也要查出来”也就是“IS NULL OR xxx”就不能只用一个方法搞定放到后面讲or嵌套的时候细说。2.4 orderBy排序也有讲究排序看起来简单实际容易出乱子的地方在于多条件排序的优先级。QueryWrapper支持链式叠加wrapper.orderByDesc(create_time).orderByAsc(id); // ORDER BY create_time DESC, id ASC生成的SQL排序顺序和链式调用的顺序保持一致这一点得心里有数。另外我强烈建议只要是分页查询都加一个唯一性字段做第二排序列。因为ORDER BY create_time DESC碰上同一秒创建的大量数据分页会出现同一页数据反复出现或者数据跳过的现象。加一个id ASC或者id DESC做“仲裁”才能保证分页的稳定性。3. 动态条件组合写一个“会看脸色”的查询3.1 condition参数让条件自己判断该不该拼前面例子里的条件都是写死的但真实业务里90%是“前端传了才筛没传就不筛”。QueryWrapper的每个条件方法第一个参数都可以传一个布尔值condition只有它为true时这个条件才会拼到SQL里QueryWrapperUser wrapper new QueryWrapper(); wrapper.like(StringUtils.isNotBlank(query.getName()), name, query.getName()) .eq(query.getStatus() ! null, status, query.getStatus()) .between(query.getStartTime() ! null query.getEndTime() ! null, create_time, query.getStartTime(), query.getEndTime()) .orderByDesc(create_time);这段代码翻译成人话就是名字传了就模糊搜名字状态传了就精确匹配状态开始和结束时间都传了才拼BETWEEN然后统一按创建时间倒序。所有if判断都消失了整个人都清爽了。这里我个人的习惯是凡是接口入参是对象就先把入参里的关键字段用org.apache.commons.lang3.StringUtils或者cn.hutool.core.util.StrUtil做非空判断然后作为第一个condition参数传进去。3.2 or条件到底怎么组优先级坑你踩过没QueryWrapper的默认逻辑是AND方法和方法之间都是AND的关系。当你需要“或者”语义的时候就要用or()。看起来很简单但这里有个优先级问题非常容易踩坑。举个例子查“姓张的用户或者状态为1的用户”。新手很容易写成// 危险写法结果不对 wrapper.like(name, 张).or().eq(status, 1);这生成的SQL是什么样的是WHERE name LIKE %张% OR status 1。单独看好像没问题但如果你前面还有其他条件就翻车了。比如加一个“部门id为3”的条件wrapper.like(name, 张) .or() .eq(status, 1) .eq(dept_id, 3); // 生成WHERE name LIKE %张% OR status 1 AND dept_id 3由于SQL里AND的优先级高于OR这个SQL实际语义是name LIKE %张% OR (status 1 AND dept_id 3)和业务想要的“姓张 且 部门为3 或 状态为1 且 部门为3”完全不一样。正因为如此不要轻易在链式调用的“外层”用or()除非你非常清楚SQL的优先级规则。正确做法是使用and(Consumer)和or(Consumer)传一个函数式子条件让框架帮你把子条件整体包起来wrapper.and(w - w.like(name, 张).or().eq(status, 1)) .eq(dept_id, 3); // 生成WHERE (name LIKE %张% OR status 1) AND dept_id 3业务上很多“组合筛选”需求比如列表页的高级搜索里关键字可能匹配昵称、邮箱和手机号任意一个这种场景就非常适合套一层andwrapper.and(w - w.like(nickname, keyword) .or() .like(email, keyword) .or() .like(mobile, keyword)) .eq(status, 1); // WHERE (nickname LIKE %xx% OR email LIKE %xx% OR mobile LIKE %xx%) AND status 1我个人的建议凡是or条件一律放在and(Consumer)/or(Consumer)里面去做宁可多包一层也不要在外层裸拼or()。这样后续加条件的时候不需要重新分析优先级代码更稳。3.3 按更新时间优化排序除了条件动态列排序也可以这么玩。很多列表页允许用户选择按“最新”“最热”“价格升序”等排序映射到后端就是动态选择排序列String sortField getSortField(query.getSortType()); boolean isAsc query.getSortType() ! null query.getSortType().equals(asc); wrapper.last(ORDER BY sortField (isAsc ? ASC : DESC));这里我要提醒last方法拼接的是原始SQL片段虽然MyBatis-Plus文档里允许你传排序值但绝不建议把前端传的排序字段直接拼进来否则就是SQL注入的大坑。我一般会在代码里维护一个白名单映射MapString, String SORT_FIELD_MAP new HashMap(); SORT_FIELD_MAP.put(createTime, create_time); SORT_FIELD_MAP.put(price, price); SORT_FIELD_MAP.put(sales, sales_count);前端传一个标识后端“翻译”成真实的列名查不到就默认按创建时间倒序。这样既灵活又不会裸接用户输入。4. 面向对象的查询对象转QueryWrapper与LambdaQueryWrapper4.1 实体对象自动转查询条件一个构造器搞定等值查询很多业务场景里前端把查询条件封装成了一个实体对象比如前端传来UserQuery里面可能有status、deptId、level这些字段。如果这些条件全部是等值匹配QueryWrapper提供了一个非常省事的构造器重载new QueryWrapper(实体对象)它会自动把非null字段都作为等于条件。UserQuery query new UserQuery(); query.setStatus(1); query.setDeptId(3L); QueryWrapperUser wrapper new QueryWrapper(query); // 等价于WHERE status 1 AND dept_id 3但实际项目里实体对象里常常有我们不想当成查询条件的字段比如pageNum、pageSize或者一个备注字段。此时可以给不需要的字段加上TableField(exist false)注解放进实体类里。我比较建议的做法是新建一个独立的xxxQuery类只放真正会被用于查询的字段而不是直接把数据库实体类拿去接收查询参数。数据库实体类应该保持ORM的纯粹性查询对象和实体对象分离后期维护成本会低很多。4.2 LambdaQueryWrapper告别硬编码字段名上面例子里的name、status、create_time这种字符串字段名一旦数据库字段改了名代码编译期间根本不会报错运行期才可能直接变成未知字段排查效率非常低。LambdaQueryWrapper的字段引用方式就能解决这个痛点LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(User::getName, 张) .eq(User::getStatus, 1) .ge(User::getBalance, 100);通过User::getName这种方法引用编译器在校验的时候就确认了字段存在同时字段名是框架通过属性名推测出来的和实体类里的TableField映射保持着同步。日常开发中我几乎默认都会用LambdaQueryWrapper只有遇到“非常规查询”才会退回QueryWrapper。Lambda不等于银弹它也有自己的坑。最典型的是团队里改实体字段名时IDE自动重命名只能覆盖方法引用如果某个地方用字符串name写死了IDE是不知道的等到测试时才发现。所以我的团队规范是以LambdaQueryWrapper为默认少部分非常规SQL用QueryWrapper时必须有注释说明用的哪个字段。4.3 对象转LambdaQueryWrapper的两种姿势题目热搜里提到的“对象转querywrapper”实际业务上最常见的是有对象但希望对象里部分字段作为“普通等值条件”另一部分字段做模糊、范围等操作。这种场景我一般分两步走。第一步用构造器直接转// UserQuery里设置status、deptId这种等值字段 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(query.getStatus() ! null, User::getStatus, query.getStatus()) .eq(query.getDeptId() ! null, User::getDeptId, query.getDeptId()) .like(StringUtils.isNotBlank(query.getName()), User::getName, query.getName()) .ge(query.getMinBalance() ! null, User::getBalance, query.getMinBalance());第二步如果Query对象里的一个字段名和实体类属性名对应关系没法靠推断比如前端传了一个minPrice实体类属性是price这属于属性不直接对应那就不能用方法引用的自动推断得手动指定表字段名这时候转回QueryWrapper拿到更多自由度。反过来QueryWrapper转LambdaQueryWrapper很少见一般不强行转换看哪个好写用哪个。4.4 新增和更新操作里的UpdateWrapperQueryWrapper不是只能用于查询MyBatis-Plus里UpdateWrapper和LambdaUpdateWrapper还承担了构造更新条件的任务。比如你要“把状态为1的所有用户积分增加10分”不用先查出列表再循环更新一条update就能完成LambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(User::getStatus, 1) .setSql(points points 10); userMapper.update(null, updateWrapper);这个用法最大的价值是避免了“先select再update”的两步操作既减少一次网络往返又降低并发下读取到旧值的概率。类似地“把某id用户昵称改了”也只走一个update接口LambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(User::getId, 1024) .set(User::getNickname, 新昵称); userMapper.update(null, updateWrapper);这种写法还有一层好处UpdateWrapper和实体对象更新可以互补。实体对象更新天然忽略null值遇到“想主动把某字段置null”的场景就很别扭但UpdateWrapper.set(字段, null)直接解决。5. 从列表分页到报表统计三个完整实战案例5.1 案例一用户分页列表的多条件筛选现在我们把前面的知识点拼起来写一个完整的后管用户分页列表接口。假设请求参数有keyword匹配昵称/手机号、status、deptId、registerTimeRange、sortType。Service层核心代码如下public PageResultUserVO pageUsers(UserQuery query) { PageUser page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.and(StringUtils.isNotBlank(query.getKeyword()), w - w.like(User::getNickname, query.getKeyword()) .or() .like(User::getMobile, query.getKeyword())) .eq(query.getStatus() ! null, User::getStatus, query.getStatus()) .eq(query.getDeptId() ! null, User::getDeptId, query.getDeptId()) .between(query.getStartTime() ! null query.getEndTime() ! null, User::getCreateTime, query.getStartTime(), query.getEndTime()); applySort(wrapper, query.getSortType()); PageUser result userMapper.selectPage(page, wrapper); return convertToPageResult(result); }注意几个关键点。第一keyword存在时用and(w - w.like(...).or().like(...))把目标字段的or条件包在一个括号里避免和后面status条件发生优先级冲突。第二between只在起止时间都传了才会拼防止只传一端导致查询范围变成半开区间却查得很莫名其妙。第三applySort是我抽出来的私有方法内部用一个字段白名单Map控制排序列选择禁止前端直接传列名。这套结构几乎没有多余代码复制到其他管理端接口只需要改实体泛型和字段引用复用性很高。5.2 案例二订单报表统计——groupBy、having、selectQueryWrapper不仅能查列表还能直接做统计报表。比如“按订单状态分组统计每个状态下的订单数量和总金额并且只保留订单数大于等于10的分组”。这种SQL直接用selectMaps配合QueryWrapper就能搞定不需要单独写XMLLambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.select(Order::getStatus, Order::getOrderCount, // 这个字段是虚拟字段统计用的别名 Order::getTotalAmount) .groupBy(Order::getStatus) .having(COUNT(*) 10) .orderByDesc(true, total_amount); ListMapString, Object mapList orderMapper.selectMaps(wrapper);注意这里的select方法里我传了实体类的字段引用但实际SQL里你通常需要的是聚合函数所以更标准的写法是QueryWrapperOrder wrapper new QueryWrapper(); wrapper.select(status, COUNT(*) AS cnt, SUM(amount) AS total_amount) .groupBy(status) .having(COUNT(*) 10) .orderByDesc(total_amount); ListMapString, Object mapList orderMapper.selectMaps(wrapper);为什么用QueryWrapper而不是Lambda因为这里select的列名COUNT(*) AS cnt、SUM(amount) AS total_amount不是实体类属性强行用Lambda反而别扭字符串反而直观。selectMaps返回的结果是一个ListMap的key是select里写的别名如果你没写别名key就是函数名那一长串后面取值容易写错。所以我写聚合SQL的时候基本都会显式给别名。having的应用场景不是每个项目都能碰到但一旦碰到就是“报表过滤”的需求。它和where的区别要说清楚where是在分组前过滤原始行having是在分组后过滤聚合结果。业务上像“统计每个用户下单金额但只展示总金额超10000的大客户”这种就必须用having你不能在where里写SUM(amount) 10000因为where阶段还没有分组数据。5.3 案例三关联查询——exists和apply的灵活使用单表查询是QueryWrapper的舒适区但实际开发总会碰到“查user表条件是关联表的某些数据存在”。比如“查所有拥有会员角色的用户”。ORM层面有Join方法但用QueryWrapper的exists子查询更轻量也更能控制查询范围QueryWrapperUser wrapper new QueryWrapper(); wrapper.exists(SELECT 1 FROM user_role ur WHERE ur.user_id user.id AND ur.role_id {0}, roleId); ListUser users userMapper.selectList(wrapper);这里{0}是MyBatis-Plus的占位符可以安全地传入参数框架会处理成预编译参数避免SQL注入。exists的性能逻辑是“命中一条就停止”非常适合判断关联数据是否存在的过滤器。当然数据量非常大的时候EXISTS和IN怎么选还要看表和索引的具体分布不是绝对的。另一种场景是apply它允许你拼接一段自己掌控的SQL片段适合QueryWrapper没提供的方法覆盖不到的自定义函数比如按JSON字段取值过滤MySQL的JSON_EXTRACTwrapper.apply(JSON_EXTRACT(extra_info, $.vip_level) {0}, 3);注意apply和last都是拼接原始SQL段落但两者的定位不一样。apply是拼接一段“条件”是在WHERE层追加last是直接把SQL片段追加在整个SQL的最末尾主要是拼ORDER BY、LIMIT这类结构。前者接受参数占位符后者通常建议传白名单校验后的常量。无论如何这两个方法都应该谨慎使用能不用就不用的原则不会害你。6. 我踩过的坑和调试技巧6.1 那三个我至今印象深刻的线上问题第一个是 or 优先级问题。有一回运营反馈“筛选条件越多查出来的数据反而越少”我查了半天最后打印SQL发现WHERE name LIKE %张% OR status 1 AND dept_id 3这种结构导致一部分“部门不匹配但名字匹配”的数据被带出来了看起来是变多了实际上个别条件下又漏数据。从那以后我定了一个死规矩所有or条件必须进and(Consumer)或or(Consumer)包一层不做任何例外。第二个是 like 注入通配符问题。商品名称搜索用户搜“50%”结果把所有“50”开头和带各种后缀的商品都拽出来了看起来就像搜索功能出现了bug。后来我在项目里加了统一的转义工具类虽然改成转义后会有极少数场景不匹配用户预期比如用户真想搜索“%”符号但整体利远大于弊。第三个是last(LIMIT 10)和分页插件冲突。有一次我在一个导出的临时场景里用了last(LIMIT 10)结果是因为代码复用到另一个分页接口SQL直接被MyBatis-Plus的分页拦截器再包了一层LIMIT最终SQL是LIMIT 10, 10之类直接报错或查出奇怪数据。解决办法是把last用法限制在“仅内部小范围函数使用”并且必须在代码注释里写明“此方法会拼原始SQL禁止在分页场景复用”。6.2 排查问题一定要养成打印SQL的习惯很多新手写QueryWrapper遇到问题第一反应是“MyBatis-Plus是不是有bug”其实绝大多数时候是自己条件组合逻辑写岔了。我的排查顺序是这样的先把自动生成的SQL完整打出来看看实际执行的语句和你的预期差在哪。配置SQL打印很简单在application.yml里设置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl然后控制台就能看到完整的SQL和参数。要注意的是SQL里的?占位符不会自动替换成实际参数参数会跟在SQL后面打印。你需要把参数值手动带进SQL去看才能准确判断条件是否拼多了、拼错了。另外一个很实用的小技巧测试阶段可以在单元测试里临时把condition参数全部写成true把接口当前请求的所有参数列出来看SQL里条件是不是都有再全部改成false看SQL是不是会退化成一个“裸查”且不带任何条件。这样做一遍基本能暴露90%的动态拼接问题。6.3 性能优化小心“查询全部字段”的隐性开销selectList(wrapper)默认会查询表中所有字段。列表接口还好但在报表导出、批量处理的场景里一条记录十几个字段真正用到的只有三五个全查出来不仅浪费网络带宽还增加数据库IO开销。我一般会通过select方法指定只查需要的字段LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.select(User::getId, User::getNickname, User::getMobile) .in(User::getId, userIds); ListUser users userMapper.selectList(wrapper);字段变少还有一个隐藏的好处selectMaps拿到Map之后如果字段不多内存缓存占用也会小很多。在处理几万、几十万级别的数据导出时这个差距非常明显。6.4 复杂条件到底该用QueryWrapper还是XML最后想聊一个经常被问的问题条件特别复杂的场景用QueryWrapper还是写XML我的标准很简单单表、条件以等值/模糊/范围为主用QueryWrapper多表join、子查询特别深、需要充分优化的SQL上XML。有人说QueryWrapper写多表关联很难看这是事实。apply和exists虽然能搞定一部分但可读性和可维护性确实不如XML直观。反过来像用户列表这种单表CRUD你还专门去XML里写一堆if标签完全没有必要反而增加了文件跳转成本。我的团队规范是单表查询默认QueryWrapper超过两张表关联或者查询逻辑超过6个条件就转XML并且XML里同样保留where标签和if的动态能力。两种方式没有谁优谁劣看场景下菜碟才是老手的姿势。根据我自己这些年的使用体验QueryWrapper最大的价值不是省几行代码而是把“动态条件组合”这个容易出错的过程变成了结构化、类型安全的API调用。你不需要做字符串拼接不需要每次手动加空格和AND条件优先级也更容易理清楚。用它之前我的列表接口又臭又长用它之后处理同类需求的效率明显上了一个台阶。最后再分享一个小体会刚开始用的时候别急着背API先把你手里最常写的三四个查询改成QueryWrapper跑通之后对比下原来的代码长短和可读性你自然就能感受到它的好。如果后面遇到“字段名老是写错”“条件组合优先级混乱”这些问题再回过头来把Lambda写法用起来整个过程循序渐进就好。这套东西不难难的是把“结构化的条件表达”变成一种下意识的设计习惯。