java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > MyBatisPlus DISTINCT去重

MyBatis Plus中实现DISTINCT去重的三种方案与实战指南

作者:菩提风

本文为你揭秘三种MyBatis Plus中实现DISTINCT去重的方案,重点推荐使用select方法注入SQL片段,兼顾类型安全和灵活性,同时分享复杂场景下的实战技巧,帮你避开分页、多字段去重等常见坑点

1. 项目概述:当Lambda遇上Distinct,如何优雅去重?

在MyBatis Plus的日常开发中, QueryWrapper 搭配Lambda表达式已经成了我们构建查询条件的“标准姿势”。它带来的类型安全和编译期检查,让代码既清爽又健壮。但不知道你有没有遇到过这样一个场景:你需要查询某个字段的唯一值列表,比如“获取所有不重复的部门名称”或者“列出所有有订单的客户ID”。这时候,你本能地想在Lambda链式调用里加上一个 .distinct() ,结果发现IDE根本没这个提示,直接报错。这感觉就像开车上了高速,想开定速巡航却发现按钮是坏的,非常别扭。

这个需求其实非常普遍,尤其是在做下拉框数据源、统计去重计数或者关联查询时。 DISTINCT 是SQL中的基础关键字,但在MyBatis Plus的Lambda QueryWrapper 中,它的使用方式却和直接写SQL或使用普通 QueryWrapper 有些不同,需要一点“小技巧”。网上能找到的很多资料要么语焉不详,要么给出的方法已经过时。今天,我就结合自己踩过的坑和项目中的实际应用,来彻底讲清楚如何在Lambda QueryWrapper 中正确、高效地使用 DISTINCT ,让你不再为这个“小问题”困扰。

2. 核心原理:QueryWrapper与Distinct的协作机制

要解决问题,得先理解问题的根源。为什么在Lambda表达式中不能直接调用 distinct ?这得从MyBatis Plus的设计说起。

2.1 QueryWrapper的SQL构建逻辑

QueryWrapper 的本质是一个查询条件包装器,它的核心工作是动态拼接SQL语句的 WHERE 部分。当我们调用 wrapper.eq(User::getName, “张三”) 时,它会在内部构建出类似 name = ‘张三’ 的SQL片段。 SELECT 子句的构建,默认并不是 QueryWrapper 的职责,它默认会查询所有字段( SELECT * )。 DISTINCT 关键字是作用于整个 SELECT 字段列表的,它属于 SELECT 子句的范畴,而不是 WHERE 子句。

在普通的字符串列名形式的 QueryWrapper 中,我们可以通过 wrapper.select(“DISTINCT column_name”) 来指定查询字段并加入 DISTINCT 。这是因为 select(String… columns) 方法直接接收SQL片段字符串,我们可以把 “DISTINCT column_name” 作为一个整体参数传进去。

2.2 Lambda表达式的类型安全限制

Lambda QueryWrapper (即 LambdaQueryWrapper )通过方法引用来获取属性名,其 select 方法被重载为 select(SFunction<T, ?>… columns) 。这个方法接收的是函数式接口,它的设计初衷是进行“属性选择”,即从实体类中选择需要查询的字段。编译器和方法签名会严格检查传入的是否是合法的getter方法引用。像 “DISTINCT” 这样的SQL关键字,并不是实体类的属性,因此无法通过Lambda表达式直接表示。

这就产生了一个矛盾:我们既想享受Lambda的类型安全,又想注入SQL关键字。MyBatis Plus的解决之道是提供一种“混合”模式,允许我们在Lambda的框架下,嵌入小段的SQL片段。理解了这个设计逻辑,我们就能明白,解决方案不是去“硬闯”Lambda的限制,而是找到官方提供的“后门”或“通道”。

2.3 Distinct的两种应用场景辨析

在实际使用前,我们还需要明确 DISTINCT 的两种用法,这决定了我们后续选择哪种技术方案:

  1. 单字段去重查询 :这是最常见的场景,例如 SELECT DISTINCT department_id FROM user 。目标是获取该字段所有不重复的值。
  2. 多字段组合去重查询 :例如 SELECT DISTINCT department_id, status FROM user 。目标是基于多个字段的组合进行去重,只有所有指定字段的值都相同的记录才会被合并。

LambdaQueryWrapper 对于这两种场景的支持度略有不同,需要区别对待。

