MyBatis-Plus分组统计分页查询实战:LambdaQueryWrapper与聚合函数的高效结合

MyBatis-Plus分组统计分页查询实战:LambdaQueryWrapper与聚合函数的高效结合
1. 项目概述当统计查询遇上MyBatis-Plus的优雅封装在后台管理、数据报表这类业务场景里我们经常遇到一个经典需求对数据库中的记录按某个维度比如部门、商品类别、日期进行分组然后计算每个组的汇总值如总销售额、总访问量最后还得把这些统计结果漂亮地分页展示出来并且支持按汇总值大小排序。听起来是不是很熟悉直接用SQL写一个GROUP BY配合SUM再加个LIMIT和ORDER BY就能搞定。但当我们回到Java世界使用MyBatis-Plus这样优秀的ORM框架时问题就变得有点“微妙”了。MyBatis-Plus的LambdaQueryWrapper以其类型安全、链式调用的优雅特性成为了我们构建查询条件的首选。然而当你想用它来实现“分组统计并分页排序”时可能会瞬间懵住LambdaQueryWrapper的groupBy方法明明存在但select部分怎么聚合Page对象的分页查询page()方法它底层生成的SQL真的能兼容GROUP BY吗排序字段是数据库表的原始字段还是聚合后的别名这一连串的问题正是本篇文章要彻底厘清和解决的。简单来说我们要做的就是在MyBatis-Plus框架下使用LambdaQueryWrapper安全、正确且高效地实现一个包含GROUP BY、聚合函数如SUM、分页Page以及排序ORDER BY的完整数据统计查询。这不仅仅是API的调用更是对MyBatis-Plus分页原理和SQL组装逻辑的一次深度实践。2. 核心思路与方案选型为什么不能直接用page()方法在动手写代码之前我们必须先理解为什么“LambdaQueryWrapperPage”的常规组合在分组统计场景下会失灵。这关系到MyBatis-Plus分页插件的核心工作原理。2.1 MyBatis-Plus分页插件的机制与局限MyBatis-Plus的分页插件PaginationInterceptor或其后续版本在执行分页查询时实际上会执行两条SQL查询总记录数它会基于你提供的QueryWrapper智能有时是“武断”地生成一条SELECT COUNT(1) FROM ...的语句用于计算满足当前查询条件的总记录数这是分页计算页码的基础。查询分页数据在总记录数查询完成后会再生成一条带有LIMIT或数据库方言对应的分页语法如Oracle的ROWNUM的SQL语句来获取当前页的数据。问题的根源就出在“查询总记录数”这一步。当你使用wrapper.groupBy(...)时生成的SQL会包含GROUP BY子句。分页插件在生成COUNT语句时默认行为是简单地在你的查询语句外面套一层SELECT COUNT(1) FROM (你的原始查询) tmp_count。对于简单的查询这没问题但对于包含GROUP BY的查询这个COUNT查询的结果不是你分组前的总记录数而是分组后的组数。这会导致分页的总条数计算错误进而引发页码混乱、数据重复或缺失等一系列诡异问题。注意这是一个非常关键的踩坑点。很多开发者发现分组分页查询结果不对首先怀疑的是分页SQL本身其实问题往往出在自动生成的COUNT查询上。2.2 可行的技术方案对比既然默认方案行不通我们有哪些选择呢方案A自定义SELECT语句与手动统计总条数思路放弃使用LambdaQueryWrapper的默认select()方法它主要用于选择表字段直接在wrapper中使用select(“SUM(column) as total, group_column”)这样的自定义SQL片段。同时完全禁用分页插件自动生成的COUNT查询自己另写一条查询来获取正确的总组数。优点灵活性最高可以完全控制SELECT和COUNT的逻辑适用于最复杂的统计场景。缺点代码稍显繁琐需要手动维护两条查询的关联性确保WHERE条件一致失去了部分LambdaQueryWrapper的类型安全优势。方案B使用Select注解编写完整XML/注解SQL思路在Mapper接口的方法上直接使用Select注解编写完整的SQL语句包括GROUP BY、SUM、ORDER BY和分页参数如#{page.offset},#{page.size}。总条数查询也单独用一个方法写明。优点SQL清晰直观易于调试和优化是处理复杂SQL的终极方案。缺点脱离了Wrapper的链式调用风格需要手动拼接WHERE条件时会比较麻烦。方案C对查询进行“两层封装”思路第一层查询先使用LambdaQueryWrapper进行分组和聚合但不分页获取所有分组结果或使用COUNT(DISTINCT group_column)快速计算组数。在内存或第二层简单查询中再进行分页和排序。优点逻辑简单易于理解。缺点性能灾难。如果分组前的数据量巨大第一层查询会加载大量数据到内存极易导致OOM和数据库性能瓶颈。绝不推荐用于生产环境。结论对于追求代码优雅与类型安全同时需要处理分组统计分页的场景方案A是最佳实践。它最大限度地保留了MyBatis-Plus的便利性同时通过关键性的手动干预规避了框架的局限性。下文将围绕方案A展开详细实现。3. 核心实现步骤详解我们以一个具体的业务场景为例有一张订单表t_order包含字段id,product_category产品类别,amount订单金额,create_time。现在需要统计每个产品类别的总销售金额并按总金额从高到低排序最后进行分页展示。3.1 环境准备与实体定义首先确保你的项目已引入MyBatis-Plus依赖并配置好了分页插件这里以Spring Boot为例。// 1. 实体类 Order.java Data TableName(t_order) public class Order { private Long id; private String productCategory; // 产品类别 private BigDecimal amount; // 订单金额 private LocalDateTime createTime; } // 2. Mapper接口 OrderMapper.java public interface OrderMapper extends BaseMapperOrder { // 我们将在Service层使用Wrapper这里无需额外定义方法 } // 3. 分页配置 (MyBatisPlusConfig.java) Configuration public class MyBatisPlusConfig { /** * 新版拦截器配置方式 (MyBatis-Plus 3.4.0) */ Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件 PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); paginationInnerInterceptor.setMaxLimit(1000L); // 设置单页最大记录数 paginationInnerInterceptor.setOverflow(true); // 超出最大页后回到首页 interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }3.2 构建分组统计查询的LambdaQueryWrapper这是核心步骤我们需要构建一个既能指定分组和聚合字段又能携带查询条件的Wrapper。// 在Service层的方法中 public PageMapString, Object getCategoryStats(PageMapString, Object page, String startDate, String endDate) { // 1. 创建查询包装器 LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); // 2. 添加查询条件 (例如按时间范围筛选) if (StringUtils.hasText(startDate) StringUtils.hasText(endDate)) { wrapper.between(Order::getCreateTime, startDate, endDate); } // 3. 【关键】自定义SELECT语句进行分组和聚合 wrapper.select( product_category as category, // 分组字段并起别名 SUM(amount) as totalAmount // 聚合函数并起别名 ); // 4. 指定分组字段 wrapper.groupBy(Order::getProductCategory); // 或 wrapper.groupBy(product_category); // 5. 指定排序规则 (按聚合结果totalAmount降序) wrapper.orderByDesc(totalAmount); // 注意这里排序用的是SELECT中的别名 // ... 分页查询将在下一步进行 }关键点解析wrapper.select(String... sqlSelect)这个方法允许我们传入自定义的SQL片段。这里我们不仅选择了字段还使用了SUM(amount) as totalAmount这样的聚合函数和别名。别名至关重要它是后续排序和结果集映射的凭据。wrapper.groupBy(SFunctionT, ? column)使用Lambda方法引用指定分组字段保证了类型安全。wrapper.orderByDesc(String column)排序时必须使用SELECT子句中定义的别名totalAmount而不是实体类的属性名amount。因为排序发生在分组聚合之后此时amount字段已不存在存在的是聚合后的totalAmount。3.3 执行分页查询与处理COUNT问题现在到了最关键的环节执行分页查询。我们必须处理错误的自动COUNT查询。public PageMapString, Object getCategoryStats(PageMapString, Object page, String startDate, String endDate) { // ... 接上面的代码构建好wrapper后 // 6. 【核心】创建Page对象并设置为不进行COUNT查询 // Page的泛型使用Map因为返回的不是Order实体而是包含category和totalAmount的映射 page.setSearchCount(false); // 禁用自动COUNT查询 // 7. 执行分页查询此时只查询分页数据 PageMapString, Object resultPage orderMapper.selectMapsPage(page, wrapper); // 8. 【核心】手动查询正确的总组数 // 我们需要一个只做COUNT的查询但COUNT的是分组后的组数不是原始行数。 // 方法查询去重后的分组字段数量或者使用子查询。 Long totalGroupCount getTotalGroupCount(wrapper); resultPage.setTotal(totalGroupCount); // 重新计算总页数 resultPage.setPages((totalGroupCount page.getSize() - 1) / page.getSize()); return resultPage; } /** * 手动计算分组后的总组数 */ private Long getTotalGroupCount(LambdaQueryWrapperOrder wrapper) { // 复制一个Wrapper只用于COUNT查询避免影响原来的SELECT LambdaQueryWrapperOrder countWrapper new LambdaQueryWrapper(); countWrapper.apply(wrapper.getCustomSqlSegment()); // 复制自定义的WHERE条件片段 // 关键COUNT查询的是去重后的分组字段数量 // SQL示例: SELECT COUNT(DISTINCT product_category) FROM t_order WHERE ... // 在MyBatis-Plus中我们可以这样构造 countWrapper.select(COUNT(DISTINCT product_category)); // 执行查询返回一个Map列表虽然只有一条记录 ListMapString, Object countList orderMapper.selectMaps(countWrapper); if (countList ! null !countList.isEmpty()) { Object count countList.get(0).values().iterator().next(); return ((Number) count).longValue(); } return 0L; }实操心得page.setSearchCount(false)这是本方案的灵魂。它告诉MyBatis-Plus“别帮我查总数我自己来”。必须设置。selectMapsPage因为我们select的是自定义字段和聚合函数返回的不是Order实体对象而是MapString, Object。这个方法正合适。手动COUNT的逻辑COUNT(DISTINCT group_column)是计算总组数最高效的方式。我们通过apply(wrapper.getCustomSqlSegment())巧妙复用了原始Wrapper的WHERE条件保证了数据范围的一致性。计算总页数在手动设置total后MyBatis-Plus的Page对象不会自动重新计算总页数pages需要我们根据公式总页数 (总记录数 每页大小 - 1) / 每页大小手动设置。3.4 结果处理与前端对接查询返回的resultPage对象包含了当前页的数据resultPage.getRecords()和我们已经手动设置好的分页信息total,pages,current,size。// 在Controller中 GetMapping(/stats/category) public RPageMapString, Object getCategoryStats( RequestParam(defaultValue 1) Long current, RequestParam(defaultValue 10) Long size, RequestParam(required false) String startDate, RequestParam(required false) String endDate) { // 构建分页对象注意当前页从1开始 PageMapString, Object page new Page(current, size); PageMapString, Object result orderService.getCategoryStats(page, startDate, endDate); return R.ok(result); }返回给前端的数据结构示例{ code: 200, msg: success, data: { records: [ {category: 电子产品, totalAmount: 50000.00}, {category: 服装, totalAmount: 30000.00}, // ... 当前页其他数据 ], total: 25, // 总组数 size: 10, current: 1, pages: 3 // 总页数 } }4. 高级技巧与性能优化掌握了基础实现后我们来看看如何让它更健壮、更高效。4.1 复杂聚合与多字段分组业务需求不会总是SUM一个字段。你可能需要AVG平均、COUNT计数、MAX/MIN最大/最小或者按多个字段分组。// 多聚合函数示例统计每个类别订单数、总金额、平均金额、最大金额 wrapper.select( product_category as category, COUNT(*) as orderCount, SUM(amount) as totalAmount, AVG(amount) as avgAmount, MAX(amount) as maxAmount ); wrapper.groupBy(Order::getProductCategory); // 多字段分组示例按类别和年份分组 wrapper.select( product_category as category, YEAR(create_time) as year, // 使用数据库函数 SUM(amount) as totalAmount ); wrapper.groupBy(Order::getProductCategory, “YEAR(create_time)”); // 分组字段需与SELECT对应 // 对应的COUNT查询也要调整COUNT(DISTINCT CONCAT(product_category, ‘-’, YEAR(create_time)))注意事项当使用数据库函数如YEAR(),DATE_FORMAT()参与分组时COUNT查询中的DISTINCT部分也需要使用相同的函数表达式否则计数会不准确。CONCAT函数是一种常见的解决方案用于生成复合分组键。4.2 查询条件复用与Wrapper构建工厂在手动COUNT查询中我们复制了WHERE条件。为了确保条件100%一致可以将构建核心WHERE条件的逻辑抽取出来。private LambdaQueryWrapperOrder buildBaseQueryWrapper(String startDate, String endDate) { LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(startDate) StringUtils.hasText(endDate)) { wrapper.between(Order::getCreateTime, startDate, endDate); } // 可以添加其他公共条件如状态过滤 wrapper.eq(Order::getStatus, 1); return wrapper; } public PageMapString, Object getCategoryStats(PageMapString, Object page, String startDate, String endDate) { // 构建基础条件Wrapper LambdaQueryWrapperOrder baseWrapper buildBaseQueryWrapper(startDate, endDate); // 构建统计查询Wrapper LambdaQueryWrapperOrder queryWrapper baseWrapper.clone(); // 克隆一份 queryWrapper.select(...).groupBy(...).orderByDesc(...); // 构建COUNT查询Wrapper LambdaQueryWrapperOrder countWrapper baseWrapper.clone(); countWrapper.select(COUNT(DISTINCT product_category)); // ... 后续执行查询 }使用clone()方法或重新应用条件能有效避免条件遗漏或不一致的风险。4.3 使用自定义ResultMap或VO接收结果一直使用Map接收结果虽然灵活但失去了类型安全和IDE的智能提示。我们可以定义专门的统计结果VOValue Object。Data public class CategoryStatsVO { private String category; private BigDecimal totalAmount; private Long orderCount; // ... 其他聚合字段 }但是MyBatis-Plus的selectMapsPage方法无法直接映射到自定义VO。有两种解决方案查询后手动转换将ListMap遍历并转换成ListCategoryStatsVO。简单直接适用于简单场景。使用MyBatis的Select注解或XML映射回归方案B在Mapper中定义方法编写完整SQL并指定resultType或resultMap。这是最规范的方式尤其适合复杂查询。// 在OrderMapper中 Select(SELECT product_category as category, SUM(amount) as totalAmount FROM t_order ${ew.customSqlSegment} GROUP BY product_category ORDER BY totalAmount DESC LIMIT #{page.offset}, #{page.size}) ListCategoryStatsVO selectCategoryStatsPage(Param(page) PageCategoryStatsVO page, Param(ew) LambdaQueryWrapperOrder wrapper); // 对应的COUNT查询也需要单独定义 Select(SELECT COUNT(DISTINCT product_category) FROM t_order ${ew.customSqlSegment}) Long selectCategoryStatsCount(Param(ew) LambdaQueryWrapperOrder wrapper);这种方式结合了Wrapper的条件构建便利性和自定义SQL的灵活精准是大型项目的推荐做法。5. 常见问题排查与实战技巧在实际开发中你肯定会遇到各种奇怪的问题。这里记录了几个典型的“坑”和解决方法。5.1 问题一分页总条数显示不正确或者数据有重复/缺失症状前端分页组件显示的总页数不对点击下一页时数据似乎乱了套可能看到重复的数据或漏掉一些数据。根因没有禁用自动COUNT查询page.setSearchCount(false)或者手动计算总组数的逻辑有误比如错误地COUNT(*)而不是COUNT(DISTINCT group_column)。排查开启MyBatis-Plus的SQL日志mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl。观察控制台打印的SQL。你会看到两条一条是查询数据的一条是查询总数的。重点看那条COUNT语句它是不是在查询分组后的组数核对你的手动COUNT查询生成的SQL是否正确。解决确保严格执行了“禁用自动COUNT 手动精确COUNT”的步骤。5.2 问题二排序ORDER BY不生效或报错症状查询结果没有按预想的顺序排列或者直接抛出“Unknown column ‘xxx’ in ‘order clause’”的SQL异常。根因排序字段名写错在orderByDesc或orderByAsc中使用了实体类属性名如Order::getAmount而不是SELECT中定义的别名如“totalAmount”。聚合后的字段必须用别名排序。聚合字段别名冲突或包含特殊字符别名如果和表字段重名或者包含空格、引号可能导致解析错误。建议使用简单的、无空格的别名。解决仔细检查wrapper.select()中的别名定义确保orderBy系列方法中使用的字符串与别名完全一致。5.3 问题三查询性能缓慢特别是数据量大时症状统计分页查询耗时很长数据库服务器压力大。根因没有为分组和排序字段建立索引GROUP BY和ORDER BY操作如果没有索引会导致全表扫描和文件排序Using filesort性能极差。手动COUNT查询效率低COUNT(DISTINCT ...)在数据量大时可能较慢尤其是分组字段是字符串时。查询条件字段无索引WHERE条件中的过滤字段没有索引导致需要扫描大量无关数据才能分组。优化建议索引是王道为product_category,create_time以及经常用于WHERE条件的字段建立合适的索引。对于(product_category, create_time)这样的联合索引对分组和范围查询都可能有奇效。考虑汇总表如果实时性要求不高可以建立定时任务将分组统计结果预先计算好存入一张“统计汇总表”。前端查询直接查这张小表性能会有数量级的提升。审视COUNT必要性如果前端是“无限滚动”加载或者总页数不那么重要可以考虑不执行COUNT查询或者使用一个估算值。5.4 问题四MyBatis-Plus版本差异导致的API变化症状代码在同事的电脑上运行正常在你的环境却报错提示找不到某个方法。说明MyBatis-Plus版本迭代较快一些API会有变动。例如PaginationInterceptor在3.4.x之后被标记为过时推荐使用MybatisPlusInterceptorPaginationInnerInterceptor。早期版本的selectMapsPage方法签名可能略有不同。建议团队内部统一MyBatis-Plus的版本号。查阅官方文档时注意对应你使用的版本。遇到API问题首先检查依赖版本和官方文档的更新日志。5.5 一个实用的调试技巧打印最终SQL在复杂Wrapper构建过程中你可能会不确定最终生成的SQL是什么样。MyBatis-Plus提供了获取Wrapper对应SQL的方法这在调试时非常有用。LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); // ... 构建wrapper String sqlSegment wrapper.getSqlSegment(); // 获取WHERE条件部分不包含SELECT ListObject paramList wrapper.getParamNameValuePairs().values().stream().collect(Collectors.toList()); // 注意getSqlSegment()获取的SQL片段是带占位符(?)的参数在paramList中。 // 更直接的方式是开启SQL日志查看完整语句。通过系统性地理解MyBatis-Plus分页机制、掌握“自定义SELECT手动COUNT”的核心模式并灵活运用Wrapper的条件构建、克隆和复用你就能游刃有余地处理各类分组统计分页需求。这套方案在保持代码整洁度的同时赋予了开发者对复杂SQL的精准控制力是MyBatis-Plus进阶使用的必备技能。