RuoYi框架Service层多查询分页失效手写ManualPagination工具类全解析在企业级应用开发中分页查询是最基础也最频繁使用的功能之一。RuoYi作为国内广泛使用的快速开发框架集成了MyBatis的PageHelper分页插件为开发者提供了便捷的startPage()方法。然而在实际开发中当Service层需要执行多个查询或对结果集进行拼接处理时这个看似完美的分页机制却可能突然罢工导致返回的数据条数与预期严重不符。本文将深入剖析这一问题的根源并给出一个高可用的手动分页解决方案。1. 问题现象与复现当分页遇上多查询让我们从一个典型的业务场景说起假设我们需要开发一个订单管理系统在订单列表中需要展示订单基本信息以及关联的用户名称。由于数据库设计采用了分表策略订单表和用户表分开存储Service层的实现可能会写成这样public TableDataInfo selectOrderList(Order order) { startPage(); // 启用分页 ListOrder orders orderMapper.selectOrderList(order); // 查询订单列表 orders.forEach(o - { User user userMapper.selectUserById(o.getUserId()); // 为每个订单查询用户信息 o.setUserName(user.getName()); }); return getDataTable(orders); }这段代码看起来逻辑清晰但实际运行时会出现两个致命问题分页仅对第一个查询生效startPage()只拦截紧随其后的第一条SQLorderMapper.selectOrderList后续的用户查询完全不受控制总条数统计错误框架统计的是订单查询的总数但实际返回的是订单与用户信息组合后的对象更复杂的情况出现在需要合并多个查询结果时public TableDataInfo searchProducts(ProductQuery query) { startPage(); ListProduct dbProducts productMapper.selectByQuery(query); // 数据库查询 ListProduct esProducts esProductService.search(query); // ElasticSearch查询 ListProduct merged Stream.concat(dbProducts.stream(), esProducts.stream()) .sorted(Comparator.comparing(Product::getScore)) .collect(Collectors.toList()); return getDataTable(merged); }这种情况下分页完全失效因为PageHelper只拦截MyBatis查询内存中的集合合并发生在分页之后总条数无法正确反映合并后的结果集2. 原理解析PageHelper的工作机制为何失效要理解为什么会出现这些问题我们需要深入PageHelper的实现原理。PageHelper本质上是一个MyBatis拦截器Interceptor它通过动态代理机制在SQL执行前后插入分页逻辑[调用startPage()] → [创建Page对象存入ThreadLocal] → [执行Mapper方法] → [拦截器介入] → [改写SQL添加LIMIT] → [执行count查询获取总数] → [返回包装结果]这个设计存在几个关键限制单次拦截一个startPage()调用只对下一个MyBatis查询有效SQL层面分页分页是通过改写SQL语句实现的无法作用于内存中的集合操作线程绑定分页参数存储在ThreadLocal中容易受多线程环境影响当Service层需要执行多个MyBatis查询合并不同数据源的结果对查询结果进行复杂的内存处理这些场景下PageHelper的自动分页机制就会完全失效。这就是我们需要手动分页解决方案的根本原因。3. 解决方案ManualPagination工具类设计与实现针对上述问题我们设计一个基于内存的手动分页工具类ManualPagination它具有以下特点通用性强适用于任何List类型的数据集兼容性好返回RuoYi标准的TableDataInfo结构性能可控利用Stream API实现高效分页使用简单一行代码完成复杂分页逻辑完整实现代码如下public class ManualPagination { /** * 手动分页处理 * param list 待分页的原始数据集 * param T 数据类型 * return 分页后的TableDataInfo */ public static T TableDataInfo paginate(ListT list) { PageDomain pageDomain TableSupport.buildPageRequest(); int pageNum pageDomain.getPageNum(); int pageSize pageDomain.getPageSize(); TableDataInfo result new TableDataInfo(); result.setCode(HttpStatus.SUCCESS); result.setMsg(查询成功); // 内存分页核心逻辑 ListT pageData list.stream() .skip((pageNum - 1L) * pageSize) .limit(pageSize) .collect(Collectors.toList()); result.setRows(pageData); result.setTotal(list.size()); return result; } /** * 带转换器的分页方法 * param list 原始数据集 * param converter 数据转换函数 * param T 源数据类型 * param R 目标数据类型 * return 分页后的TableDataInfo */ public static T, R TableDataInfo paginate(ListT list, FunctionT, R converter) { ListR converted list.stream().map(converter).collect(Collectors.toList()); return paginate(converted); } }3.1 关键实现解析这个工具类的核心在于正确使用Stream API的skip和limit方法skip((pageNum - 1L) * pageSize)跳过前面所有页的数据定位到当前页的起始位置。注意使用long类型避免整数溢出。limit(pageSize)限制当前页的数据量确保返回正确的条数。异常处理虽然代码中未展示实际使用时应该添加对空列表、越界页码等情况的处理。3.2 使用示例对于前面提到的订单查询问题改造后的代码如下public TableDataInfo selectOrderList(Order order) { ListOrder orders orderMapper.selectOrderList(order); // 先获取全部数据 orders.forEach(o - { User user userMapper.selectUserById(o.getUserId()); o.setUserName(user.getName()); }); return ManualPagination.paginate(orders); // 最后统一分页 }多数据源合并的场景public TableDataInfo searchProducts(ProductQuery query) { ListProduct dbProducts productMapper.selectByQuery(query); ListProduct esProducts esProductService.search(query); ListProduct merged Stream.concat(dbProducts.stream(), esProducts.stream()) .sorted(Comparator.comparing(Product::getScore)) .collect(Collectors.toList()); return ManualPagination.paginate(merged); }4. 性能优化与适用场景虽然内存分页解决了功能问题但我们仍需关注其性能影响。以下是几种典型场景的性能对比场景PageHelper分页手动内存分页适用性建议单表简单查询⚡️ 极快 较慢优先使用PageHelper多表关联查询⚡️ 快 慢根据数据量选择多查询结果合并❌ 不可用✅ 可用必须使用手动分页大数据量(10万)⚡️ 高效 内存风险避免内存分页非SQL数据源❌ 不可用✅ 可用必须使用手动分页4.1 性能优化建议对于可能返回大数据集的场景可以采用以下优化策略分批加载先获取ID列表分页再按需加载详细数据public TableDataInfo getLargeDataSet(Query query) { // 第一步只查询ID并分页 ListLong ids mapper.selectIds(query); TableDataInfo pageInfo ManualPagination.paginate(ids); // 第二步根据分页后的ID加载详细数据 ListDetail details mapper.selectDetailsByIds( ((ListLong)pageInfo.getRows()).stream() .collect(Collectors.toList())); pageInfo.setRows(details); return pageInfo; }异步加载前端先显示基础信息再异步加载附加数据// 前端示例代码 fetch(/api/orders).then(res { showOrders(res.data.rows); // 先显示订单 res.data.rows.forEach(order { fetch(/api/users/${order.userId}).then(user { updateUserName(order.id, user.name); // 异步加载用户名 }); }); });缓存策略对频繁访问的关联数据进行缓存Cacheable(value users, key #userId) public User getUserById(Long userId) { return userMapper.selectById(userId); }5. 高级应用增强版ManualPagination为了满足更复杂的业务需求我们可以对基础工具类进行功能增强5.1 支持动态排序public static T TableDataInfo paginate(ListT list, ComparatorT comparator) { PageDomain pageDomain TableSupport.buildPageRequest(); String orderBy pageDomain.getOrderBy(); if (StringUtils.isNotEmpty(orderBy)) { // 实现动态排序逻辑 ComparatorT dynamicComparator buildComparator(orderBy); list.sort(comparator ! null ? comparator.thenComparing(dynamicComparator) : dynamicComparator); } return paginate(list); }5.2 支持自定义总数计算某些场景下我们可能需要自定义总数计算逻辑如从搜索引擎返回的总数public static T TableDataInfo paginate(ListT list, long total) { TableDataInfo result paginate(list); result.setTotal(total); return result; }5.3 分页与DTO转换结合在分层架构中我们经常需要将DO转换为DTOpublic TableDataInfo getOrderList() { ListOrder orders orderMapper.selectAll(); return ManualPagination.paginate(orders, this::convertToDTO); } private OrderDTO convertToDTO(Order order) { OrderDTO dto new OrderDTO(); // 转换逻辑 return dto; }6. 最佳实践与常见陷阱在实际项目中使用手动分页时需要注意以下几点数据量监控添加日志记录原始数据集大小避免内存溢出log.debug(Paginating {} items, page {} of size {}, list.size(), pageNum, pageSize);线程安全确保分页操作不会修改原始集合// 不安全 ListOrder orders getOrdersFromSomewhere(); ManualPagination.paginate(orders); orders.clear(); // 会影响已分页的数据 // 安全做法 ListOrder orders new ArrayList(getOrdersFromSomewhere()); ManualPagination.paginate(orders);空集合处理始终考虑空集合情况public static T TableDataInfo paginate(ListT list) { if (list null || list.isEmpty()) { TableDataInfo empty new TableDataInfo(); empty.setCode(HttpStatus.SUCCESS); empty.setRows(Collections.emptyList()); empty.setTotal(0); return empty; } // ...原有逻辑 }分页参数校验防止恶意传入超大分页参数public static void validatePageParams(int pageNum, int pageSize) { if (pageNum 1 || pageSize 1 || pageSize MAX_PAGE_SIZE) { throw new IllegalArgumentException(Invalid pagination parameters); } }与前端协作统一分页参数命名规范// 前端请求示例 axios.get(/api/data, { params: { pageNum: 1, pageSize: 10, orderBy: createTime desc } });