3. 方法详解:三种实现Distinct的实战方案

掌握了原理,我们来看具体怎么做。我将介绍三种从推荐到备选的方案,你可以根据实际情况选择。

3.1 方案一:使用select方法注入SQL片段(推荐)

这是目前最优雅、也是最符合MyBatis Plus设计哲学的方式。核心思路是:使用 select 方法,但只传入我们需要的字段(通过Lambda指定),然后通过 select 方法的另一个重载版本来完成 DISTINCT 的注入。

具体操作如下:

// 场景:查询所有不重复的部门ID
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.select(User::getDepartmentId); // 第一步:用Lambda选择字段
wrapper.select(“DISTINCT(department_id)”); // 第二步:注入DISTINCT SQL片段

List<User> userList = userMapper.selectList(wrapper);
// 注意:这里返回的User对象,只有departmentId字段有值,其他字段为null

关键点解析:

重要提示 :使用此方法后,查询返回的 User 对象列表, 只有被 select 指定的字段(这里是 departmentId )有值,其他所有字段都是 null 。这是因为MyBatis Plus开启了“查询字段优化”,只查询和映射你明确指定的字段。如果你需要这个对象的所有字段,此方案不适用。

多字段去重示例:

// 查询不重复的 (部门ID, 状态) 组合
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.select(User::getDepartmentId, User::getStatus);
wrapper.select(“DISTINCT(department_id, status)”);

List<User> userList = userMapper.selectList(wrapper);
// 返回的User对象,仅departmentId和status字段有值

3.2 方案二:使用apply方法拼接自定义SQL(灵活)

如果你的去重逻辑更复杂,或者方案一在某些极端情况下不奏效(比如不同数据库方言对 DISTINCT 括号的解析差异),可以使用 apply 方法。 apply 方法用于拼接 WHERE 条件后的自定义SQL片段,但我们可以“创造性”地用它来影响 SELECT 。

这种方法更接近于原生SQL,灵活性最高,但也放弃了部分Lambda的类型安全优势。

// 查询所有不重复的部门ID,并按照ID排序
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.apply(“1=1”) // 一个恒真条件,目的是引入APPLY片段
      .last(“SELECT DISTINCT department_id FROM user”); // 注意:这里使用了.last()

// 更常见的做法是直接与.last()结合
LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<>();
wrapper2.isNotNull(User::getDepartmentId) // 一个正常的WHERE条件
        .last(“DISTINCT department_id”); // 追加到SQL末尾

关键点与风险:

3.3 方案三:退回到普通QueryWrapper(兼容)

当Lambda的“糖”实在吃不下去时,回归本质也是一种务实的选择。普通 QueryWrapper 虽然失去了编译期类型检查,但在处理复杂SQL片段时简单直接。

// 使用字符串列名
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.select(“DISTINCT department_id AS departmentId”); // 使用AS别名确保映射
// 可以继续添加WHERE条件
wrapper.isNotNull(“department_id”);

List<User> userList = userMapper.selectList(wrapper);

优点 :直观,与写SQL思维一致,尤其适合从原生SQL迁移过来的老代码。 缺点 :类型不安全,列名拼写错误要到运行时才能发现;重构(如实体类字段名修改)时,这些字符串不会被自动识别和更新。

3.4 方案对比与选型建议

为了更直观,我将三种方案总结如下:

特性方案一: select 注入SQL片段方案二: apply / .last()方案三:普通 QueryWrapper
类型安全⭐⭐⭐⭐ (Lambda选择字段)⭐ (需手动保证SQL正确)⭐ (纯字符串)
代码优雅度⭐⭐⭐⭐ (链式调用,意图清晰)⭐⭐ (SQL拼接感强)⭐⭐⭐ (直接明了)
灵活性⭐⭐⭐ (满足大部分去重场景)⭐⭐⭐⭐⭐ (可嵌入任意SQL)⭐⭐⭐⭐⭐ (完全控制SQL)
推荐度首选特殊复杂场景备用Lambda受限时或简单场景使用
核心要点先Lambda select 字段,再SQL select 片段谨慎使用 .last() ,清楚SQL结构直接使用字符串列名

