ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MyBatis-Plus QueryWrapper动态查询实战:从原理到复杂场景应用

2026/8/15 5:42:32 拓冰建站 浏览量
MyBatis-Plus QueryWrapper动态查询实战:从原理到复杂场景应用 1. 项目概述为什么动态查询是后端开发的“硬通货”在任何一个涉及数据展示的后端项目里查询功能永远是业务逻辑中最活跃、最复杂的部分。产品经理今天说“这个列表要支持按名称模糊搜索。”明天又提“再加个按状态和时间范围的多条件筛选。”后天可能还会补一句“用户希望这些筛选条件可以自由组合不填的就不生效。”如果你还在手写一长串的if-else来拼接 SQL 的WHERE条件不仅代码冗长、难以维护还极易出错比如漏掉一个空格或者and关键字就会导致查询失败。Mybatis-Plus 的QueryWrapper及其 Lambda 表达式版本LambdaQueryWrapper就是为了解决这个痛点而生的神器。它本质上是一个查询条件构造器允许你以面向对象、链式调用的方式动态地构建 SQL 的 WHERE 部分。你不再需要关心 SQL 语句的拼接细节只需要关注业务逻辑“如果前端传了名字就加上名字的模糊匹配如果传了状态就加上状态的等值匹配。”QueryWrapper会帮你处理好一切生成安全、正确的 SQL。结合网络热词来看mybatis-plus 动态取消租户隔离和对象转querywrapper恰恰印证了QueryWrapper在应对复杂、动态场景时的核心价值。无论是多租户数据过滤的灵活控制还是将前端查询对象自动转换为查询条件其底层都依赖于QueryWrapper强大的动态构建能力。可以说掌握QueryWrapper尤其是其动态条件构建技巧是高效使用 Mybatis-Plus、提升后端开发效率的必经之路。2. QueryWrapper 核心设计与思路拆解2.1 两种Wrapper的选择QueryWrapper vs LambdaQueryWrapperMybatis-Plus 提供了两种主要的查询包装器QueryWrapperT和LambdaQueryWrapperT。它们功能完全一致最大的区别在于条件字段的指定方式。QueryWrapper使用字符串形式指定数据库字段名。例如QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(name, 张三).gt(age, 18);这种方式简单直接但存在一个致命问题类型不安全且容易出错。字符串name和age是硬编码的如果数据库表字段名变更或者你手误写成了nmae编译器不会报错只有在运行时执行 SQL 时才会暴露问题这给代码维护和排查 Bug 带来了很大风险。LambdaQueryWrapper则通过 Lambda 表达式和方法引用来指定字段完美解决了上述问题。LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getName, 张三).gt(User::getAge, 18);这里的User::getName在编译时就会被检查如果User类中没有getName方法或者方法返回值类型不匹配编译将无法通过。这实现了编译期类型安全。同时在 IDE 的帮助下你可以方便地进行重命名重构所有引用该字段的地方都会自动更新极大提升了代码的健壮性和可维护性。实操心得在新项目中无脑选择LambdaQueryWrapper。虽然它的语法看起来稍微复杂一点但这点学习成本远低于后期排查一个因字段名拼写错误导致的隐蔽 Bug 所花费的时间。QueryWrapper仅在一些非常特殊的、无法获取实体类引用的场景下例如极度动态的元编程才考虑使用。2.2 链式调用与条件组合的逻辑QueryWrapper的核心 API 设计采用了流畅的链式调用Fluent API风格。每一个条件方法如eq,like,gt都会返回Wrapper自身因此可以不断地在后面.出新的条件。这里需要理解其底层组合逻辑默认情况下所有通过链式调用添加的条件都是用AND连接。wrapper.eq(status, 1).like(name, tech).gt(create_time, 2023-01-01);生成的 SQL 将是WHERE status 1 AND name LIKE %tech% AND create_time 2023-01-01那么如何实现OR连接呢Mybatis-Plus 提供了or()和and()方法来显式控制逻辑关系。or()将后续的一个条件用OR与前面的条件连接。and()显式地使用AND通常可省略因为它是默认行为。更复杂的嵌套逻辑则需要使用nested方法或apply方法拼接 SQL 片段。理解这个“默认AND显式OR”的模型是写出正确动态查询的关键。3. 动态条件构建的四种实战模式动态查询的核心在于根据前端传入的参数是否有效非null、非空字符串等来决定是否添加对应的查询条件。下面介绍四种最常用、最清晰的实现模式。3.1 模式一最直接的 if 语句判断这是最基础、最易理解的方式。直接在代码中判断参数然后调用 Wrapper 的方法。public ListUser queryUsers(String name, Integer status, LocalDateTime beginTime, LocalDateTime endTime) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); if (StringUtils.isNotBlank(name)) { // 使用 likeRight 表示 name LIKE 张%性能优于左右模糊(%张%) wrapper.likeRight(User::getName, name); } if (status ! null) { wrapper.eq(User::getStatus, status); } if (beginTime ! null endTime ! null) { wrapper.between(User::getCreateTime, beginTime, endTime); } else { // 处理只有一个时间的情况 if (beginTime ! null) { wrapper.ge(User::getCreateTime, beginTime); // ge: greater than or equal } if (endTime ! null) { wrapper.le(User::getCreateTime, endTime); // le: less than or equal } } // 添加默认排序 wrapper.orderByDesc(User::getCreateTime); return userMapper.selectList(wrapper); }注意事项对于字符串判断是否为空建议使用StringUtils.isNotBlank()来自 Apache Commons Lang 或 Spring 的StringUtils因为它会同时排除null、空字符串和纯空格字符串。对于时间范围查询between虽然方便但要考虑前端是否一定会同时传入开始和结束时间。更健壮的做法是像上面代码一样拆分成ge() 和le() 来处理边界情况。like、likeLeft、likeRight的选择like(“字段”, “value”)生成%value%前后模糊性能最差除非必要尽量避免。likeRight(“字段”, “value”)生成value%前缀匹配可以利用数据库索引是最推荐的模糊查询方式。likeLeft(“字段”, “value”)生成%value后缀匹配一般无法利用索引。3.2 模式二使用Objects.nonNull等工具方法配合函数式编程当条件判断逻辑简单时可以利用 Java 8 的Objects工具类和函数式接口让代码更简洁。public ListUser queryUsers(String name, Integer status) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // 使用 Objects.nonNull 和 StringUtils.isNotBlank Optional.ofNullable(status).ifPresent(s - wrapper.eq(User::getStatus, s)); Optional.ofNullable(name) .filter(StringUtils::isNotBlank) .ifPresent(n - wrapper.likeRight(User::getName, n)); return userMapper.selectList(wrapper); }这种写法避免了多层if嵌套意图更清晰。但它更适合于简单的条件添加如果条件间有复杂的依赖关系如上面的时间范围判断还是模式一的if语句更直观。3.3 模式三封装成独立的条件构建方法当某个查询条件构建逻辑非常复杂或者在多处重复使用时可以将其封装成一个独立的方法。private void applyTimeFilter(LambdaQueryWrapperUser wrapper, LocalDateTime begin, LocalDateTime end) { if (begin ! null end ! null) { if (begin.isAfter(end)) { // 处理开始时间晚于结束时间的逻辑错误 throw new IllegalArgumentException(开始时间不能晚于结束时间); } wrapper.between(User::getCreateTime, begin, end); } else if (begin ! null) { wrapper.ge(User::getCreateTime, begin); } else if (end ! null) { wrapper.le(User::getCreateTime, end); } // 如果都为空则不添加任何条件 } public ListUser queryUsers(..., LocalDateTime begin, LocalDateTime end) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // ... 添加其他条件 applyTimeFilter(wrapper, begin, end); // 调用封装的方法 return userMapper.selectList(wrapper); }这种方式极大地提高了代码的复用性和可读性也使得单元测试更加容易。3.4 模式四结合 Spring 的Condition注解思想自定义实现对于更大型、更规范的项目可以借鉴 Spring 中ConditionalOnProperty等注解的思想自定义一套注解来描述查询条件。不过这通常需要结合 AOP 或自定义注解处理器来实现复杂度较高这里提供一个概念性示例// 自定义注解概念示例 Retention(RetentionPolicy.RUNTIME) Target(ElementType.FIELD) public interface QueryCondition { String column(); // 对应数据库字段 Operator operator() default Operator.EQ; // 操作符如 EQ, LIKE, GT boolean ignoreBlank() default true; // 是否忽略空值 } // 在查询参数对象上使用 Data public class UserQueryDTO { QueryCondition(column name, operator Operator.LIKE_RIGHT) private String name; QueryCondition(column age, operator Operator.GT) private Integer minAge; } // 通过一个工具类利用反射解析 DTO 并构建 QueryWrapper public class QueryWrapperBuilder { public static T, Q LambdaQueryWrapperT build(Q queryObject) { LambdaQueryWrapperT wrapper new LambdaQueryWrapper(); // 反射遍历 queryObject 的字段读取 QueryCondition 注解 // 根据注解信息和字段值动态调用 wrapper 的相应方法 return wrapper; } }这种模式将条件规则声明化使业务代码非常简洁但框架搭建有一定成本。社区也有一些开源工具如mybatis-plus-query提供了类似功能在项目复杂度达到一定级别时值得引入。4. 复杂查询场景的进阶技巧4.1 处理OR条件和条件分组假设我们需要查询status 1或者name包含“张”并且age 30的用户。这里就涉及到了OR和条件分组。LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .or(wp - wp.like(User::getName, 张).lt(User::getAge, 30));生成的 SQL 是WHERE status 1 OR (name LIKE %张% AND age 30)or(ConsumerLambdaQueryWrapperT consumer)方法接受一个函数式接口在这个函数里构建的条件会被括号包裹并用OR连接到主条件上。嵌套AND分组 如果需要(A AND B) OR (C AND D)这样的逻辑可以嵌套使用or和and。wrapper.and(wp - wp.eq(...).gt(...)) // (A AND B) .or(wp - wp.like(...).lt(...)); // OR (C AND D)4.2 动态SELECT字段与聚合查询有时我们不需要查询整个实体对象的所有字段特别是当表字段很多而前端只需要其中几项时动态选择字段可以显著减少网络传输和数据库开销。public ListMapString, Object queryUserSimpleInfo(String name) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.select(User::getId, User::getName, User::getAvatar); // 只查询这三个字段 if (StringUtils.isNotBlank(name)) { wrapper.likeRight(User::getName, name); } // selectMaps 返回 ListMapString, ObjectMap的key是字段名或别名value是查询结果 return userMapper.selectMaps(wrapper); }selectMaps非常灵活还可以用于简单的聚合查询wrapper.select(COUNT(*) as user_count, AVG(age) as avg_age, dept_id); wrapper.groupBy(dept_id); ListMapString, Object result userMapper.selectMaps(wrapper); // result 中的 Map 包含{user_count10, avg_age28.5, dept_id1}4.3 子查询与EXISTS条件QueryWrapper同样支持子查询主要通过inSql,notInSql,exists,notExists等方法实现。场景查询存在订单的用户。LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.exists(SELECT 1 FROM order WHERE order.user_id user.id AND order.status PAID); // 或者使用 apply 方法手动拼接子查询片段更灵活但要注意SQL注入 wrapper.apply(EXISTS (SELECT 1 FROM order WHERE order.user_id user.id AND order.status PAID));对于IN子查询// 查询部门ID在某个列表中的用户 wrapper.inSql(User::getDeptId, SELECT id FROM dept WHERE status 1);重要提醒apply方法允许直接拼接 SQL 片段功能强大但必须谨慎使用绝对不要将用户输入直接拼接进去以防 SQL 注入攻击。应使用{0}、{1}等占位符并通过参数传入。// 错误存在SQL注入风险 wrapper.apply(create_time userInputDate ); // 正确使用预编译占位符 wrapper.apply(create_time {0}, userInputDate);4.4 关联查询非 JOIN的模拟Mybatis-Plus 的 Wrapper 本身不直接处理多表 JOIN它专注于单表操作。实现关联查询通常有两种方式使用 Mybatis 的 XML/注解写 JOIN SQL这是最正统、性能最好的方式复杂关联查询首选。使用QueryWrapper进行“多次查询”先查询主表拿到 ID 列表再用in查询关联表。这在某些简单场景下更清晰。// 1. 先查用户ID LambdaQueryWrapperUser userWrapper new LambdaQueryWrapper(); userWrapper.select(User::getId).likeRight(User::getName, 张); ListLong userIds userMapper.selectObjs(userWrapper) // 返回ListObject .stream() .map(id - (Long) id) .collect(Collectors.toList()); // 2. 用用户ID查订单 if (!userIds.isEmpty()) { LambdaQueryWrapperOrder orderWrapper new LambdaQueryWrapper(); orderWrapper.in(Order::getUserId, userIds); ListOrder orders orderMapper.selectList(orderWrapper); }selectObjs方法只返回查询结果的第一列常用于获取 ID 列表。5. 结合网络热词的深度应用解析5.1 实现“对象转 QueryWrapper”网络热词中提到了“对象转querywrapper”这通常指将前端传来的一个查询参数对象如UserQueryDTO自动转换为QueryWrapper。我们可以利用反射或工具库如BeanUtils、Hutool轻松实现一个通用工具方法。public class QueryWrapperUtils { /** * 将一个简单POJO对象的非空字段转换为等值查询条件 * param queryObject 查询对象 * param clazz 实体类类型 * return LambdaQueryWrapper */ public static T, Q LambdaQueryWrapperT buildEqWrapper(Q queryObject, ClassT entityClass) { LambdaQueryWrapperT wrapper new LambdaQueryWrapper(); if (queryObject null) { return wrapper; } Field[] fields queryObject.getClass().getDeclaredFields(); for (Field field : fields) { try { field.setAccessible(true); Object value field.getValue(queryObject); // 判断值是否有效非null非空字符串 if (isValidValue(value)) { // 这里需要将字段名映射到实体类的属性。这是一个简化示例。 // 实际应用中可能需要注解来指定映射关系或者要求DTO字段名与实体类属性名一致。 String fieldName field.getName(); // 假设实体类有同名的属性且遵循驼峰转下划线的规则Mybatis-Plus默认支持 // 更严谨的做法是通过实体类的Field获取对应的Lambda方法引用这里简化处理。 wrapper.eq(StringUtils.toUnderScoreCase(fieldName), value); } } catch (IllegalAccessException e) { // 忽略无法访问的字段 } } return wrapper; } private static boolean isValidValue(Object value) { if (value null) { return false; } if (value instanceof CharSequence) { return StringUtils.isNotBlank((CharSequence) value); } return true; } }在实际项目中可以使用更成熟的方案如基于MapStruct或Spring的BeanWrapper进行更精准的类型和属性映射或者直接使用社区开源的mybatis-plus-query等扩展库。5.2 理解“动态取消租户隔离”“Mybatis-plus 动态取消租户隔离”是一个高级话题。Mybatis-Plus 提供了多租户插件TenantLineInnerInterceptor它会自动在所有查询的 SQL 中加上租户 ID 条件如tenant_id 1。但在某些全局管理或数据导出的场景下超级管理员需要能查询所有租户的数据此时就需要“动态取消”这个过滤条件。实现思路通常有两种使用Interceptor的上下文ThreadLocal在执行业务方法前在一个 ThreadLocal 变量中设置一个“忽略租户”的标记。在自定义的租户处理器中检查这个标记如果存在则返回null或不添加租户条件。使用QueryWrapper的特定方法Mybatis-Plus 的租户插件默认会对所有selectList、selectPage等方法生效。但你可以通过创建不携带租户条件的QueryWrapper来绕过不这通常不行因为插件是在 SQL 解析层面添加的条件。更常见的做法是第一种。例如public class TenantContext { private static final ThreadLocalBoolean IGNORE_TENANT ThreadLocal.withInitial(() - false); public static void setIgnoreTenant(boolean ignore) { IGNORE_TENANT.set(ignore); } public static boolean isIgnoreTenant() { return IGNORE_TENANT.get(); } public static void clear() { IGNORE_TENANT.remove(); } } // 在自定义的 TenantLineHandler 中 Override public Expression getTenantId() { if (TenantContext.isIgnoreTenant()) { // 返回 null 表示不添加租户条件 return null; } // ... 正常返回当前租户ID的表达式 } // 在需要查询全量数据的方法中 public ListUser queryAllTenantUsers() { try { TenantContext.setIgnoreTenant(true); // 这里的查询将不会自动添加 tenant_id 条件 return userMapper.selectList(new LambdaQueryWrapper()); } finally { TenantContext.clear(); // 务必清理防止内存泄漏和上下文污染 } }这个热词说明QueryWrapper的使用需要放在整个 Mybatis-Plus 插件生态中去理解灵活应对各种业务场景。5.3 实现“数据库字段级加密”查询“Spring Boot Mybatis-Plus:实现数据库字段级加密”是另一个热门需求。例如用户手机号、身份证号等敏感信息在数据库中是加密存储的。那么使用QueryWrapper进行查询时如果直接wrapper.eq(User::getPhone, “13800138000”)由于数据库里存的是加密后的字符串肯定查不到。解决方案是在QueryWrapper构建条件时对查询值进行同样的加密。首先你需要有一个统一的加密工具类EncryptUtil。在构建查询条件时先加密再查询。public ListUser queryByEncryptedPhone(String plainPhone) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // 查询前对输入参数进行加密 String encryptedPhone EncryptUtil.encrypt(plainPhone); wrapper.eq(User::getPhone, encryptedPhone); // 数据库字段存储的也是加密后的值 return userMapper.selectList(wrapper); }对于模糊查询这变得非常棘手因为加密后的数据失去了原文的格式无法进行LIKE操作。一种折中方案是在数据库中额外增加一个“脱敏索引字段”例如存储手机号的前三位和后四位明文然后对这个字段进行模糊查询。这需要根据具体的安全和业务需求进行权衡设计。6. 性能优化与常见陷阱排查6.1 索引失效与查询性能QueryWrapper生成的 SQL 质量直接决定了查询性能。一些不当的写法会导致数据库索引失效。陷阱1对索引字段进行函数操作或计算。// 错误示例假设 create_time 是 datetime 类型且有索引 wrapper.apply(DATE(create_time) {0}, 2023-10-01); // 对字段使用 DATE 函数索引失效 // 正确做法使用范围查询 wrapper.ge(User::getCreateTime, 2023-10-01 00:00:00); wrapper.lt(User::getCreateTime, 2023-10-02 00:00:00);陷阱2模糊查询like ‘%xxx’导致索引失效。如前所述优先使用likeRight‘xxx%’进行前缀匹配才能利用索引。陷阱3使用or条件不当。WHERE a 1 OR b 2如果a和b分别有索引但数据库可能选择全表扫描。可以考虑改写成UNION两个查询或者使用in替代部分or。陷阱4查询大量不需要的字段。务必使用wrapper.select(...)来限制返回的字段特别是避免SELECT *查询包含TEXT、BLOB等大字段的表。6.2 N1 查询问题在使用QueryWrapper查询出列表后如果遍历列表再去查询每个对象的关联数据例如查询用户列表后再循环查询每个用户的订单就会产生著名的 N1 查询问题对数据库造成巨大压力。解决方案使用 Mybatis 的collection或association进行一次性 JOIN 查询在 XML 中定义 ResultMap。使用 Mybatis-Plus 的TableField(exist false)和自定义查询方法手动在 Service 层做一次in查询进行数据组装。对于简单场景可以考虑使用selectMaps进行一次性的关联查询但结果处理较复杂。6.3 分页查询的正确姿势结合热词“mybatis-plus分页查询”Mybatis-Plus 的分页插件PaginationInnerInterceptor需要正确配置。配置分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件 PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInnerInterceptor.setMaxLimit(1000L); // 设置单页最大记录数 paginationInnerInterceptor.setOverflow(true); // 超过最大页数后是否返回首页false则返回空 interceptor.addInnerInterceptor(paginationInnerInterceptor); // 可以添加其他插件如乐观锁、租户等 return interceptor; } }使用分页查询public PageUser queryUserPage(UserQueryDTO queryDTO, Integer pageNum, Integer pageSize) { // 1. 构建查询条件 LambdaQueryWrapperUser wrapper buildWrapper(queryDTO); // 复用之前的动态构建方法 // 2. 创建分页对象。pageNum从1开始。 PageUser page new Page(pageNum, pageSize); // 可以设置是否进行 count 查询默认true。如果确定不需要总数设为false能提升性能。 // page.setSearchCount(false); // 3. 执行分页查询 return userMapper.selectPage(page, wrapper); }selectPage方法返回的Page对象中包含了分页数据 (records)、总记录数 (total)、每页大小 (size)、当前页 (current) 等信息可以直接返回给前端。踩坑记录分页插件是通过拦截器在 SQL 执行前后动态追加LIMIT和计算COUNT的。一定要确保你的QueryWrapper没有自己写limit语句否则会干扰插件的正常工作。另外复杂的GROUP BY或DISTINCT查询可能会导致分页插件自动生成的COUNT语句错误此时需要手动指定count查询可以使用page.setOptimizeCountSql(false)并自定义count语句。6.4 逻辑删除与查询如果项目中使用了 Mybatis-Plus 的全局逻辑删除TableLogic注解那么默认情况下selectList、selectById等所有查询方法都会自动加上deleted 0的条件。如果你需要查询包含已逻辑删除的数据则需要使用特定的 Wrapper 方法。// 查询所有数据包括已删除的 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(...).or(...); // 你的业务条件 // 关键忽略逻辑删除条件 wrapper.ignoreLogicDelete(); ListUser allUsers userMapper.selectList(wrapper);ignoreLogicDelete()方法会使得本次查询跳过自动添加的逻辑删除条件。同理也有ignoreTenantLine()等方法用于跳过其他插件添加的全局条件。