多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MyBatis-Plus Wrapper 详解:从入门到避坑,告别动态SQL拼接烦恼

MyBatis-Plus Wrapper 详解:从入门到避坑,告别动态SQL拼接烦恼 做了快十年的 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 就简单了LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); wrapper.eq(Goods::getStatus, 1) .like(Goods::getName, name); ListGoods list goodsMapper.selectList(wrapper);你看条件是一个 Java 方法调用值被当作参数传入MyBatis 会安全地处理既不需要字符串拼接也不需要关心要不要加引号。这就是 Wrapper 解决的核心痛点安全、可读、可组合。1.2 QueryWrapper 和 LambdaQueryWrapper 怎么选这两个类功能几乎一样最大的区别是字段名的表达方式。QueryWrapper 使用字符串指定字段名QueryWrapperGoods wrapper new QueryWrapper(); wrapper.eq(status, 1).like(name, 手机);LambdaQueryWrapper 使用方法引用LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); wrapper.eq(Goods::getStatus, 1).like(Goods::getName, 手机);我强烈建议新项目一律用 LambdaQueryWrapper。原因很简单编译期就能检查字段是否存在。你如果写stauts这种字符串编译器不会报错等 SQL 跑到数据库才抛异常但你写Goods::getStautsIDE 直接红色波浪线提醒你这比什么都管用。另外当你重构实体类字段名时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迁移其实特别简单。构造方式改成// 旧写法 QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(age, 18).lt(salary, 10000); // 新写法 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getAge, 18).lt(User::getSalary, 10000);如果不想每次都newMP 也提供了静态方法LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery();还有链式调用风格ListUser list new LambdaQueryWrapperUser() .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 场景一根据前端动态参数拼条件后端最常见的就是接前端传的查询对象一堆字段可能为空需要动态拼接。以前很多人写 ifLambdaQueryWrapperOrder 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时才会拼接这个条件于是可以简化成LambdaQueryWrapperOrder 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然后使用betweenLocalDateTime 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 作为子查询QueryWrapperOrder 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 里也支持 existswrapper.exists(SELECT 1 FROM orders WHERE orders.user_id users.id);这种写法比较偏原生 SQL适合数据库性能调优时使用但可读性会下降建议加注释。3.4 场景四UpdateWrapper 如何做局部更新很多人用 MP 更新数据时习惯先查出来再 set 再 updateById。简单场景问题不大但如果你只想更新某几个字段尤其是想把一个字段置为null就得用 UpdateWrapper。需求把指定用户的手机号清空同时更新备注。如果直接updateById传实体MP 默认忽略null字段手机号根本清不掉。这时用 LambdaUpdateWrapper 就很合适LambdaUpdateWrapperUser 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 提供了批量插入ListUser userList buildUserList(); userMapper.insert(userList); // 需要继承 ServiceImpl 或使用 IService如果是 Service 层用saveBatch(list)即可。但这里有个多年的坑saveBatch默认分批大小为 1000单批提交的 SQL 长度也有限制所以一次性插入几万条以上时建议自己切分再分批执行。批量更新就不要用循环 updateById 了效率低。如果每个实体更新的字段不一样只能循环如果是一致性字段更新直接用 UpdateWrapper 批量处理LambdaUpdateWrapperOrder 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 里可以用LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); wrapper.select(Goods::getCategory, Goods::getCategory, Goods::getCategory) // 配合 count .groupBy(Goods::getCategory) .having(COUNT(*) {0}, 10);这里有个小知识点select不能直接写聚合函数通常需要配合apply或者用QueryWrapper的字符串形式。比如QueryWrapperGoods 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手写 LIKEwrapper.apply(name LIKE CONCAT(%, {0}, %), keyword);这里面的%和_就完全没有转义风险极大。解决方案是手动 escapeString 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 大数据量下的分页和流式查询用户列表动辄几十万条如果一次性全部查出来内存直接扛不住。现在基本都用分页PageUser page new Page(1, 10); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1).orderByDesc(User::getId); PageUser result userMapper.selectPage(page, wrapper); System.out.println(result.getRecords());这里要留意分页插件在selectPage时会自动执行一条 COUNT 查询数据量大时 COUNT 也会慢。有时候 COUNT 不是必要的比如下拉加载更多场景可以用page.setSearchCount(false)关掉它能省不少时间。还有一种场景你需要一次性处理大量数据但不想一次性加载到内存可以用流式查询try (CursorUser 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 字符串”里解放出来让我有更多时间去琢磨真正的性能和架构问题。希望这篇从入门到避坑的总结能帮你少走一些弯路。
返回列表