个人建议 :在绝大多数“通过实体类字段进行去重查询”的场景下, 无条件选择方案一 。它在类型安全、代码可读性和框架兼容性上取得了最佳平衡。只有在需要进行极其复杂的、包含子查询或数据库特定函数的去重逻辑时,才考虑方案二,并且务必编写详细的单元测试来验证生成的SQL。方案三可以作为项目中原有代码风格的延续,在新代码中不推荐作为首选。

4. 实战进阶:复杂场景下的Distinct应用与优化

掌握了基本方法,我们来看看在实际项目中可能遇到的更复杂的情况以及如何优化。

4.1 场景一:去重查询并返回完整对象

这是一个常见的需求:我想获取不重复的部门列表,但每个部门信息需要完整(部门名、负责人等)。方案一只返回单个字段,不符合要求。怎么办?

正确思路是:关联查询。 我们不应该在 user 表上做 DISTINCT 然后映射完整 User 对象(这逻辑不对),而应该先获取去重的部门ID,再通过部门ID去查询完整的部门信息。

// 1. 先获取不重复的部门ID列表
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.select(User::getDepartmentId);
wrapper.select(“DISTINCT(department_id)”);
List<User> usersWithOnlyDeptId = userMapper.selectList(wrapper);
List<Long> distinctDeptIds = usersWithOnlyDeptId.stream()
        .map(User::getDepartmentId)
        .filter(Objects::nonNull)
        .collect(Collectors.toList());

// 2. 根据ID列表查询完整的部门信息
if (!distinctDeptIds.isEmpty()) {
    List<Department> deptList = departmentMapper.selectBatchIds(distinctDeptIds);
    // 现在deptList就是所有不重复的完整部门对象
}

如果部门信息就在 User 实体中(反范式设计),那么你需要的是 GROUP BY 而不是 DISTINCT ,这通常需要用到 groupBy 方法,是另一个话题了。

4.2 场景二:结合分页Page使用Distinct

这是坑点最多的地方。当你使用 MyBatis Plus 的分页插件 Page 时,直接结合 DISTINCT 可能会得到错误的总数统计。

Page<User> page = new Page<>(1, 10);
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.select(User::getDepartmentId);
wrapper.select(“DISTINCT(department_id)”);

IPage<User> userPage = userMapper.selectPage(page, wrapper);
System.out.println(userPage.getTotal()); // 这个总数可能不对!

问题分析 :分页插件计算总数( total )时,会执行一条 SELECT COUNT(1) FROM (你的查询SQL) tmp_count 。当你的查询SQL包含 DISTINCT 时,这个计数逻辑可能不是你想要的不重复计数,而是所有记录数(取决于数据库优化)。更严重的是,如果 DISTINCT 在多字段上, COUNT 查询可能会报语法错误。

解决方案 :对于需要去重分页的场景,建议将 分页操作放在内存中 ,或者编写两条独立的SQL:

  1. 一条用于查询不重复数据的ID或关键字段列表(可能还需要总数)。
  2. 另一条根据获取到的ID列表,进行分页查询完整数据。

或者,直接使用自定义SQL @Select 注解或XML映射文件来精确控制分页计数逻辑。

4.3 场景三:Distinct与聚合函数的搭配

有时我们不仅要去重,还要计数,例如“统计有多少个不同的部门”。

QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.select(“COUNT(DISTINCT department_id) as deptCount”);

List<Map<String, Object>> maps = userMapper.selectMaps(wrapper);
Long distinctDeptCount = (Long) maps.get(0).get(“deptCount”);

注意 :这种 COUNT(DISTINCT column) 的场景,使用 LambdaQueryWrapper 非常不便,因为返回的不是实体对象。此时 强烈推荐使用 selectMaps 配合普通 QueryWrapper ,或者使用 @Select 注解编写自定义SQL。 selectMaps 返回一个 Map 列表,每个 Map 对应一行结果,键是查询语句中的别名( as deptCount ),值就是查询结果。这是处理聚合查询、统计字段的常用技巧。

4.4 性能考量与索引优化

DISTINCT 操作在数据库层面通常需要对选定的字段进行排序或哈希计算以消除重复行,这是一个相对耗时的操作,尤其是在大表上。

5. 常见问题排查与避坑指南

在实际开发中,我遇到了不少关于 DISTINCT 的“坑”,这里集中总结一下。

5.1 问题一:使用Distinct后,返回的对象字段为null

现象 :采用了方案一,查询能正常执行,但返回的实体对象中,除了 select 指定的字段外,其他所有字段都是 null 。

