MyBatis-Plus聚合查询分页排序实战:LambdaQueryWrapper的边界与解决方案 1. 项目概述当统计查询遇上分页排序在后台管理、数据报表这类业务场景里我们经常遇到一个看似简单却有点“拧巴”的需求先对数据进行分组统计比如按部门汇总销售额然后还要对这个统计结果进行分页和排序。乍一听这似乎是SQL的GROUP BY加上SUM再配上LIMIT和ORDER BY就能搞定的事。但当你真正在像MyBatis-Plus这样的ORM框架里试图用LambdaQueryWrapper优雅地实现它时可能会发现路有点走不通——或者更准确地说直接的路被堵上了需要绕点弯子。我自己就曾在做一个运营数据看板时踩过这个坑。需求很明确统计每个商品类目在指定时间段的订单总金额和总数量并且要按照总金额从高到低排序最后还要支持前端分页展示。本能地我抄起LambdaQueryWrapper开始链式调用.groupBy()、.select()里加上sum然后.orderByDesc()最后交给Page对象。结果呢要么是分页总数不对要么是排序根本没生效在聚合结果上查出来的数据一团糟。这让我意识到LambdaQueryWrapper虽然强大但它设计初衷是用于构建面向单表实体或明确映射的视图的查询条件当查询的“目标”从原始记录变为一个聚合后的结果集时它的某些特性就和我们的需求产生了错位。所以今天我们就来彻底拆解这个需求如何使用MyBatis-Plus的LambdaQueryWrapper或其思想配合group by和聚合函数进行统计并实现真正有效的分页与排序。这不是一个简单的API调用教程而是一次对ORM框架能力边界和解决方案的探索。你会发现最终的方案可能会稍稍跳出LambdaQueryWrapper的舒适区但理解其背后的“为什么”能让你在今后的开发中更加游刃有余。2. 核心思路拆解为什么不能一条Wrapper走到底在直接上代码之前我们必须先理清阻碍所在。理解了这个你才能明白后续方案的设计逻辑而不是死记硬背步骤。2.1 LambdaQueryWrapper的设计边界与聚合查询的冲突LambdaQueryWrapper的本质是一个查询条件包装器它通过Lambda表达式提供类型安全的字段引用最终目的是生成一个WHERE子句。它的.select()方法可以定制查询字段包括聚合函数.groupBy()方法也确实能生成GROUP BY子句。那么问题出在哪里核心冲突在于“查询主体”的转变和分页插件的处理机制。查询主体的模糊性当我们使用wrapper.select(Order::getCategoryId, “sum(amount) as totalAmount”)并groupBy(Order::getCategoryId)时SQL语句看起来是对的。但MyBatis-Plus的Mapper方法如selectList和分页插件PaginationInterceptor或其后继者默认期望查询和返回的是Order实体对象的列表。然而这条SQL执行后返回的每一行并不是一个完整的Order对象而是一个包含categoryId和totalAmount的“混合体”。虽然MyBatis-Plus能处理这种非实体映射的结果返回Map列表但这与它强类型、实体化的核心设计有出入尤其在配合分页时容易出问题。分页总数的错误计算这是最大的坑。MyBatis-Plus的分页插件为了实现分页会进行两次查询第一次查询COUNT(1)。插件会自动将你的原始SQL改写成一条计算总数的SQL。对于普通的查询它会聪明地去掉ORDER BY然后套上SELECT COUNT(1) FROM (… )。但是当你的原始SQL包含GROUP BY时这个自动生成的COUNT查询就可能出错。因为它生成的可能是SELECT COUNT(1) FROM (你的带GROUP BY的完整SQL) tmp。数据库执行这个子查询时返回的是分组后的行数。然而我们需要的总数是分组前的记录数吗不我们需要的总数是分组后的结果集的行数。插件很难自动判断你的业务意图导致page.getTotal()这个值完全不对进而使前端分页控件失灵。排序的时机问题在SQL中ORDER BY是对最终结果集进行排序。如果你的ORDER BY是针对聚合字段如totalAmount它必须放在GROUP BY之后。在LambdaQueryWrapper中直接调用.orderByDesc(Order::getAmount)生成的SQL可能是ORDER BY order.amount这指的是原始表中的金额字段而不是聚合后的sum(amount)。你需要一种方式来引用聚合字段的别名进行排序。注意这里的关键是认识到LambdaQueryWrapper是一个优秀的“条件构造器”但它不是“查询构造器”的全部。对于复杂的、特别是结果集结构已发生变化的查询我们需要更灵活地控制整个SQL的生命周期。2.2 可行的技术方案选型面对上述冲突我们有几种常见的解决思路每种都有其适用场景方案A使用原生SQL或XML映射文件思路放弃在Java代码中动态构建复杂SQL直接编写完整的SQL语句在Mapper接口中定义方法通过Select注解或XML文件映射。优点绝对的控制力可以精确编写任何复杂的SQL包括子查询、窗口函数等。分页插件对原生Select语句的支持相对较好尤其是简单的COUNT覆盖。缺点失去了Lambda表达式的类型安全性和动态构建的灵活性。SQL字符串不利于维护和重构。方案B使用MyBatis-Plus的queryWrapper自定义SELECT和FROM子句部分版本支持思路利用QueryWrapper的select方法直接传入自定义的SQL片段甚至可以结合from方法指定查询的表或子查询。这比纯Lambda方式更灵活。优点保留了动态构建的能力可以直接控制SELECT列表。缺点对聚合字段的排序引用依然麻烦分页总数问题可能依然存在。代码可读性会下降。方案C进行两次查询推荐且稳健的实践思路这是我最常用也最推荐在复杂聚合分页场景下使用的方法。其核心是将问题拆解查询1获取满足条件的分组统计结果列表不带分页。使用LambdaQueryWrapper或自定义SQL完成分组、聚合、过滤和排序。这一步得到所有分组后的数据。查询2手动计算分页。在内存中或通过数据库对查询1的结果再做一次计数查询计算出总记录数即分组数。然后对查询1的结果列表手动进行分片List.subList来模拟分页。查询3可选如果排序复杂在查询1中完成。优点逻辑清晰完全规避了分页插件的黑盒问题。对排序有绝对控制权。适用于数据量不是极端巨大的场景例如分组后的结果在万级以内。缺点需要手动处理分页逻辑如果分组前数据量巨大且条件复杂查询1可能较慢。不是真正的数据库端分页。方案D使用数据库特性如窗口函数或视图思路对于高级数据库如PostgreSQL, MySQL 8.0可以使用窗口函数COUNT(*) OVER()在聚合查询中同时获取总行数实现高效的真分页。或者将复杂的聚合查询定义为数据库视图然后像查询普通表一样对视图进行分页。优点性能高是真正的数据库端分页。缺点依赖特定数据库版本语法不通用。视图需要额外的数据库维护。对于大多数Java Web应用特别是使用MySQL 5.7等版本的情况方案C两次查询在复杂度、可控性和性能之间取得了最好的平衡。接下来我们就以这个方案为例进行详细实操。3. 核心细节解析与实操要点我们以一个具体的案例贯穿始终统计订单表中每个商品分类category_id的总销售额sum(amount)和订单数count(1)并按照总销售额降序排列最后支持分页查看。实体类Order简化如下Data TableName(“t_order”) public class Order { private Long id; private Long categoryId; // 商品分类ID private BigDecimal amount; // 订单金额 private Integer status; private LocalDateTime createTime; }我们需要一个CategorySummaryDTO来承载统计结果Data public class CategorySummaryDTO { private Long categoryId; private BigDecimal totalAmount; // 总销售额 private Integer orderCount; // 订单数 // 可能还有分类名称等需要联查或后续填充 }3.1 统计查询的Wrapper构建技巧即使我们决定采用“两次查询”方案第一次的聚合查询仍然可以用LambdaQueryWrapper来动态构建条件这比拼接SQL字符串要安全和优雅。// 示例构建基础聚合查询的Wrapper LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); // 1. 指定查询字段原始字段和聚合函数 wrapper.select( Order::getCategoryId, // 分组字段 SqlUtils.sqlAggregateFunc(“SUM({0})”, Order::getAmount, “total_amount”), // 聚合函数起别名 SqlUtils.sqlAggregateFunc(“COUNT({0})”, “1”, “order_count”) // 计数使用字符串”1” // 注意MyBatis-Plus的SqlUtils工具类可以辅助构建但这里用字符串更直观 ); // 更直接的方式是使用selectSql方法传入SQL片段字符串 wrapper.selectSql( “category_id, SUM(amount) as total_amount, COUNT(1) as order_count” ); // 2. 添加分组条件 wrapper.groupBy(Order::getCategoryId); // 或 wrapper.groupBy(“category_id”); // 3. 添加基础过滤条件WHERE子句 wrapper.eq(Order::getStatus, 1); // 只统计已支付的订单 wrapper.ge(Order::getCreateTime, startTime); wrapper.le(Order::getCreateTime, endTime); // 4. 关键点排序如何实现 // 我们不能直接 Order::getAmount因为那是原始字段。 // 我们需要对聚合结果的别名进行排序。但Lambda表达式无法直接引用别名。 // 一种方法是使用orderBy(boolean condition, boolean isAsc, R column) // 但column需要是实体字段或数据库列名。这里我们只能用数据库列名别名。 wrapper.orderByDesc(“total_amount”); // 直接使用聚合字段的别名进行排序 // 或者更严谨地使用orderBySql方法 wrapper.orderBySql(“total_amount DESC”); // 5. 注意我们这里暂时不设置分页参数limit因为第一次查询要获取全部结果。实操心得wrapper.selectSql()比链式调用多个select方法更清晰尤其是在字段多的时候。聚合字段的别名如total_amount一定要和后续接收结果的DTO字段名或TableField注解的value值对应起来如果使用实体接收的话。更常见的做法是让Mapper方法返回ListMapString, Object然后手动转换到DTO这样最灵活。排序时直接使用聚合字段的别名字符串是有效的因为最终它会被拼接到SQL的ORDER BY子句中。这是绕过Lambda表达式限制的一个实用技巧。3.2 分页总数的手动计算策略这是整个方案的核心。我们不能依赖分页插件自动生成的COUNT查询。我们需要自己计算分组后的记录数。有两种计算方式方式一基于聚合查询结果再计数推荐利用数据库既然我们已经有了一个完整的聚合查询带WHERE条件那么分组后的行数其实就是对这个查询结果再求一次COUNT。我们可以构建一条COUNT(DISTINCT …)或者子查询。// 专门用于计算分组后总数的Wrapper LambdaQueryWrapperOrder countWrapper new LambdaQueryWrapper(); countWrapper.eq(Order::getStatus, 1) .ge(Order::getCreateTime, startTime) .le(Order::getCreateTime, endTime); // 计算不重复的 category_id 数量这就是分组后的行数 Long totalGroups orderMapper.selectCount(countWrapper.select(Order::getCategoryId).groupBy(Order::getCategoryId)); // 注意selectCount方法会执行 SELECT COUNT(1) FROM ( … )但里面的GROUP BY可能会影响结果。 // 更稳妥的方式是使用自定义查询 // Long totalGroups orderMapper.selectGroupCount(countWrapper); // 需要自定义Mapper方法实际上更直接清晰的方式是写一个专门的Mapper方法执行一条SQL!– OrderMapper.xml – select id“countGroups” resultType“java.lang.Long” SELECT COUNT(DISTINCT category_id) FROM t_order WHERE status 1 AND create_time BETWEEN #{startTime} AND #{endTime} /select然后在Java中调用Long totalGroups orderMapper.countGroups(startTime, endTime);。这种方式语义最明确性能也好。方式二查询全部统计结果后内存计数简单适用于数据量小先执行不带LIMIT的聚合查询获取全部结果的List然后通过list.size()得到总数。接着再手动对这个List进行分页。这种方法在分组结果集很小比如几百上千条时最简单但如果分组结果上万条一次性加载到内存可能会有压力。选择建议如果业务对性能敏感分组数量可能很大务必采用方式一数据库计数。方式二仅适用于管理后台等数据量可控的场景。3.3 排序在分页中的正确应用排序必须在分页之前完成而且是针对整个聚合结果集的排序。在我们的方案中排序是整合在第一次聚合查询查询1里的。这意味着我们是先对所有符合条件的数据进行分组、聚合、排序得到一个有序的完整列表然后再从这个有序列表中截取当前页的数据。关键点排序条件ORDER BY total_amount DESC是聚合查询的一部分它保证了我们截取到的每一页数据都是基于全局排序的正确片段。如果排序在分页之后进行那将毫无意义。4. 实操过程与核心环节实现现在我们将上面的思路整合成一个完整的Service方法。这里采用“方式一数据库计数 内存分页”的混合模式即先查询总数再执行完整的排序后聚合查询最后在内存中分页。这避免了执行两次几乎相同的复杂聚合查询一次取所有数据一次取分页数据是一种折中但高效的方案。4.1 完整的Service层实现代码Service RequiredArgsConstructor public class OrderStatisticsService { private final OrderMapper orderMapper; /** * 分页查询分类销售统计 * param pageParam 分页参数当前页、每页大小 * param queryParam 查询条件时间范围、状态等 * return 分页结果包含统计列表和总数 */ public PageResultCategorySummaryDTO getCategorySummaryPage(PageParam pageParam, StatisticsQueryParam queryParam) { // 1. 参数校验与准备 if (pageParam.getPageNo() 1) pageParam.setPageNo(1); if (pageParam.getPageSize() 100) pageParam.setPageSize(100); // 防止过大分页 // 2. 构建基础查询条件Wrapper (用于计数和聚合查询) LambdaQueryWrapperOrder baseWrapper buildBaseQueryWrapper(queryParam); // 3. 计算分组后的总记录数核心步骤 Long total countDistinctGroups(baseWrapper, queryParam); // 4. 如果总数大于0才执行聚合查询 ListCategorySummaryDTO records Collections.emptyList(); if (total 0) { // 5. 构建完整的聚合排序查询Wrapper LambdaQueryWrapperOrder queryWrapper buildAggregationQueryWrapper(queryParam); // 执行查询获取所有已排序的聚合结果 ListMapString, Object mapList orderMapper.selectMaps(queryWrapper); // 将Map转换为DTO records convertMapListToDTO(mapList); // 6. 手动内存分页 records applyMemoryPagination(records, pageParam); } // 7. 构造返回结果 return new PageResult( records, total, pageParam.getPageNo(), pageParam.getPageSize() ); } /** * 构建基础查询条件Wrapper仅包含WHERE条件 */ private LambdaQueryWrapperOrder buildBaseQueryWrapper(StatisticsQueryParam param) { LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(param.getStatus() ! null, Order::getStatus, param.getStatus()) .ge(param.getStartTime() ! null, Order::getCreateTime, param.getStartTime()) .le(param.getEndTime() ! null, Order::getCreateTime, param.getEndTime()); // 可以添加其他条件如店铺ID等 // wrapper.eq(…); return wrapper; } /** * 计算不重复的分组数量使用自定义Mapper方法 */ private Long countDistinctGroups(LambdaQueryWrapperOrder baseWrapper, StatisticsQueryParam param) { // 方法1使用自定义SQL推荐清晰高效 // return orderMapper.countDistinctCategory(param.getStartTime(), param.getEndTime(), param.getStatus()); // 方法2基于Wrapper构建计数查询稍复杂 // 这里演示一种利用现有Wrapper的思路实际上计算DISTINCT category_id的COUNT可以直接用SQL。 // 为了演示我们假设在OrderMapper.xml中有一个对应的方法。 return orderMapper.selectGroupCount(baseWrapper); } /** * 构建包含SELECT、GROUP BY、ORDER BY的聚合查询Wrapper */ private LambdaQueryWrapperOrder buildAggregationQueryWrapper(StatisticsQueryParam param) { LambdaQueryWrapperOrder wrapper buildBaseQueryWrapper(param); // 复用WHERE条件 // 关键自定义SELECT和ORDER BY wrapper.selectSql( “category_id,” “SUM(amount) as total_amount,” “COUNT(1) as order_count” // 可以添加其他聚合如 AVG(amount) as avg_amount ); wrapper.groupBy(Order::getCategoryId); // 或 wrapper.groupBy(“category_id”); // 按总销售额降序排列 wrapper.orderByDesc(“total_amount”); // 如果需要多级排序例如销售额相同按订单数排序 // wrapper.orderByDesc(“total_amount”).orderByDesc(“order_count”); // 注意这里没有.limit()因为我们要先拿到全部排序好的结果。 return wrapper; } /** * 将selectMaps返回的结果转换为DTO列表 */ private ListCategorySummaryDTO convertMapListToDTO(ListMapString, Object mapList) { if (CollectionUtils.isEmpty(mapList)) { return Collections.emptyList(); } ListCategorySummaryDTO dtoList new ArrayList(mapList.size()); for (MapString, Object map : mapList) { CategorySummaryDTO dto new CategorySummaryDTO(); // 注意Map中的key是数据库列的别名大小写可能取决于数据库配置通常是小写或大写。 dto.setCategoryId(((Number) map.get(“category_id”)).longValue()); dto.setTotalAmount(new BigDecimal(map.get(“total_amount”).toString())); dto.setOrderCount(((Number) map.get(“order_count”)).intValue()); dtoList.add(dto); } return dtoList; } /** * 在内存中应用分页逻辑 */ private ListCategorySummaryDTO applyMemoryPagination(ListCategorySummaryDTO allRecords, PageParam pageParam) { int pageNo pageParam.getPageNo(); int pageSize pageParam.getPageSize(); int startIndex (pageNo - 1) * pageSize; int endIndex Math.min(startIndex pageSize, allRecords.size()); if (startIndex allRecords.size()) { return Collections.emptyList(); // 请求的页码超出范围 } return allRecords.subList(startIndex, endIndex); } } // 辅助类定义 Data class PageParam { private Integer pageNo 1; private Integer pageSize 10; } Data class StatisticsQueryParam { private LocalDateTime startTime; private LocalDateTime endTime; private Integer status; } Data class PageResultT { private ListT records; private Long total; private Integer pageNo; private Integer pageSize; // 可以计算总页数等 public Integer getTotalPages() { return (int) Math.ceil((double) total / pageSize); } }4.2 Mapper层与XML的配合对于自定义的计数查询需要在OrderMapper接口和XML文件中定义。// OrderMapper.java public interface OrderMapper extends BaseMapperOrder { /** * 计算满足条件的唯一分类数量用于分页总数 */ Long selectGroupCount(Param(“ew”) LambdaQueryWrapperOrder wrapper); }!– OrderMapper.xml – ?xml version“1.0” encoding“UTF-8” ? !DOCTYPE mapper PUBLIC “-//mybatis.org//DTD Mapper 3.0//EN” “http://mybatis.org/dtd/mybatis-3-mapper.dtd” mapper namespace“com.example.mapper.OrderMapper” !– 其他基础CRUD方法由MyBatis-Plus自动生成 – select id“selectGroupCount” resultType“java.lang.Long” SELECT COUNT(DISTINCT category_id) FROM t_order where if test“ew ! null” !– 使用MyBatis-Plus的Wrapper条件片段 – ${ew.customSqlSegment} /if /where /select /mapper重要提示${ew.customSqlSegment}会直接拼接WHERE后面的条件字符串。请确保你的wrapper中只包含了WHERE条件如eq,ge,le而不包含SELECT、GROUP BY、ORDER BY等子句。这就是为什么我们在buildBaseQueryWrapper方法中只构建了条件部分。如果Wrapper包含了groupBy这个计数SQL就会出错。5. 常见问题与排查技巧实录在实际操作中你肯定会遇到各种奇怪的问题。下面是我踩过的一些坑和对应的解决方案。5.1 问题一分页总数total永远等于每页大小pageSize或不对现象前端显示总页数不对或者只有一页数据。排查首先检查你是否错误地使用了MyBatis-Plus的Page对象和selectPage方法。在这个场景下直接使用它们99%会导致总数计算错误。检查你的“计算总数”的逻辑。如果你是用selectCount(wrapper.groupBy(…))返回的可能是分组后每个组的行数通常是1然后又被COUNT了一次结果自然不对。或者插件生成的COUNT语句嵌套了GROUP BY。最可靠的验证方法打开SQL日志查看实际执行的两条或更多SQL语句。你会看到分页插件生成的COUNT语句长什么样。如果它把你的复杂聚合SQL包在了子查询里那总数肯定不对。解决坚定不移地采用手动计算分组数的方案。要么用COUNT(DISTINCT group_column)要么对聚合结果再做一次COUNT。务必通过SQL日志确认你手动计算的SQL是正确的。5.2 问题二排序ORDER BY没有生效现象查询结果没有按照指定的聚合字段排序。排查检查wrapper.orderByDesc(“total_amount”)中的字段名。这个字段名必须是SELECT子句中定义的别名且大小写敏感。最好与数据库返回的列名完全一致。可以通过打印最终SQL或查看日志来确认。确认排序语句是否被意外覆盖。例如在buildAggregationQueryWrapper之后是否又调用了其他可能设置ORDER BY的方法。如果排序字段是字符串类型的数字如VARCHAR类型的ID数据库会按字符串规则排序”10” “2”。这时需要转换类型例如在MySQL中可以用ORDER BY CAST(total_amount AS DECIMAL) DESC但这在LambdaQueryWrapper中难以直接实现可能需要orderBySql。解决使用wrapper.orderBySql(“total_amount DESC, order_count DESC”)来获得最直接的控制。打印出queryWrapper.getCustomSqlSegment()或最终执行的SQL直接在数据库客户端运行看排序是否生效。5.3 问题三性能问题查询速度慢现象当原始数据量很大百万级以上时聚合查询即使有索引也可能很慢。排查与优化索引检查确保WHERE条件中的字段如status,create_time和GROUP BY字段category_id上有合适的索引。一个复合索引(status, create_time, category_id, amount)可能会对这类查询有奇效覆盖索引。数据量评估我们的方案是先获取所有分组排序后的结果再内存分页。如果分组后的类别数量本身非常大比如超过1万那么一次性加载所有结果到JVM内存再进行排序和分片对内存和数据库都是压力。SQL优化检查聚合查询的EXPLAIN执行计划。解决极限优化如果性能是首要考虑且数据库版本支持如MySQL 8.0可以考虑使用窗口函数进行真正的数据库端分页。例如SELECT * FROM ( SELECT category_id, SUM(amount) as total_amount, COUNT(1) as order_count, ROW_NUMBER() OVER (ORDER BY SUM(amount) DESC) as rn FROM t_order WHERE status 1 AND create_time BETWEEN ? AND ? GROUP BY category_id ) t WHERE rn BETWEEN ? AND ?这需要完全使用原生SQL或复杂的QueryWrapper自定义SELECT但它是性能最好的解决方案。折中方案如果类别数很多可以退而求其次放弃“全局精确排序分页”采用“近似分页”。例如只查询前N个热门分类ORDER BY … LIMIT 1000然后在这1000条里做内存分页。或者引入缓存缓存聚合结果。5.4 问题四接收结果类型转换异常现象selectMaps返回的Map里的BigDecimal变成了Double或Integer导致转换错误。排查不同数据库驱动对数字类型的处理方式不同。MySQL的DECIMAL类型可能被JDBC驱动以BigDecimal或Double形式返回。解决在convertMapListToDTO方法中进行稳健的类型转换。private CategorySummaryDTO convertMapToDTO(MapString, Object map) { CategorySummaryDTO dto new CategorySummaryDTO(); Object catId map.get(“category_id”); if (catId ! null) { dto.setCategoryId(((Number) catId).longValue()); } Object totalAmt map.get(“total_amount”); if (totalAmt ! null) { if (totalAmt instanceof BigDecimal) { dto.setTotalAmount((BigDecimal) totalAmt); } else if (totalAmt instanceof Double) { dto.setTotalAmount(BigDecimal.valueOf((Double) totalAmt)); } else if (totalAmt instanceof Integer) { dto.setTotalAmount(new BigDecimal((Integer) totalAmt)); } else { // 尝试字符串解析 dto.setTotalAmount(new BigDecimal(totalAmt.toString())); } } // … 类似处理其他字段 return dto; }5.5 一个实用的调试技巧打印最终SQL在开发阶段将MyBatis的SQL日志级别设为DEBUG是必须的。但有时你想在代码中直接看到Wrapper生成的SQL片段可以这样做LambdaQueryWrapperOrder wrapper …; String sqlSegment wrapper.getSqlSegment(); // 获取WHERE条件部分 String customSqlSegment wrapper.getCustomSqlSegment(); // 获取自定义表达式部分如果有 // 或者获取更完整的SQL表示不包含参数值 String sqlSelect wrapper.getSqlSelect(); // 获取SELECT部分 // 最直接的方式是注入一个SqlInjector但更简单的是看日志。其实最省心的办法就是在application.yml中配置logging: level: com.example.mapper: debug # 你的Mapper包路径这样每条执行的SQL及其参数都会清晰地打印在控制台方便你直接拷贝到数据库工具中调试。最后我想说的是LambdaQueryWrapper是一个强大的工具但它并非银弹。在复杂的统计查询面前认清它的边界选择合适的工具原生SQL、自定义XML、甚至混合使用才是资深开发者的做法。本文提供的“手动分页”方案虽然多了一次计数查询和内存分片但换来了绝对的清晰度和可控性在大多数中小型业务场景下是完全够用且稳健的。当你下次再遇到“分组统计并分页排序”的需求时希望这套组合拳能让你从容应对。