做了快十年的 Java 后端,我写复杂查询的时候最怕的不是 SQL 难写,而是条件一多、拼接起来全是 if,代码又臭又长,还容易漏一个空格、多一个括号。后来我把 MyBatis-Plus 的 Wrapper 用顺了,这类问题基本从我的代码里消失了。Wrapper 是 MyBatis-Plus 里用来构造查询条件的那套 API,也有人叫它“条件构造器”,平时我们主要接触的是 QueryWrapper、 LambdaQueryWrapper、 UpdateWrapper 这一族。它最大的价值在于:把原本要在 Java 里手动拼接 WHERE 条件的活儿,改成一种可链式调用、可动态拼接、还能走编译期类型检查的写法。尤其是 Lambda 写法,字段名不写字符串,直接引用实体类的 getter,重构实体字段的时候不容易翻车。这篇文章我把自己从入门到避坑整理了一遍,适合刚接触 MyBatis-Plus 的 Java 开发,也适合已经用了 Wrapper 但经常踩坑的同学。
1. 为什么我建议你优先用 Wrapper
很多老项目到现在还在 Mapper XML 里写一大堆<if>标签做动态 SQL。不是说 XML 不能写,而是在大部分简单查询场景下,Wrapper 能把“条件构造”这件事直接搬到 Java 代码里,让维护的人不用来回切换 XML 文件和 Java 文件。你拿到一个查询需求,直接在 Service 层把条件链式拼好,Mapper 方法签名就是一个空壳,项目代码的可读性会明显提升。
1.1 Wrapper 到底在干什么
Wrapper 本质上就是把 SQL 的 WHERE 片段抽象成了 Java 对象。你在 Java 里调用eq、like、in这些方法时,它内部会维护一个条件集合,最后在 MyBatis 执行 SQL 前,把这些条件翻译成标准的 SQL 片段拼进语句里。咱们手动写代码时容易犯的“动态 SQL 拼接错误”,比如少个空格、少个and、括号不匹配,Wrapper 基本都帮你规避了。
提示:Wrapper 本身不改变 SQL 的执行方式,它只是帮你组 SQL。真正执行还是走 MyBatis 的 PreparedStatement,所以参数会自动用
?占位,不会出现传统字符串拼接注入问题。
举一个直观的例子。假设我们要查status = 1且name不为空的所有商品,传统写法是:
String sql = "SELECT * FROM goods WHERE status = " + status + " AND name LIKE '%" + name + "%'";这种写法非常危险,只要有用户输入,就必须自己做转义。用 Wrapper 就简单了:
LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Goods::getStatus, 1) .like(Goods::getName, name); List<Goods> list = goodsMapper.selectList(wrapper);你看,条件是一个 Java 方法调用,值被当作参数传入,MyBatis 会安全地处理,既不需要字符串拼接,也不需要关心要不要加引号。这就是 Wrapper 解决的核心痛点:安全、可读、可组合。
1.2 QueryWrapper 和 LambdaQueryWrapper 怎么选
这两个类功能几乎一样,最大的区别是字段名的表达方式。
QueryWrapper 使用字符串指定字段名:
QueryWrapper<Goods> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1).like("name", "手机");LambdaQueryWrapper 使用方法引用:
LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Goods::getStatus, 1).like(Goods::getName, "手机");我强烈建议新项目一律用 LambdaQueryWrapper。原因很简单:编译期就能检查字段是否存在。你如果写"stauts"这种字符串,编译器不会报错,等 SQL 跑到数据库才抛异常;但你写Goods::getStauts,IDE 直接红色波浪线提醒你,这比什么都管用。
另外,当你重构实体类字段名时,QueryWrapper 里的字符串不会跟着变,运行时才炸;Lambda 写法因为引用了方法,IDE 的重构功能会帮你一起改。长远来看,维护成本天差地别。唯一需要用 QueryWrapper 的场景,可能是数据库字段名与实体属性名差异很大,或者根本没有对应实体字段的复杂统计列,这种情况偶尔用一下没问题,但别当成默认选择。
1.3 用实体对象做条件行不行
MyBatis-Plus 的selectList(entity)确实支持直接用实体对象作为查询条件,它的规则是“非空字段自动等值匹配”。这种写法短期内很爽:
Goods query = new Goods(); query.setStatus(1); query.setName("手机"); goodsMapper.selectList(query);但坑也很明显:你想查name != '手机'怎么办?你想查status IN (1,2,3)怎么办?实体对象做条件的表达能力太弱,只能做等值匹配。所以它只适合极简单的表单查询。遇到稍微复杂点的场景,老老实实用 Wrapper。我见过不少项目前期用实体对象偷懒,后期需求一多全推倒重写,非常痛苦。可以用,但不要把它当作万能方案。
2. LambdaWrapper 写法的正确姿势
很多刚接触 Wrapper 的同学喜欢把一长串方法连着写,看着很拉风,但写着写着就乱了,尤其是and、or混在一起时,条件优先级经常和业务想法不一样。我建议你对 Wrapper 的方法分组记忆:条件匹配类、范围模糊类、分组排序类、SQL 片段补充类。
2.1 从 QueryWrapper 迁移到 LambdaQueryWrapper
老项目里可能到处都是 QueryWrapper,迁移其实特别简单。构造方式改成:
// 旧写法 QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("age", 18).lt("salary", 10000); // 新写法 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getAge, 18).lt(User::getSalary, 10000);如果不想每次都new,MP 也提供了静态方法:
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery();还有链式调用风格:
List<User> list = new LambdaQueryWrapper<User>() .eq(User::getName, "张三") .orderByDesc(User::getCreateTime) .list();注意这里用到了list()方法,它其实是 BaseMapper 提供的快捷方法,本质还是selectList。链式风格写测试用例很舒服,生产代码里看你团队习惯,两种都行。我自己的习惯是:条件多于两个就分步构造,低于两个才链式。
2.2 常用 API 速查表
这里我把日常开发最常用的方法整理成一张表,后面实战部分会大量用到。建议收藏,用的时候回来查。
| 方法 | 说明 | 示例 |
|---|---|---|
eq | 等于 | .eq(User::getStatus, 1) |
ne | 不等于 | .ne(User::getStatus, 0) |
gt/ge | 大于 / 大于等于 | .gt(User::getAge, 18) |
lt/le | 小于 / 小于等于 | .le(User::getScore, 100) |
between | 区间,闭区间 | .between(User::getCreateTime, start, end) |
notBetween | 不在区间 | .notBetween(User::getAge, 18, 30) |
like | 模糊查询,左右都匹配 | .like(User::getName, "张") |
likeLeft | 左模糊%xx | .likeLeft(User::getMobile, "138") |
likeRight | 右模糊xx% | .likeRight(User::getName, "张") |
in | 集合匹配 | .in(User::getId, idList) |
notIn | 不在集合 | .notIn(User::getRole, roleList) |
isNull | 字段为空 | .isNull(User::getDeletedAt) |
isNotNull | 字段不为空 | .isNotNull(User::getEmail) |
orderByAsc | 升序排序 | .orderByAsc(User::getSort) |
orderByDesc | 降序排序 | .orderByDesc(User::getCreateTime) |
groupBy | 分组 | .groupBy(User::getDeptId) |
having | 分组后筛选 | .having("count(*) > {0}", 2) |
select | 指定查询列 | .select(User::getId, User::getName) |
last | 追加 SQL 片段 | 分页禁用,容易出问题 |
apply | 拼接原生 SQL | .apply("date_format(create_time,'%Y-%m-%d') = {0}", day) |
nested | 括号包裹嵌套条件 | .nested(w -> w.eq(...).or().eq(...)) |
2.3 多条件组合与优先级“括号”
这是 Wrapper 进阶最重要的一环。先看一个容易出错的写法:
wrapper.eq(User::getStatus, 1) .eq(User::getType, 2) .or() .eq(User::getLevel, 3);这条语句翻译成 SQL 大概是:
WHERE status = 1 AND type = 2 OR level = 3由于 SQL 中AND优先级高于OR,它实际执行的是:
WHERE (status = 1 AND type = 2) OR level = 3如果业务想要的其实是“状态为1,且(类型为2或级别为3)”呢?这时候必须用and方法包裹一组条件:
wrapper.eq(User::getStatus, 1) .and(w -> w.eq(User::getType, 2) .or() .eq(User::getLevel, 3));或者使用nested,它专门用于生成括号:
wrapper.eq(User::getStatus, 1) .nested(w -> w.eq(User::getType, 2) .or() .eq(User::getLevel, 3));and和nested在这里效果差不多。区别在于nested会无条件生成括号,而and内部条件多时会自己生成括号。我习惯用nested来表达“这一块必须包起来”,语义更明确。多条件组合写完后,最好看一眼打印出来的 SQL,确认括号位置,比空想可靠得多。
3. 实战拆解:把复杂条件翻译成 Wrapper
这一节我用几个真实项目里最常见的场景来演示怎么用 Wrapper。从简单到复杂,一步步来。
3.1 场景一:根据前端动态参数拼条件
后端最常见的就是接前端传的查询对象,一堆字段可能为空,需要动态拼接。以前很多人写 if:
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.isNotBlank(req.getOrderNo())) { wrapper.eq(Order::getOrderNo, req.getOrderNo()); } if (req.getStatus() != null) { wrapper.eq(Order::getStatus, req.getStatus()); } if (req.getStartTime() != null) { wrapper.ge(Order::getCreateTime, req.getStartTime()); } if (req.getEndTime() != null) { wrapper.le(Order::getCreateTime, req.getEndTime()); }这种写法本身没问题,就是 if 多了以后代码很长。Wrapper 的第一个参数支持布尔值,为true时才会拼接这个条件,于是可以简化成:
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.isNotBlank(req.getOrderNo()), Order::getOrderNo, req.getOrderNo()) .eq(req.getStatus() != null, Order::getStatus, req.getStatus()) .ge(req.getStartTime() != null, Order::getCreateTime, req.getStartTime()) .le(req.getEndTime() != null, Order::getCreateTime, req.getEndTime());这样写不仅短,而且一眼能看出哪些条件依赖哪个参数。需要注意:第一个布尔参数写错了条件就不会生效,而且不会报错,所以我在代码里会严格要求团队把条件表达式写清楚,不要图省事直接传true。
3.2 场景二:时间范围查询与日期处理
时间查询是后端最容易踩坑的点。前端传一个日期字符串,数据库存的是datetime,如果不统一处理,很容易出现“查不到当天的数据”这种问题。
建议用法是:前端传startTime和endTime,后端统一转成LocalDateTime,然后使用between:
LocalDateTime start = LocalDate.parse(req.getStartDate()).atStartOfDay(); LocalDateTime end = LocalDate.parse(req.getEndDate()).atTime(LocalTime.MAX); wrapper.between(Order::getCreateTime, start, end);这里有两个细节值得注意:
一个是 end 时间必须包含当天的最后一秒,也就是23:59:59,否则数据库里恰好是当天 23:59 的记录会漏掉。我见过好几次把 end 传成00:00:00导致边界数据查不出来的情况。
另一个是不要对字段做函数处理。比如有人这么写:
wrapper.apply("DATE(create_time) >= DATE({0})", start);虽然能跑,但DATE(create_time)让索引失效,数据量一大查询就慢。正确做法是直接对字段做范围匹配,让索引能用上。
3.3 场景三:子查询用inSql还是嵌套查询
需求是“查出最近下单过的用户”。直观写法是:
wrapper.in(User::getId, subQuery);MyBatis-Plus 的in支持另一个 Wrapper 作为子查询:
QueryWrapper<Order> orderQuery = new QueryWrapper<>(); orderQuery.select("user_id").eq("status", 1); wrapper.in(User::getId, orderQuery);也可以直接用inSql,但要注意参数拼接问题:
wrapper.inSql(User::getId, "SELECT user_id FROM orders WHERE status = 1");我强烈建议少用inSql。虽然有 MyBatis 的预编译保护,但inSql里的内容是完全拼接进 SQL 的,一旦从外部传入内容就存在注入风险。能用in加 Wrapper 就用这种组合,毕竟 Wrapper 也是对象,可以嵌套调用。
还有一个隐藏的性能坑:子查询返回的数据量太大,SQL 可能会变得很长。如果子查询结果可能超过几百上千条,最好改写为 JOIN 或者 EXISTS。Wrapper 里也支持 exists:
wrapper.exists("SELECT 1 FROM orders WHERE orders.user_id = users.id");这种写法比较偏原生 SQL,适合数据库性能调优时使用,但可读性会下降,建议加注释。
3.4 场景四:UpdateWrapper 如何做局部更新
很多人用 MP 更新数据时,习惯先查出来再 set 再 updateById。简单场景问题不大,但如果你只想更新某几个字段,尤其是想把一个字段置为null,就得用 UpdateWrapper。
需求:把指定用户的手机号清空,同时更新备注。如果直接updateById传实体,MP 默认忽略null字段,手机号根本清不掉。这时用 LambdaUpdateWrapper 就很合适:
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(User::getId, 1001) .set(User::getMobile, null) .set(User::getRemark, "已清空手机号"); userMapper.update(null, updateWrapper);注意update(null, updateWrapper)的第一个参数传null,表示不根据实体更新,完全以 UpdateWrapper 为准。如果你同时传了实体对象和 UpdateWrapper,实体里的非空字段会参与更新,此时要格外小心。
3.5 场景五:批量插入和批量更新
很多同学一上来就用循环调save,数据量一大会非常慢。MyBatis-Plus 提供了批量插入:
List<User> userList = buildUserList(); userMapper.insert(userList); // 需要继承 ServiceImpl 或使用 IService如果是 Service 层,用saveBatch(list)即可。但这里有个多年的坑:saveBatch默认分批大小为 1000,单批提交的 SQL 长度也有限制,所以一次性插入几万条以上时,建议自己切分再分批执行。
批量更新就不要用循环 updateById 了,效率低。如果每个实体更新的字段不一样,只能循环;如果是一致性字段更新,直接用 UpdateWrapper 批量处理:
LambdaUpdateWrapper<Order> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.in(Order::getStatus, Arrays.asList(1, 2)) .set(Order::getStatus, 3); orderMapper.update(null, updateWrapper);这种一次性把所有状态为 1、2 的订单改成 3,不仅代码简洁,SQL 也只需要一条UPDATE,性能远好于循环。
3.6 场景六:分组统计与 having
统计每个分类的商品数量,并且只要数量大于等于 10 的分类。传统的 XML 写法要写<foreach>或者动态 if。Wrapper 里可以用:
LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.select(Goods::getCategory, Goods::getCategory, Goods::getCategory) // 配合 count .groupBy(Goods::getCategory) .having("COUNT(*) >= {0}", 10);这里有个小知识点:select不能直接写聚合函数,通常需要配合apply或者用QueryWrapper的字符串形式。比如:
QueryWrapper<Goods> wrapper = new QueryWrapper<>(); wrapper.select("category, COUNT(*) as cnt") .groupBy("category") .having("COUNT(*) >= {0}", 10);having里使用{0}占位符是很重要的习惯,避免直接拼接外部值,既防注入,也避免引号问题。这是我在生产环境总结出来的硬经验。
4. 绕开这些坑
Wrapper 用熟了以后,平常的增删改查写起来非常顺。但有些坑是线上才会暴露的,我把这几年踩过的、帮别人排过的常见问题都列在这里,每条都值得你认真看一遍。
4.1 坑一:Bean 属性名和数据库字段名的映射问题
MyBatis-Plus 默认开启驼峰映射,所以goodsNo会自动映射到goods_no。用 LambdaQueryWrapper 时,Goods::getGoodsNo会正确翻译成goods_no,这一点没什么问题。但如果你用 QueryWrapper 手写字符串,写"goodsNo"就会直接报 SQL 语法错误或者“字段不存在”。原因很简单:字符串不会经过驼峰转换,数据库里根本没有goodsNo这个列。
解决方法有两个:要么坚持用 Lambda 写法;要么写字符串时老老实实写下划线字段名。千万不要以为 MP 会自动帮你把字符串也转成下划线,它没你想的那么智能。
4.2 坑二:or、and 混用导致条件范围失控
这个坑我在 2.3 节提过,但线上还有更隐蔽的版本:多个or与and混在一起,生成的 SQL 完全不是想要的样子。比如:
wrapper.eq(User::getStatus, 1) .or() .eq(User::getMobile, "138") .like(User::getName, "张")你以为意思是“状态为1,或者手机号是138且名字含张”,但实际 SQL 是:
WHERE status = 1 OR mobile = '138' AND name LIKE '%张%'因为 AND 优先级更高,结果完全变了。遇到这类情况,不要偷懒,必须用nested或and明确包裹。我的习惯是:只要出现两个以上or,立刻用括号包起来,避免长期维护后自己也看不懂。
4.3 坑三:模糊查询里的 % 和 _ 被当作通配符
用户输入一个%或者_,MySQL 会把它当成通配符处理,导致“明明只查一个词却匹配出一堆数据”。比如用户搜索100%,SQL 里的LIKE '%100%%'会匹配任意包含100后接任意字符的字符串。MyBatis-Plus 的like方法默认会帮你处理大部分特殊字符转义,但如果你用了apply手写 LIKE:
wrapper.apply("name LIKE CONCAT('%', {0}, '%')", keyword);这里面的%和_就完全没有转义,风险极大。解决方案是手动 escape:
String escaped = keyword.replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_"); wrapper.apply("name LIKE CONCAT('%', {0}, '%') ESCAPE '\\' ", escaped);提醒:哪怕用了 MP 的
like,我也建议在后端对用户输入做一次白名单校验或长度限制,比如搜索关键词最长 50 个字符。不要把所有安全都交给 ORM。
4.4 坑四:update 时想置 null,结果字段纹丝不动
这是新手问得最多的问题。MyBatis-Plus 在 update 时有默认策略:实体对象的null字段不更新。这个设计本意是好的,避免了误操作把字段清空。但你真想清空一个字段,直接用实体就清不掉。正确的办法是用LambdaUpdateWrapper.set(字段, null),像 3.4 节那样。你还可以通过实体字段上的@TableField(updateStrategy = FieldStrategy.IGNORED)来放开策略,但我个人不推荐,因为那等于把这个表的 NULL 更新限制全放开,太危险,容易误操作。
4.5 坑五:逻辑删除字段带来的隐式条件
很多项目用逻辑删除,在实体上加@TableLogic注解。这时候 MP 会在所有查询上自动追加deleted = 0条件。这本身是保护机制,但有天你发现怎么都查不到“被删除的数据”,其实是这条隐式条件导致的。如果你在某些场景下真的要查包括已删除的数据,有两个选择:一是写自定义 SQL,在 XML 里用${ew.customSqlSegment}自己控制;二是临时关闭逻辑删除的自动拼接,这需要配置拦截器,比较麻烦,一般不建议。
更隐蔽的坑是:你手动在 Wrapper 里追加deleted = 0,虽然逻辑上没错,但会和 MP 自动追加的deleted = 0重复。即使不影响结果,也会让排查问题的人一头雾水。所以逻辑删除字段千万别在 Wrapper 里再写了。
4.6 坑六:last() 拼接分页,SQL 直接崩掉
last()可以往 SQL 末尾追加片段,灵活性很高,但也非常危险。常见的错误是在 Wrapper 里写:
wrapper.last("LIMIT 10");如果项目里同时配了分页插件,分页插件也会尝试追加 LIMIT,最终 SQL 变成:
... LIMIT 10 LIMIT 10数据库直接报语法错误。我见过不止一次线上因为这个原因出错。凡是要分页,统一用 MyBatis-Plus 的Page对象配合分页插件,不要手动控制 LIMIT。last()只适合在某些特殊 SQL 语法确实无法用现有 API 表达时才使用,而且用之前得想清楚后果。
4.7 坑七:select 指定字段后,实体里其他字段全是为 null
当你用wrapper.select(Goods::getId, Goods::getName)查询后,返回的 Goods 对象只有 id 和 name 有值,其他属性全是 null。这时候如果你把对象直接塞给前端,前端拿price就发现是空。很多同学在这个问题上困惑很久,其实select就是只查指定列,其余字段当然是 null。处理方式有两种:要么不指定 select,全字段查出来;要么在 Service 里手动组装 DTO。我还有个小建议:批量查询时,如果只需要少量字段,用 select 确实能减少网络传输和数据库 IO,但如果数据量不大(几百条以内),其实没必要省这点开销,全字段查询更稳妥。
5. 性能排查与 SQL 调试
很多人把 Wrapper 当成黑盒,写完了就扔给数据库跑。反正代码是 Java,语法不报错,等到线上慢查询才发现问题。这里分享几个实用的排查手段。
5.1 先看 SQL 真实长什么样
我调试 Wrapper 的第一件事,永远是打开 SQL 日志。在application.yml里配置:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样每次执行都会在控制台打印:
==> Preparing: SELECT id,name,status,create_time FROM users WHERE status = ? AND name LIKE ? ORDER BY create_time DESC ==> Parameters: 1(String), %张%(String)看日志能发现大量问题:条件顺序、参数个数、是否多了条件、括号位置、排序是否生效,一目了然。调完记得把 log-impl 关掉或者改成 Slf4j,否则生产环境日志会爆炸。
5.2 用 EXPLAIN 验证索引
Wrapper 生成的 SQL 照样可能走不上索引。比如你对name字段加了索引,但查询是like '%张',那索引就白发。或者你在 Wrapper 里用apply("DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-01-01'"),create_time索引直接失效。调优手段是把 SQL 日志里拿到的语句,手动拿到数据库客户端执行EXPLAIN,看type是不是ref或range,别老是ALL全表扫描。发现全表扫描时,优先检查条件字段是否有索引,然后是函数包裹问题。
提示:Wrapper 不是数据库调优工具,它只是帮你写 SQL。SQL 本身的性能,仍然需要你对索引结构有清晰认识。
5.3 大数据量下的分页和流式查询
用户列表动辄几十万条,如果一次性全部查出来,内存直接扛不住。现在基本都用分页:
Page<User> page = new Page<>(1, 10); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1).orderByDesc(User::getId); Page<User> result = userMapper.selectPage(page, wrapper); System.out.println(result.getRecords());这里要留意,分页插件在selectPage时会自动执行一条 COUNT 查询,数据量大时 COUNT 也会慢。有时候 COUNT 不是必要的(比如下拉加载更多场景),可以用page.setSearchCount(false)关掉它,能省不少时间。
还有一种场景:你需要一次性处理大量数据但不想一次性加载到内存,可以用流式查询:
try (Cursor<User> cursor = userMapper.selectCursor(wrapper)) { cursor.forEach(user -> process(user)); }Cursor 本质是游标,一行一行读,不会把所有数据塞进内存。但它对数据库连接占用时间较长,处理完要尽快关闭,而且不要在事务内跑太久,否则连接和锁都可能出问题。这也是不少新手容易忽视的细节。
5.4 条件参数里塞 List 时的注意事项
in方法的参数是集合时,如果集合为空,MP 会怎么处理?在很多老版本里,空集合生成的 SQL 是IN (),数据库直接报语法错误。后来 MP 做了优化,空集合会生成WHERE 1 = 2这种恒假条件,但如果你自己写in之前没做判空,依赖这种优化行为是不稳妥的。我的习惯是:入参集合为空时,直接返回空列表,不再构造 Wrapper。这样可以避免无意义的 SQL 执行,也减少理解成本。
另外,IN列表元素过多时,数据库会有参数上限,比如 Oracle 有 1000 的限制,MySQL 虽然宽松但也不是无限。一次in上万条数据,SQL 文本本身就可能把数据库搞崩。这种大批量查询请设计成批量切分或者 JOIN 关联。
我最后再讲几点个人经验
做项目这几年,我把所有查询条件都收敛到 Service 层:Controller 只接收一个查询对象,Service 里通过LambdaQueryWrapper构造条件,Mapper 层基本只放方法签名。对于简单场景根本不用碰 XML,只有遇到多表 JOIN、复杂 GROUP BY、报表统计才会在 XML 里写 SQL。这样分工下来,团队维护成本明显降低,新人上手也快,因为单表查询思路是一致的,不用到处找动态 SQL 散落在哪里。
另一个小技巧是:Wrapper 的eq、like这些方法第一个参数可以传布尔条件,这不仅是省代码,更重要的是把“条件是否生效”这个决策和条件本身写在同一行。代码评审时,大家一眼就能看到哪个参数控制哪个分支,比在下面写一堆 if 清晰得多。
最后想说,Wrapper 并不是银弹。多表关联依然要老老实实写 JOIN;复杂子查询、递归查询,Wrapper 也表达不了。但它确实把我日常 80% 的简单查询从“写 SQL 字符串”里解放出来,让我有更多时间去琢磨真正的性能和架构问题。希望这篇从入门到避坑的总结,能帮你少走一些弯路。