
如果你用 Java 写后端大概率已经被 MyBatis-Plus 的 Wrapper 包围了。不管是简单列表查询、后台管理筛选还是批量更新几乎每个 Controller 里都有 LambdaQueryWrapper 或 QueryWrapper 的身影。我之前带过几个新人看到eq(User::getStatus, 1)这种写法都会问一句这不是 SQL 吗怎么塞在 Java 里了这其实是 MyBatis-Plus 给出的“动态条件构造器答案”代码里不用再拼一堆 if SQL 字符串Wrapper 会帮你把 Java 条件翻译成 where 子句。这篇文章就专聊 Wrapper 的 Lambda 写法、实战套路以及我踩过的各种坑适合刚接触 MyBatis-Plus 的新手也适合正在被复杂条件拼接折磨的老手。1. Wrapper 是如何从 MyBatis 动态 SQL 里杀出来的1.1 为什么 Service 层拼接条件这么难受早期用原生 MyBatis写一个列表查询通常要维护一份 XMLselect idselectUserList resultTypeUser SELECT * FROM user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if /where /select每个查询都要开 XML、写 if、写 concat字段一变Mapper 接口、XML、参数对象都要跟着改。查询条件一多XML 里 if 嵌套 if时间长了连自己都懒得翻。Wrapper 把条件拿到 Java 代码里来拼例如LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1);在 Service 层就能直接看出查询意图不用来回切文件。而且 Wrapper 支持 condition 参数可以实现“该条件为空就不拼接”比 XML 里写一堆if要轻量得多。这里也要说清楚我不是否定 XML。复杂统计、多表 join、特殊 SQL 场景我反而会主动回到 XML。Wrapper 解决的是 80% 的日常动态查询不是所有问题。1.2 QueryWrapper 与 LambdaQueryWrapper 怎么选很多初学者会纠结到底用哪个我把对比直接列出来对比项QueryWrapperLambdaQueryWrapper字段写法字符串 username方法引用 User::getUsername编译期检查无写错到运行期才报有编译期就能发现字段改名全局搜索字符串容易漏IDE 重构自动改拼接聚合列方便select(count(id) as cnt)相对麻烦适合场景统计、报表、临时 SQL业务 CRUD、动态查询说实话没有哪个绝对好。日常 CRUD 我优先 LambdaQueryWrapper因为类型安全。但遇到分组统计、要写复杂字段别名时QueryWrapper 的字符串列名反而更直接。两者是同一套条件体系的两种表达没必要硬分高下实际项目里共存很常见。1.3 Lambda 解析原理和它天然的类型安全感为什么能用User::getUsername代替username原理是 MP 拿到这个 Lambda 后会解析成 SerializedLambda反射出实现方法名getUsername再转成属性名username最后配合驼峰转下划线得到数据库列名。中间这些过程 MP 会做缓存性能开销基本可以忽略。既然能拿到方法名就天然避免了字符串写错。最常见的体验是实体字段重构改名为userName用 QueryWrapper 的地方可能漏改而 Lambda 写法会在编译期直接报错。用惯之后确实回不去了。不过这个解析机制也不是绝对稳热部署、内部类、泛型继承都可能触发异常我放在第 4 部分细讲。2. LambdaQueryWrapper 高频 API 的实用写法2.1 等值、范围、模糊动态条件先判断再拼接基础等值查询LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .eq(User::getType, admin);但实际接口入参大概率是可能为 null 的直接 eq 会把 null 也拼进条件影响结果。所以动态查询我习惯用带 boolean 的重载String username request.getParameter(username); Integer status request.getParameter(status) null ? null : Integer.valueOf(...); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(username), User::getUsername, username) .eq(status ! null, User::getStatus, status);第一个参数是布尔值只有为 true 时才会拼接这个条件。这样即便前端漏传参数SQL 也不会被污染。类似方法ge、le、gt、lt、like、in、ne都支持这种带 condition 的写法建议养成习惯。范围查询wrapper.ge(startTime ! null, User::getCreateTime, startTime) .lt(endTime ! null, User::getCreateTime, endTime);模糊查询wrapper.like(StringUtils.hasText(keyword), User::getNickname, keyword); // LIKE %keyword% wrapper.likeRight(StringUtils.hasText(keyword), User::getPhone, keyword); // LIKE keyword%需要提醒的是like默认是全模糊。如果用户输入%、_在 SQL 里会被当成通配符出现“明明按关键词搜却把全表捞出来”的情况。业务上能接受的话直接限制输入长度和特殊字符不能接受就做转义处理。空值判断用wrapper.isNull(User::getDeletedAt); wrapper.isNotNull(User::getUpdatedAt);2.2 排序、限定字段少查一列是一列排序wrapper.orderByDesc(User::getCreateTime) .orderByAsc(User::getId);对应 SQL 是ORDER BY create_time DESC, id ASC。如果希望 id 优先排序就要把 id 写在前面。只查询需要的字段wrapper.select(User::getId, User::getUsername, User::getStatus);默认selectList是 SELECT 全部字段。表里如果有 text、mediumtext 这种大字段列表页会白白把所有大字段读出来。只 select 业务需要的列查询速度往往立竿见影。还有一个细节列名是数据库关键字时比如字段叫order、group、desc建议在实体字段上用TableField(order)显式声明否则 MP 生成的 SQL 在 MySQL 里可能报语法错误。这个坑我踩过一次。2.3 and/or 嵌套别把括号拼错了地方最常见的错误是把 or 直接写在条件外层。比如“状态为 1且昵称包含张 或 手机号以 138 开头”有人会写wrapper.eq(User::getStatus, 1) .like(User::getNickname, 张) .or() .likeRight(User::getPhone, 138);这个 SQL 语法没错语义却是status 1 AND nickname LIKE %张% OR phone LIKE 138%因为 AND 优先级高于 OR整个条件被拆成了两个独立条件。正确写法是用and方法包一层wrapper.eq(User::getStatus, 1) .and(w - w.like(User::getNickname, 张) .or() .likeRight(User::getPhone, 138));生成的 SQLWHERE status 1 AND (nickname LIKE %张% OR phone LIKE 138%)反过来如果要在 or 里再组合 and就调or(w - w.eq(...).eq(...))。核心原则是需要括号的地方一律用 and/or 加 Lambda 参数包起来不要图省事在外面直接or()。带 boolean 的and也一样存在wrapper.and(StringUtils.hasText(keyword), w - ...)条件为 false 时整个括号不拼接。3. 四个可复制实战场景3.1 用户管理列表keyword、状态、时间范围的组合查询后台用户列表是最典型的动态查询场景。需求一般是根据昵称/手机号/用户名模糊搜索筛选用户状态指定创建时间范围分页返回。// 入参 String keyword req.getKeyword(); Integer status req.getStatus(); LocalDate startDate req.getStartDate(); LocalDate endDate req.getEndDate(); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // 关键字同时匹配三个字段注意括号 wrapper.and(StringUtils.hasText(keyword), w - w.like(User::getUsername, keyword) .or().like(User::getNickname, keyword) .or().like(User::getPhone, keyword)); // 状态 wrapper.eq(status ! null, User::getStatus, status); // 创建时间范围用 ge lt 而不是 between避免 endDate 当天用户被漏掉 if (startDate ! null) { wrapper.ge(User::getCreateTime, startDate.atStartOfDay()); } if (endDate ! null) { wrapper.lt(User::getCreateTime, endDate.plusDays(1).atStartOfDay()); } wrapper.orderByDesc(User::getId); PageUser page new Page(req.getPageNum(), req.getPageSize()); IPageUser result userMapper.selectPage(page, wrapper);这段代码我在中后台项目里直接用过很多次。注意 where 条件与 Page 搭配时MP 分页插件会自动生成 count 查询一般不需要手动 count。日期范围用ge/lt是为了查“截至 endDate 当天 23:59:59.999”的全部数据between的闭区间在这种场景容易漏数据。3.2 订单统计分组聚合还是 QueryWrapper 更顺手需求按日期统计最近 7 天的订单数和订单金额。如果用 LambdaQueryWrapper聚合列没有原生方法可用硬凑很难受。这种场景我通常直接切 QueryWrapperQueryWrapperOrder wrapper new QueryWrapper(); wrapper.select(order_date, count(id) as cnt, sum(amount) as total_amount) .ge(order_date, LocalDate.now().minusDays(6)) .lt(order_date, LocalDate.now().plusDays(1)) .groupBy(order_date) .orderByAsc(order_date); ListMapString, Object rows orderMapper.selectMaps(wrapper);返回的结构类似[{order_date2025-06-01, cnt12, total_amount1234.00}, ...]。selectMaps会把每行变成一个 Map很适合报表导出或前端图表展示。为什么这里不用 Lambda因为聚合函数没法用方法引用表达字符串列名反而干净。Wrapper 不是只有 Lambda 一种实际项目里 LambdaQueryWrapper 和 QueryWrapper 共存是常态。更复杂的多表 join 聚合我建议直接写在 XML 里不要硬用 Wrapper 凑否则 SQL 的可读性会牺牲掉。3.3 批量状态更新更新前先上个保险批量更新状态用的不是查询 wrapper而是 LambdaUpdateWrapperLambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(req.getDeptId() ! null, User::getDeptId, req.getDeptId()) .set(User::getStatus, 0) .set(User::getUpdateTime, LocalDateTime.now()); userMapper.update(null, updateWrapper);注意update第一个参数传 null这样只会按 updateWrapper 的 set 字段更新。如果第一个参数传入实体实体里非 null 字段也会参与 SET容易误更新。更重要的是更新之前必须确认 where 条件存在。假如req.getDeptId()为空eq 的 condition 不满足updateWrapper 只有 SET 没有 WHEREuserMapper.update(null, updateWrapper)会触发全表更新。这不是危言耸听我在真实项目里见过不止一次。最简单的保护if (updateWrapper.getSqlSegment().isEmpty()) { throw new IllegalStateException(批量更新必须携带 where 条件); }更稳妥的是在 MyBatis-Plus 插件层加一道拦截Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }BlockAttackInnerInterceptor会直接拦截没有 where 条件的 update/delete SQL相当于给全表写操作上了保险。加了这个插件后空条件全表更新会直接抛异常而不是默默执行。3.4 唯一性校验ne 排除自己和逻辑删除的配合新增用户时校验用户名是否已存在修改用户时校验用户名是否被别的人占用这是很常见的需求LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getUsername, username) .ne(userId ! null, User::getId, userId); Long count userMapper.selectCount(wrapper); if (count ! null count 0) { // 用户名已存在 }ne表示不等于加上它就能排除当前这条记录。如果项目配置了逻辑删除MP 会在 SQL 自动追加deleted 0不需要手动写。此时逻辑删除过的历史用户名不会占用新用户校验这个行为通常符合直觉。注意selectCount方法返回值在不同版本有差异老版本是 Integer新版是 Long使用时以 IDE 提示为准。判断存在性时不要查 list 再判断 sizeselectCount更省资源。4. Wrapper 用久了才会踩到的深坑4.1 条件全空引发的全表更新事故这个坑我在 3.3 提了一嘴这里再展开说。最容易发生的场景不是“没写条件”而是“写了但条件判断全部为 false”。LambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(req.getDeptId() ! null, User::getDeptId, req.getDeptId()) .set(User::getStatus, 0); userMapper.update(null, updateWrapper);前端少传一个 deptIdeq 就不拼了updateWrapper 只剩下 SET没有 WHERE全表 status 被清零。这个事故如果发生在线上基本就是“删库跑路”级别的压力。所以我在团队里定了一条规矩凡是使用 update 或 delete 的 Wrapper调用前必须检查getSqlSegment()是否为空同时全局启用BlockAttackInnerInterceptor。两者叠加才能把风险压到最低。不要迷信“这是内部系统不会传错”。内部系统跟外部系统一样会传错只是没人会在意而已。4.2 lambda 找不到缓存解析失败的常见原因LambdaQueryWrapper 用多了偶尔会遇到can not find lambda cache for this property之类的报错。原因是 MP 解析 lambda 的本质是从 SerializedLambda 里反射出方法名再换算字段名这一步依赖 Class 元数据。我遇到的几种触发情况项目开了热部署应用运行中 class 被替换旧的 lambda 缓存与实际类对不上实体类写在私有内部类中或字段定义在泛型父类上同一个 JVM 里多个 ClassLoader 加载了同一份实体类。遇到这种问题我的处理顺序是先重启应用排除热部署缓存问题如果还报错再检查实体是不是写在非静态内部类里能改成顶层类就改实在改不了这个查询就改用 QueryWrapper 的字符串字段名。没必要跟框架硬磕。4.3 last() 是把双刃剑分页插件冲突和注入风险wrapper.last(limit 10)看起来简单实际是个坑。第一个问题如果你加了分页插件又调用 selectPageMP 会先帮你生成 limit 分页这时last(limit 10)又拼一个 limit 上去SQL 变成LIMIT ?,? LIMIT 10直接报错。我见过同事在同一个查询里既用 Page 又用 last(limit 1)排查了半天。第二个问题last是把字符串原样追加到 SQL 末尾。如果加的是用户输入比如wrapper.last(order by orderBy)基本等于把 SQL 注入的钥匙交给别人。我的建议是分页一律用 Page不要用last(limit)必须用 last 时只允许写固定字符串如last(FOR UPDATE)任何变量都别往 last 里塞。可能有人问那查一条数据用selectList(wrapper.last(limit 1))行不行可以但非 MySQL 数据库的 limit 语法不一样跨库就挂了。简单判断是否存在直接用 selectCount 并不慢。4.4 in 集合过大与 between 边界wrapper.in(User::getId, idList)是很常见的写法但 idList 上千条时SQL 会非常长MySQL 可能因为解析成本高而变慢在 Oracle 里超过 1000 条直接报错。如果你没法保证入参数量建议分批处理ListListLong batchIds Lists.partition(idList, 500); for (ListLong ids : batchIds) { // 分段处理 }注意分批后如果需要整体结果不要循环去查库再手动合否则会有 N1 问题可以分批只查 id最后整体查一次或者考虑用临时表去 join这个按业务取舍。between的边界问题也很典型。datetime 字段用between(start, end)是闭区间如果 end 传的是2025-06-01查询只会包含2025-06-01 00:00:00这一瞬间的数据当天其余数据全部漏掉。更稳的写法是用 ge ltwrapper.ge(User::getCreateTime, startDate.atStartOfDay()) .lt(User::getCreateTime, endDate.plusDays(1).atStartOfDay());这个写法我基本已经刻进肌肉记忆了。4.5 逻辑删除字段带来的隐形坑实体开启逻辑删除后MP 会在普通查询自动追加deleted 0这本身是好事。但有些人查“已删除的数据”时会手动加条件wrapper.eq(User::getDeleted, 1);结果生成 SQL 变成WHERE deleted 1 AND deleted 0什么都查不到。逻辑删除的数据默认是查不出来的真想查只能写自定义 SQL 或通过配置绕过拦截不要天真地在 Wrapper 里手动拼 deleted。另一个坑是唯一索引。比如用户表有username唯一索引逻辑删除后老用户记录统一 deleted1但还是占着唯一索引。用户重新注册同名账号时insert 会直接撞唯一键报错。常规解法是把 deleted 一起放进唯一索引或者改成基于deleted0的过滤索引这个要看数据库能力。5. 让 Wrapper 查询跑得快性能相关的实战经验5.1 先拿到真实 SQL再用 EXPLAIN 直观看索引Wrapper 写起来很爽但真正执行的 SQL 是 MP 生成的很多人不知道它长什么样。排查性能问题时首先要让 SQL 打印出来。配置里加上mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl打开日志后把生成的 SQL 拷到数据库工具里执行 EXPLAINEXPLAIN SELECT username, status FROM user WHERE status 1 ORDER BY create_time DESC;看执行计划里的type列。如果出现ALL说明没走索引数据量一大必然慢如果 type 是ref或range且 key 列有实际索引名基本问题不大。加了逻辑删除后SQL 会自动多一个deleted 0条件如果这个字段没索引也可能导致全表扫描所以逻辑删除字段建议加个普通索引。5.2 只 select 需要的字段别把大字段拖下水默认selectList会查询实体全部字段。实体里如果有备注、正文、JSON 这类大字段列表接口会被拖慢很多。我之前优化过一张 30 万行的业务表原列表查询因为 select 了全部字段平均 800ms改成只 select id、名称、状态之后掉到了 80ms 左右。代码也简单wrapper.select(User::getId, User::getUsername, User::getStatus);如果你担心返回实体时其他字段为 null可以定义专门的 DTO 或查询结果类或者用 Map 接收。不要图省事一把梭 select 全部。5.3 不要在索引字段上套函数有人为了按天统计直接写wrapper.apply(DATE_FORMAT(create_time, %Y-%m-%d) 2025-06-01);create_time 上即使有索引也废了因为 B 树是按原始值排序的套了函数之后无法用于范围匹配。正确姿势是范围查询wrapper.ge(User::getCreateTime, LocalDate.parse(2025-06-01).atStartOfDay()) .lt(User::getCreateTime, LocalDate.parse(2025-06-02).atStartOfDay());这种写法既能保证按天查询的结果完整又能让索引正常工作。数据库优化的很多问题最后都落在“不要让索引列参与表达式运算”这一条上。5.4 批量操作合并减少数据库往返Service 继承了 IService 时批量插入直接调saveBatch(list)不要 for 循环 insert。底层虽然是 batch但 MySQL 的 JDBC 驱动不一定默认开启批处理优化连接串上可以带上rewriteBatchedStatementstrue对大批量 insert 有明显提升。批量更新同理能用一条 updateWrapper 更新一批数据就别在 for 里一个 id 一个 id 地 updateById。每一条都是一次网络往返数据多起来性能差距是数量级的。如果非要逐条处理业务逻辑至少考虑手动提交事务或使用批量 executor不要放任默认逐条提交。6. 写在最后我对 Wrapper 的几条使用习惯文章写到这里日常最常用的东西基本都过了一遍。最后分享几句我个人的使用习惯。第一能 Lambda 就 Lambda但要清楚什么时候切 QueryWrapper。业务 CRUD、动态条件、简单更新LambdaQueryWrapper 是首选一旦涉及聚合、分组、别名字段QueryWrapper 的字符串列名反而更直接多表 join 和复杂报表直接回 XML别硬凑。第二更新和删除必须做双保险。代码里检查getSqlSegment()插件层挂BlockAttackInnerInterceptor两者都上才能说我对全表更新这种事放心了。第三Wrapper 写完之后养成看 SQL 的习惯。打开 SQL 日志跑一遍接口确认 where 条件、括号、limit 都没问题再提交代码。很多低级错误看一眼日志就全明白了。第四别把 Wrapper 玩出花来。一个查询里又是 apply、又是 last、又是嵌套 and可读性会急剧下降。当条件复杂到连你自己都数不清括号时停下来把这段逻辑拆开或换 XML 写 SQL长期维护成本更小。这些年我从 XML 动态 SQL 写到 Wrapper再到现在分场景选型最大的体会就是工具的好坏不在工具本身而在你对它的边界是否清楚。Lambda 写法帮我挡住了一堆字段名写错的问题却也差点让我在分组统计里绕远路。如果你也被某个 Wrapper 的骚操作坑过应该能懂为什么我要单独把这些事拿出来写。