
做后端开发的同学尤其是用过 MyBatis Plus 的应该都有这种经历原本写一条查询要手工拼 SQL 或者写 XML字段名一个字母不对就要到运行时才炸出来。后来换了 MyBatis Plus有了 lambdaQuery()这个问题基本就消失了。lambdaQuery() 是 MyBatis Plus 提供的一种基于 Lambda 表达式的链式查询构造器它不用硬编码数据库字段名字符串而是直接引用实体类的属性方法编译期就能拦住大部分低级错误。这个工具能做什么说白了就是把原本要写在 XML 里的 where 条件、排序、分组、分页全部改成 Java 代码里一行行链式调用代码既短又直观。对于 CRUD 为主的业务系统lambdaQuery() 基本能满足 90% 以上的查询需求。适合刚接触 MP 的新手快速上手也适合写过一些但还没系统梳理过 lambdaQuery 用法的同学查漏补缺。这篇文章我就以实际项目的角度把 lambdaQuery() 常用基础用法拆开讲一遍顺便把最近网上问得比较多的几个问题也一并解决批量插入到底怎么用才算对、insert 数据没报错却没写成功是什么原因、逻辑删除的数据怎么再查出来、以及怎样临时禁用逻辑删除条件。内容以 3.x 版本为准覆盖日常开发最常用的场景。1. 从 QueryWrapper 到 lambdaQuery()为什么推荐链式查询1.1 传统写法和 lambda 写法的核心区别先看一段对比。传统写法用 QueryWrapper条件字段只能写字符串QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(name, 张三); wrapper.ge(age, 18); ListUser list userMapper.selectList(wrapper);这段代码功能上没有错但问题很明显“name”和“age”是魔法字符串。一旦实体类属性改名或者数据库字段调整Java 编译器不会帮你发现只有跑到这一行才会报错甚至可能不报错而是查出错误结果。而 lambdaQuery() 是这么写的ListUser list userService.lambdaQuery() .eq(User::getName, 张三) .ge(User::getAge, 18) .list();User::getName 直接引用实体类的方法引用字段名在编译期就能校验。如果 User 里没有 getName 这个方法编译直接失败。这不是什么高深技术就是 Java 8 方法引用加函数式接口的常规运用但开发体验提升非常明显。前一种写法写错字段名后等代码上线跑挂了才发现后一种写错直接编译不通过哪个更省事不用多说。1.2 lambdaQuery() 的入口BaseMapper 与 IService 的区别使用 lambdaQuery() 有两个入口取决于你的代码用到哪一层。如果你用的是 Mapper 层写法是 wrapper 方式LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getName, 张三); userMapper.selectList(wrapper);如果你用的是 Service 层实现了 IService 接口可以直接链式调用userService.lambdaQuery() .eq(User::getName, 张三) .list();Service 链式写法内部其实就是把条件包装成一个 LambdaQueryWrapper再调用 mapper 对应方法本质是同一套东西。日常业务开发我更推荐 Service 层链式写法代码短、可读性好。但如果你需要非常复杂的 SQL还是得退回 wrapper 或者直接写自定义 SQL。这个看项目规范和个人习惯没有绝对的对错。1.3 版本和环境准备以目前主流的 MyBatis Plus 3.5.x 为例lambdaQuery() 是内置能力不需要额外引入包。使用前确保 pom.xml 里有dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency实体类建议统一加 TableName 指定表名主键加 TableId避免默认规则映射错。比如Data TableName(sys_user) public class User { TableId(type IdType.AUTO) private Long id; private String name; private Integer age; private Integer status; TableLogic private Integer deleted; }这里面 TableLogic 就是逻辑删除注解加了它之后MP 在执行普通查询时会自动追加“deleted0”条件这个问题后面我会单独展开。版本这步容易被忽略但实际挺重要不同小版本的 lambdaQuery 在某些重载方法上有差异比如 3.5.3 之后对 select 排除字段的写法做了调整如果照老版本代码写编译可能不通过。2. 核心 API 详解常用查询方法逐个拆解2.1 等值与非等值查询最常用的就是 eq 和 ne分别对应 SQL 里的 和 // 查询姓名为张三的用户 userService.lambdaQuery() .eq(User::getName, 张三) .list(); // 查询姓名不是张三的用户 userService.lambdaQuery() .ne(User::getName, 张三) .list();注意 eq 的第二个参数如果传 nullMP 默认会直接忽略这个条件不会拼进 SQL。这是 MP 的一个设计细节很多人不知道后面讲动态条件时会用到。如果不希望它忽略可以使用 eq 方法的重载形式并显式传入判断条件这个后面也会说到。等值查询是最基础的查询几乎所有业务接口里都会有理解它的“null 忽略”机制才能更好地利用后面讲到的动态条件拼接。2.2 模糊查询四兄弟模糊查询有四个方法like、notLike、likeLeft、likeRight分别对应 SQL 里的 LIKE、NOT LIKE、LIKE %值、LIKE 值%。// 姓名包含张 userService.lambdaQuery() .like(User::getName, 张) .list(); // 姓名以张开头 userService.lambdaQuery() .likeRight(User::getName, 张) .list(); // 姓名以三结尾 userService.lambdaQuery() .likeLeft(User::getName, 三) .list();like 会自动在值两边加 %生成LIKE %张%。likeRight 只在右边加 %对前缀匹配场景效率更友好。这里有个实际经验如果表数据量大尽量不要用 like 做后模糊匹配因为百分号在前面会让索引失效写成 likeRight 或者单独给前缀查询建索引会好很多。模糊查询是搜索场景的常客但这几个方法别乱用能用 likeRight 就不用 like能用等值就不用模糊索引才能真正发挥作用。2.3 范围查询范围查询包括 gt、ge、lt、le、between、notBetween// 年龄大于等于18 userService.lambdaQuery() .ge(User::getAge, 18) .list(); // 年龄在18到30之间 userService.lambdaQuery() .between(User::getAge, 18, 30) .list();between 是闭区间生成 SQL 是age between 18 and 30。如果 begin 和 end 其中一个为 nullbetween 会自动退化为单边界条件不生成 between。gt/ge/lt/le 分别对应 、、、没有任何坑注意边界条件用对即可。范围查询在价格筛选、日期区间、年龄统计这些场景非常常用特别是时间范围查询配合 Date 类型字段使用时要留意时区问题。2.4 集合查询 in 与 notInin 和 notIn 常用于列表筛选比如根据 id 集合查用户ListLong ids Arrays.asList(1L, 2L, 3L); userService.lambdaQuery() .in(User::getId, ids) .list();这里有个性能实操点in 的集合如果太大比如几千上万条生成的 SQL 会很长而且 MySQL 对 IN 列表长度有优化瓶颈。我的习惯是如果集合超过 1000就分批查询再合并或者改用临时表/关联查询。MP 的 in 方法底层会做空集合判断如果集合为 null 或空这个条件会被忽略不会生成错误的IN ()。这个设计救了不少人否则空集合直接拼 SQL 会生成IN ()数据库直接报语法错误。2.5 空值判断 isNull 与 isNotNull// 查询邮箱为空的用户 userService.lambdaQuery() .isNull(User::getEmail) .list();这个在实际开发中很常用比如查询没填邮箱或者手机号的用户做回访。注意 isNull 没有参数直接传字段引用。isNotNull 用法一样只是语义相反。有些同学刚上手会想用eq(User::getEmail, null)来查空值这是错误的SQL 里判断空值不能用等号必须用 IS NULL所以 MP 才单独提供了这两个方法。2.6 排序与分页排序是 orderByAsc 和 orderByDesc可以传多个字段userService.lambdaQuery() .orderByDesc(User::getCreateTime) .orderByAsc(User::getId) .list();生成 SQL 是order by create_time desc, id asc。多字段排序的场景谁在前谁在后会影响最终顺序SQL 本身就是从左到右的优先级。分页要用 Page 对象配合 mapper 或者 chain 里的 page 方法。IService 链式写法PageUser result userService.lambdaQuery() .ge(User::getAge, 18) .page(new Page(1, 10));page(new Page(1, 10)) 返回的 Page 对象里有 records当前页数据、total总记录数、pages总页数等。注意 MP 分页必须配置分页插件否则分页不会生效只是普通查询。拦截器配置Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }没有分页插件时页面数据能查出来但 total 永远是 0这个问题很多人踩过。分页插件是 MP 使用率最高的配置之一每次新项目搭建我都会第一时间把它加上。2.7 分组与聚合分组使用 groupBy 和 havinguserService.lambdaQuery() .groupBy(User::getDeptId) .having(count(*) {0}, 5) .list();having 和 groupBy 配合使用对分组后的结果做过滤。这里的{0}是占位符MP 会做参数绑定不要用直接拼接字符串的方式防止 SQL 注入。不过说实话真正复杂的聚合查询我不太推荐用 lambdaQuery 写一是可读性差二是聚合场景用自定义 SQL 更清晰。lambdaQuery 更合适的是简单分组比如统计各部门人数这种。2.8 last、exists、notExistslast 方法可以拼接一段原生 SQL 到查询末尾比如userService.lambdaQuery() .eq(User::getStatus, 1) .last(limit 1) .one();这里用last(limit 1)而不是查完再取第一条能少查一些数据。但 last 方法一定要小心它直接拼接 SQL 片段如果内容来自外部输入就有 SQL 注入风险。我的原则是 last 的内容必须写死绝不能拼用户输入。exists 和 notExists 在 lambdaQuery 里也支持但参数需要写一个 wrapper比较复杂。日常场景用得不多场景复杂时建议直接上 XML那才是复杂 SQL 的主场。3. 常用场景拆解从动态条件到链式组合3.1 动态条件拼接的两种姿势业务里最常见的需求是查询条件可能有也可能没有。比如搜索框输入了名字就按名字查没输入就查全部。第一种写法是 if 判断往 wrapper 里加条件LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); if (StringUtils.hasText(name)) { wrapper.eq(User::getName, name); } if (age ! null) { wrapper.ge(User::getAge, age); } ListUser users userMapper.selectList(wrapper);第二种写法是直接利用 condition 参数这是 eq 等系列方法的重载形式ListUser users userMapper.selectList(Wrappers.UserlambdaQuery() .eq(StringUtils.hasText(name), User::getName, name) .ge(age ! null, User::getAge, age));第二种写法更紧凑适合条件不多的情况。两种方式效果一样看团队规范和个人习惯。我个人在接口参数校验不复杂的场景下比较喜欢第二种代码行数少一眼能看到所有条件。3.2 链式查询配合 BaseMapper前面说的 service.lambdaQuery() 是 IService 的实现需要你的 Service 接口继承 IService。如果你的代码还停留在 Mapper 层可以直接用 BaseMapper 配合 LambdaQueryWrapper两种方式混用也没问题LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getName, 张三) .or() .eq(User::getEmail, zhangsanexample.com); ListUser list userMapper.selectList(wrapper);这里的 or() 是一个重点。默认多个条件之间是 and 连接or() 会把接下来的条件变成 or 连接。如果不加括号SQL 的运算优先级容易出问题后面单独讲。Mapper 层写法更接近底层适合对 SQL 有强控制欲的人Service 层写法更符合业务封装适合快速开发。3.3 and 和 or 的嵌套组合先说最容易翻车的 or 用法。以这个为例userService.lambdaQuery() .eq(User::getName, 张三) .eq(User::getStatus, 1) .or() .eq(User::getAge, 18) .list();这段代码生成的条件是name张三 and status1 or age18。由于 and 优先级高于 or所以实际是(name张三 and status1) or age18和业务本意“张三且状态正常或者年龄18岁”刚好一致。但如果你想表达“张三且状态为1或年龄为18”上面的写法就不对了需要用 and 嵌套userService.lambdaQuery() .eq(User::getName, 张三) .and(wrapper - wrapper .eq(User::getStatus, 1) .or() .eq(User::getAge, 18)) .list();and(Consumer) 方法接收一个子 wrapper里面的条件都会用括号包起来。这是 lambdaQuery 里最实用也最容易被忽略的一个方法遇到 or 条件务必记得用 and 包裹。我见过不少线上查询结果不准确的问题最后定位下来都是 or 和 and 优先级搞混了这个坑基本每个用 MP 的人都会踩一次。3.4 select 字段筛选避免查出一堆用不到的列默认查询是 select *很多业务表字段几十个全查出来既浪费 IO 又占内存。可以用 select 指定字段userService.lambdaQuery() .select(User::getId, User::getName, User::getAge) .list();生成 SQL 是select id, name, age from sys_user ...。这条习惯很多人没用上等数据量大起来差距就出来了。还可以用 select 排除指定字段userService.lambdaQuery() .select(User.class, info - !content.equals(info.getColumn())) .list();这个写法稍微绕一点平时我还是更推荐直接列出需要的字段。字段筛选不只是为了省流量在某些特殊场景下还有安全意义比如用户表里的密码字段查询时压根就不应该查出它来用 select 排除掉比查出后在代码里置 null 更稳妥。3.5 groupBy 与 having 的一个实际案例假设要统计每个部门下状态正常的用户数userService.lambdaQuery() .select(User::getDeptId, User::getStatus) .eq(User::getStatus, 1) .groupBy(User::getDeptId) .having(count(*) {0}, 2) .list();这里 select 处只查了 deptId 和 status但如果你在 select 里加了普通字段比如 User::getNameMySQL 的 ONLY_FULL_GROUP_BY 模式会直接报错。遇到 group by 相关的报错先检查 select 字段是否是分组字段或聚合字段。我实际工作中很少在 lambdaQuery 里写 group by因为一旦涉及多个聚合函数、条件筛选、排序代码可读性就会变得很差更推荐用 XML 写但简单分组用 lambdaQuery 还是没问题的。3.6 只查第一条记录one() 和last(limit 1)都行。one() 的语义是“最多返回一条记录”如果查询结果有多条MP 会抛 TooManyResultsException这一点要特别注意。想安全取一条且不报错两个办法一是用 last 加 limitUser user userService.lambdaQuery() .eq(User::getMobile, 13800000000) .last(limit 1) .one();二是直接 list 后取 get(0)。移动端查询通常手机号唯一用 one() 比较安全唯一索引没建好时再用 limit 兜底。写 one() 之前想一下这个条件在业务上是不是真的唯一如果不是就别偷懒。4. 进阶批量插入、逻辑删除与“不报错却没写入”的排查4.1 批量插入的正确用法网上问“mybatis plus 批量插入”的人很多。MP 的 IService 接口提供了 saveBatchListUser userList new ArrayList(); for (int i 0; i 1000; i) { User user new User(); user.setName(测试用户 i); user.setAge(20 i % 10); userList.add(user); } boolean saved userService.saveBatch(userList);saveBatch 默认每批 1000 条可以传入 batchSize 调整比如 saveBatch(userList, 500)。它底层会把实体插入语句组合成一条多值 SQL也就是insert into user(...) values (...),(...),(...)减少网络往返。需要留意的是saveBatch 走的是 MyBatis 的批量执行器默认情况下 MySQL JDBC 驱动对批量语句不会自动启用 rewriteBatchedStatements所以如果想真正提高批量插入效率可以在 JDBC URL 上加一个参数jdbc:mysql://localhost:3306/test?rewriteBatchedStatementstrue加了之后驱动会把多条 insert 语句重写成一条多值 SQL性能提升非常明显。如果不加MP 的批量插入虽然能成功但速度会差很多。我在本地测过插入 1 万条数据加了 rewriteBatchedStatements 之后耗时能缩短到原来的五分之一左右。注意这个参数只对 MySQL 有效其他数据库需要各自对应的配置。还有一种情况如果你的实体类主键是 IdType.ASSIGN_ID 或者 AUTO 都没问题但如果主键策略设置不对批量插入可能报Field id doesnt have a default value最常见的原因就是数据库主键没配自增但 MP 默认策略配置成 AUTO 了两者对不上。批量插入的时候尤其明显因为单条插入即使失败了排查起来也容易批量插入一条失败整批回滚排查成本更高。4.2 insert 数据没有写成功但也没报错究竟为什么这个问题几乎每隔一段时间就会在群里出现一次“我调了 userMapper.insert(user)控制台也没报错事务也提交了但数据库里就是没数据。” 我排查下来最典型的几个原因按概率排一下第一个原因也是最隐蔽的实体类没有加 TableNameMP 根据类名去映射表名如果映射到一张不存在的表会报错但如果项目里恰好有一张名字匹配但业务无关的表比如你插到别的表去了就会“没报错也没写入目标表”。第二个原因SQL 日志没开你以为执行了其实 mapper 方法动态代理后走了自定义 SQL 或者被拦截器跳过。这种情况一打开 SQL 日志就清楚了。第三个原因事务没提交。如果方法上加了 Transactional但外层异常被吞掉了事务可能在 catch 之后悄然回滚看起来就像没有写入。第四个原因逻辑删除字段没设置。如果表里有 deleted 字段并且实体加了 TableLogic插入时如果 deleted 为 nullMP 会默认填 0这个一般不是问题但如果有人给逻辑删除字段设了非 0 值插入后查不出来误以为没写入。第五个原因回滚了但没报错这个说白了还是事务问题日志里一般能看到 rollback 信息。排查这类问题最快的方式就是把 MyBatis SQL 日志打开确认 insert 语句真的发出去了、参数是什么、有没有回滚。application.yml 里加mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImplSQL 一打印百分之八九十的问题都能看出来。我一直觉得遇到这种“不报错但结果不对”的问题第一件事不是猜而是把日志打开看真实执行的 SQL 和参数。4.3 逻辑删除的默认行为为什么查出来的数据总少一些加了 TableLogic 注解之后MP 会自动拦截 CRUD 操作。查询时自动拼上 deleted0删除时自动把 deleted 置为 1 而不是物理 delete。这是一个非常实用的设计但副作用就是你想把包括已删除数据在内的全量数据查出来时普通的 lambdaQuery() 查不到因为它默认给你过滤了。网上搜“mybatis plus 查询 禁用逻辑删除”和“mybatis plus 怎么将逻辑删除的数据也查询出来”其实都是同一个问题如何绕过逻辑删除过滤。这个问题出现频率很高因为很多业务有这个需求管理员后台要看到已删除的数据统计报表要包含删除前的数据或者需要恢复被删除的数据。4.4 三种把逻辑删除数据也查出来的方案第一种方案最省事的自定义 SQL。在 Mapper 里写一条 Select 注解或者 XMLSQL 自己写不要用 MP 的方法。MP 拦截逻辑删除只对 MP 内置方法生效自定义 SQL 不会自动拼接 deleted0你可以随意控制查询条件。比如Select(select * from sys_user where name #{name}) ListUser selectAllIncludeDeleted(Param(name) String name);第二种方案用 XML 写纯 SQL把 resultType 映射成实体类这样查出来的数据既能包含已删除的又能直接映射成 User 对象select idselectIncludeDeleted resultTypecom.example.entity.User select * from sys_user where name #{name} /select第三种方案也是我比较推荐的如果只是偶尔一次需要查询已删除数据直接在自定义 Mapper 方法里绕过即可。如果频繁需要建议重新审视逻辑删除字段的设计是不是合理比如可以把删除状态拆成 status 字段而不是硬用一个 deleted 标志位。逻辑删除本身是一种取舍它保留了数据可追溯性但代价就是所有查询都要多一个条件某些特殊场景还得单独处理。4.5 临时禁用逻辑删除过滤的一个实测技巧有人问能不能在单次查询里禁用逻辑删除MP 3.5.2 有 InterceptorIgnore(tenantLine true)、InterceptorIgnore(dataPermission true) 这类注解但逻辑删除不是拦截器做的是 SQL 注入器做的所以这个注解对这种场景基本无效。要想按查询级别控制官方没有直接参数开关所以才更多用自定义 SQL 来解决。还有一个思路是自定义一个 Mapper 方法方法名能看出是“包含已删除”的特殊查询代码里调用时也一目了然这样既绕过了逻辑删除又不会让同事看代码时产生误解。Select(select * from sys_user where id #{id}) User selectByIdIncludeDeleted(Param(id) Long id);这段 SQL 也建议在 XML 里写格式化和维护都更方便。5. 常见问题与排查技巧实录5.1 分页插件没配置total 永远为 0这个问题放在第一个讲因为太典型了。很多人用了 page 方法发现 records 能查出来但 total 0page 总数也是 0这就是没配置分页插件。MP 3.5.x 的配置方式Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置之后分页才会拼接 limittotal 才会通过 count 查询得到。注意分页插件对多数据源、多数据库类型敏感DbType 要传对比如 MySQL 传 DbType.MYSQLPostgreSQL 传 DbType.POSTGRE_SQL。如果你项目里接了多个数据源分页插件只对当前数据源生效这个也要留意。5.2 字段名映射不上下划线和驼峰MP 默认开启下划线转驼峰也就是数据库字段 create_time 能映射到实体属性 createTime。如果你们公司数据库命名不规范字段直接叫 createTime那默认反而映射不上。可以在 application.yml 里调mybatis-plus: configuration: map-underscore-to-camel-case: true还有一种情况实体属性命名和数据库字段对不上比如数据库叫 user_name实体属性叫 name。这就必须在实体字段上加 TableFieldTableField(user_name) private String name;不加注解查询出来的 name 永远是 null又因为不报错排查起来很恼火。当你发现某个字段查出来是 null 但数据库里有值第一反应就去看实体映射注解。还有一种常见情况实体里有个属性在数据库表中没有对应字段不加 TableField(exist false) 的话插入和查询都会报错这一点新手很容易踩。5.3 one() 方法在多条记录时直接报错one() 的语义是“查询一条记录”如果 SQL 查出了多条MP 会抛 TooManyResultsException。很多人把 one() 当成“取第一条”来用这其实不对取第一条应该用 limit 1 或者 list() 后 get(0)。用 one() 之前要确保查询条件在业务上唯一比如主键查询、唯一索引字段查询。如果不确定就用last(limit 1)或有序分页第一条。我在代码 review 时看到有人用 one() 取手机号对应的用户结果生产环境出现了重复数据直接接口 500就是因为没加唯一索引还用了 one()。5.4 N1 查询问题lambdaQuery() 写起来很爽但也容易在循环里出问题最常见的坑就是在 for 循环里查数据库for (Long deptId : deptIds) { ListUser users userService.lambdaQuery() .eq(User::getDeptId, deptId) .list(); // 处理逻辑 }这个就是经典的 N1 查询每循环一次就发起一次查询100 个部门就是 101 条 SQL。优化方式很简单把 deptIds 整个传进去用 in 一次查出来再在内存里按部门分组ListUser users userService.lambdaQuery() .in(User::getDeptId, deptIds) .list(); MapLong, ListUser userMap users.stream() .collect(Collectors.groupingBy(User::getDeptId));性能差异在数据量小的时候不明显数据量上去之后循环查库会导致数据库连接被打满、接口响应变慢所以写 lambdaQuery 时多想想能不能一次查完。这个优化习惯比任何工具技巧都重要很多时候接口慢不是 MP 的问题是使用方式的问题。5.5 SQL 日志与慢 SQL 排查排查 lambdaQuery 生成的 SQL 是否正确、是否走了索引第一步都是看日志。除了 StdOutImpl 打印完整 SQL也可以配合 p6spy 这类工具。但最简单的还是把日志打开看生成的条件顺序、limit 语句、参数值。另外lambdaQuery 生成的 SQL 一般都能正常利用索引但如果有 or 条件、前模糊 like、对索引列做函数操作索引可能失效。所以遇到慢 SQL优先用 explain 查看执行计划。MP 也提供了 queryWrapper 的 getSqlSegment 方法可以在代码里直接打印条件片段LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.eq(User::getName, 张三); System.out.println(wrapper.getSqlSegment());这个方法在调试时很有用可以看到 MP 到底帮你拼了什么。不过 getSqlSegment 打印出来的片段里参数是占位符 ?不是具体值想看到具体值还是要靠完整 SQL 日志。5.6 高版本中的一些新方法和变化MP 3.5.0 之后BaseMapper 增加了 selectCount 等方法重载IService 里也增加了 lambdaQuery 的一些重载。另外 3.5.4 后逻辑删除对某些自定义方法的行为也有调整建议升级版本前先看 release notes。如果项目还是老版本升级到 3.5.x 后主要注意分页插件配置方式的迁移旧版本 PaginationInterceptor 已废弃统一改用 MybatisPlusInterceptor。整体来说lambdaQuery() 覆盖了日常 CRUD 查询的绝大部分场景越用越顺手。但真要追求极致性能或者写复杂的报表 SQL还是应该老老实实回到 XML。最后再分享一个我在实际项目里的小技巧把一些高频查询条件封装成实体类里的静态方法比如在 User 实体类里写一个构建查询条件的公共方法业务层直接用这样每个业务方法下来代码都会短很多。举个例子public static LambdaQueryWrapperUser buildNormalQuery(String name, Integer minAge) { return Wrappers.UserlambdaQuery() .eq(StringUtils.hasText(name), User::getName, name) .ge(minAge ! null, User::getAge, minAge) .orderByDesc(User::getCreateTime); }调用的时候一行搞定团队里新同学拿到就能用也不容易写错条件。做后端开发工具用熟了能省下不少排查问题的时间。顺手把日志开关配好、分页插件配好、唯一索引建好很多坑根本不会