原因 :这不是Bug,而是MyBatis Plus的“特性”。当你在 QueryWrapper 中调用了 select 方法明确指定查询字段后,MyBatis Plus会启用“查询字段优化”模式,生成的SQL语句 只包含你指定的字段 ,而不是 SELECT * 。因此,其他未被选中的字段在结果集中不存在,映射到对象里自然就是 null 。

解决方案 :

  1. 如果只需要去重字段的值 :这是正常情况,直接使用即可。你可以通过 List<Object> 或 List<Map> 来接收只包含所需字段的结果。
  2. 如果需要完整对象 :这说明你的查询逻辑设计可能有问题。你需要的是先获取去重的ID,再用ID去查询完整对象(如4.1节所述),或者考虑使用 GROUP BY 而不是 DISTINCT 。

5.2 问题二:多字段Distinct时,SQL语法错误或结果不对

现象 :尝试使用 wrapper.select(“DISTINCT(col1, col2)”) ,生成的SQL在有些数据库(如MySQL)下执行报错。

原因 : DISTINCT 关键字在SQL中是作用于后面所有列的,其标准语法是 SELECT DISTINCT col1, col2 FROM table 。 DISTINCT(col1, col2) 这种写法在某些数据库(如PostgreSQL)中可能被解释为对 (col1, col2) 这个元组整体去重,语法上是允许的,但在MySQL中不被支持。MyBatis Plus拼接SQL的方式可能因数据库方言而产生差异。

解决方案 :

对于多字段去重,最安全、兼容性最好的写法是: wrapper.select(“DISTINCT col1, col2”) 。注意,这里没有括号。

因此,在方案一中,应该这样写:

wrapper.select(User::getCol1, User::getCol2);
wrapper.select(“DISTINCT col1, col2”); // 注意:去掉了括号

这需要你手动确保字符串中的列名与实体属性名对应。虽然牺牲了一点自动化,但保证了SQL的正确性。

5.3 问题三:Distinct与Order By同时使用时顺序混乱

现象 :查询中同时使用了 DISTINCT 和 orderBy ,但排序结果不符合预期。

原因 : DISTINCT 操作在执行时,数据库可能会先收集所有行,然后进行去重,这个过程中原始的排序可能会被打乱。最终结果的顺序是不确定的,除非你显式地使用 ORDER BY 。

解决方案 : 始终在SQL中显式使用 ORDER BY 子句来保证顺序 。在MyBatis Plus中,确保在调用 wrapper.select(“DISTINCT …”) 之后,再调用 wrapper.orderByAsc/Desc 方法。生成的SQL会先处理 DISTINCT ,然后再应用 ORDER BY ,从而得到有序的去重结果。

5.4 问题四:分页查询总数异常

这个问题在4.2节已经详细阐述。这里再强调一下排查步骤:

  1. 开启MyBatis Plus的SQL日志(配置 mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl )。
  2. 观察执行的两条SQL:一条是查询数据的SQL(含 DISTINCT 和 LIMIT ),另一条是自动生成的 COUNT 查询。
  3. 分析 COUNT 查询的SQL是否正确。如果 COUNT 查询包含了 DISTINCT 导致错误或计数不准,就需要放弃使用 selectPage ,转而手动分页或自定义计数SQL。

6.一个实用的调试技巧

当你对MyBatis Plus生成的SQL不确定时,特别是使用了 DISTINCT 这类复杂片段时,务必开启SQL日志。查看控制台打印出的最终SQL语句,将其复制到数据库客户端中直接执行,验证其语法和结果是否正确。这是定位问题最快、最直接的方法。很多问题(如括号问题、字段名错误、别名冲突)在日志面前都无所遁形。

最后,记住一个核心原则:MyBatis Plus是一个强大的ORM工具,但它不是万能的。当Lambda表达式无法简洁优雅地表达复杂的SQL语义(如 DISTINCT 、复杂的 JOIN 、窗口函数等)时,不要犹豫,使用 @Select 注解或XML映射文件编写自定义SQL。正确的工具用在正确的场景,才能保证代码既清晰又高效。 DISTINCT 这个小小的关键字,正是考验我们是否理解ORM框架边界和数据库原理的一个好例子。

以上就是MyBatis Plus中实现DISTINCT去重的三种方案与实战指南的详细内容,更多关于MyBatisPlus DISTINCT去重的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文