1. 问题场景还原:一对多查询的分页数据怎么就对不上了
先说个我踩过的坑。做订单列表的时候,一个订单对应多个商品明细,很自然的就会在MyBatis的resultMap里配一个collection标签,把明细塞进订单对象里。SQL写法也很常见:主表orders JOIN 子表order_items,然后交给PageHelper去分页。
结果页面一翻就出事了:列表总数对不上,比如数据库里订单一共10条,明细有50条,PageHelper返回total可能是50而不是10;更诡异的是第一页能看到订单A,翻到第三页又见到订单A,同一个单子在好几个页面重复出现。
当时我第一反应是怀疑PageHelper的count查询写错了,一路排查下来才发现问题出在一对多join对整个查询结果的影响上。PageHelper本身没坏,坏的是“我们把一个一对多查询直接交给了分页插件”这个写法。
这个问题的出现场景非常典型,只要你做过这几类需求,大概率都会遇到:
- 主表数据是“业务主体”,比如订单、文章、用户、课程;
- 子表数据是“附属明细”,比如订单商品、文章标签、用户收货地址、课程章节;
- 页面展示时又把多个子表记录折叠进一条主表对象中;
- 同时又需要在主表维度做分页。
一句话概括痛点就是:我们想要的是“主表条数”上的分页,SQL执行却是“主表×子表”的行数上的分页。
在展开解决方案之前,先把这篇博文适用的读者范围说清楚:对MyBatis和PageHelper有一定使用经验、但还没深究过分页插件内部机制的后端开发。如果你刚接触MyBatis不久,这篇内容也能帮你绕开一个非常容易踩的坑,至少以后再见到total和页面数量对不上的问题,心里会有个明确的方向。
2. PageHelper的工作机制与问题根因
要彻底搞懂这个bug,得先知道PageHelper在底层干了什么。PageHelper的实现是基于MyBatis的拦截器机制,它拦截Executor的query方法,在真正执行SQL之前干两件事:生成一个count查询用来统计总条数,再把原始SQL改造成带LIMIT的分页语句。
注意关键点:PageHelper拦的是“你传给MyBatis的那条SQL”,它并不关心你的SQL结果会被MyBatis映射成什么样的对象结构。所以拆分来看,两条路是并行发生的:
- PageHelper统计的是“这条SQL执行后返回的记录行数”;
- MyBatis根据resultMap,把返回的每一行记录映射成业务对象。
正常情况下,一行记录映射成一个对象,两边是对得上的。一旦resultMap里出现一对多的collection映射,情况就变了:数据库返回的每一行,可能是同一个主记录的不同子记录。MyBatis在映射阶段会自己判断“如果主键相同,就把后面几行的子记录加到同一个主对象里去”,这是MyBatis的嵌套结果映射机制。
但PageHelper不知道这件事,它的count统计还是按SQL返回的行数来算的。这就是数据对不上的根源。
2.1 count查询到底统计了什么
贴一段伪代码直观感受一下:
SELECT o.id, o.order_no, oi.item_name FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id假设orders表有10个订单,order_items表有50条明细,平均一个订单5个明细,那么上面这条SQL执行完会返回50行。PageHelper生成count SQL时,默认就是把字段列表替换成count(0):
SELECT count(0) FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id这条count查出来的结果是50,不是10。所以你页面上看到的total是50,但业务意义上的“订单总数”明明是10。用户一看总数50,翻页翻到第5页,每一页其实都在展示不同订单的明细折叠,最后翻完了发现就10个单子来回重复,体验极其糟糕。
2.2 分页结果中的主记录重复问题
如果你以为只有total不对,那就低估了这个坑的破坏力。更隐蔽的是列表数据本身也会出问题。
还是上面的SQL,PageHelper会在原始SQL后面拼上一句LIMIT:
SELECT o.id, o.order_no, oi.item_name FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id LIMIT 10LIMIT截取的是JOIN之后的50行里的前10行。假设订单A有5条明细,订单B有5条明细,那么第一页10条数据里只有订单A和订单B这两个订单,但每页都会展示两条完整的订单信息(如果字段一致,看起来就像订单重复)。如果你ORDER BY o.id,第一页、第二页甚至第三页都可能是同一批订单的明细在轮番出现,因为LIMIT截断的是明细行数。
这里有一个常见的误解,有人以为MyBatis的嵌套结果映射会自动去重。注意,它只在resultMap里配置了id列(或唯一标识列)且结果集按主键排序时才能正确折叠。如果集合里某个订单的明细超过了LIMIT截断的边界,这个订单可能只映射了一半明细,甚至由于排序问题,同一主记录可以被折叠成多个对象实例,表现就是数据错乱、总数错乱同时出现。
3. 方案对比分析:先想清楚再动手
很多人遇到这个问题,第一反应是去百度“PageHelper一对多分页错乱怎么办”,然后抄一段代码回来,结果发现换个数据量又出问题。我的建议是先别急着抄,把可选方案在脑里过一遍,明白每个方案的取舍,再根据自己项目的实际情况选。
3.1 方案一:子查询分页,JOIN延后
思路很简单:既然直接JOIN会让结果集膨胀,那就先不加JOIN,把主表分页查出来,再去查子表。SQL层面通常写成IN子查询:
SELECT o.id, o.order_no, oi.item_name FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id WHERE o.id IN ( SELECT id FROM orders WHERE create_time BETWEEN ? AND ? ORDER BY create_time DESC LIMIT 10 )但这里有个大问题:PageHelper的自动count还是会把整个SQL套进去,生成:
SELECT count(0) FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id WHERE o.id IN ( SELECT id FROM orders WHERE create_time BETWEEN ? AND ? ORDER BY create_time DESC LIMIT 10 )外层JOIN循环,总数没有从根本上解决,而且IN子查询在子表数据量大的时候性能也不理想,子查询里的LIMIT还会被一些数据库方言限制。所以这个方案我一般只用在手写SQL、不用PageHelper的场景里。
3.2 方案二:嵌套查询(Nested Select)代替嵌套结果
MyBatis的resultMap里的collection可以有两种写法:一种是嵌套结果映射,就是JOIN + 结果集折叠;另一种是嵌套查询,也就是执行collection对应的独立select语句去查子表。
核心调整是把collection标签改成类似这样:
<resultMap id="OrderWithItemsMap" type="Order"> <id column="id" property="id"/> <result column="order_no" property="orderNo"/> <collection property="items" ofType="OrderItem" select="com.example.mapper.OrderItemMapper.selectByOrderId" column="id"/> </resultMap>主查询的SQL就是纯粹的订单表查询:
SELECT id, order_no, create_time FROM orders WHERE create_time BETWEEN ? AND ? ORDER BY create_time DESC这样PageHelper的count统计的就是订单表本身的条数,总数完全正确,LIMIT也作用在订单行上,分页列表不会出现重复数据。代价是会产生N+1次查询,每个订单都要去查一次明细表,这是典型的“用查询次数换正确性”的取舍。
3.3 方案三:业务层两次查询手工组装
这也是我目前在大多数项目里推荐的方式,算是对方案二的改良。思路是彻底放弃一次SQL查全的念头,拆成两步:
- 第一步:用PageHelper或自己写LIMIT,查主表分页数据,拿到当前页的订单列表;
- 第二步:收集当前页所有订单ID,一次性查这些ID的明细数据:
- 用IN查询一次性查出所有明细;
- 在Service层按orderId分组,循环塞回对应的订单对象中。
代码逻辑大概长这样:
// 1. 分页查主表 PageHelper.startPage(pageNum, pageSize); List<Order> orders = orderMapper.selectPage(query); // 2. 当前页主表ID集合 List<Long> orderIds = orders.stream().map(Order::getId).collect(Collectors.toList()); // 3. 批量查明细(内存中分组) List<OrderItem> items = orderItemMapper.selectByOrderIds(orderIds); Map<Long, List<OrderItem>> itemMap = items.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); // 4. 组装 orders.forEach(order -> order.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList())));好处很明显:两条SQL都简单,索引容易命中;总数正确、分页正确;没有N+1问题。坏处是多写一次查询和一次组装逻辑,但是这不是什么负担,反而让逻辑更直白。唯一要注意的是IN查询里的ID数量如果特别大,注意分批,一般几百个没任何问题。
我实测过一个场景:订单5000条,订单明细20000条,方案三的两条SQL加起来耗时不到50ms,方案一的JOIN写法在分页深翻页时能到几百毫秒,方案二的N+1就更不用提了,5000个订单就算走了缓存也很肉疼。
3.4 方案四:使用DISTINCT或窗口函数控制结果集行数
有些场景下,子表数据不多、不适合拆查询,可以考虑在SQL层面控制结果集的行数。比如使用DISTINCT让主表行在结果集中只出现一次,再配合LIMIT:
SELECT DISTINCT o.id, o.order_no, oi.item_name FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id LIMIT 10但你要清楚,DISTINCT是对整个SELECT出来的行做去重,如果明细内容不同,还是去不掉重复的主记录。而且PageHelper的count统计还是基于JOIN之后的行数,total照样错。
还有一部分数据库支持窗口函数,例如PostgreSQL可以用:
SELECT * FROM ( SELECT o.*, row_number() over (partition by o.id order by o.id) as rn FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id ) t WHERE rn = 1这种方案的思路是先用窗口函数把主表记录在JOIN结果集中打上序号,取第一行代表主表本身。但MyBatis映射时如果不用嵌套结果映射,数据还是会被折叠,而且PageHelper的count还是不能通过“SELECT * FROM(子查询)”直接看出正确总数,除非手动指定countSql,复杂度较高,作为通用方案推进成本不小。
3.5 方案对比总表
| 方案 | 总数准确性 | 列表页重复 | 查询次数 | 推荐指数 |
|---|---|---|---|---|
| 直接JOIN + PageHelper | 错 | 有 | 2次(count+limit) | 不推荐 |
| 子查询分页JOIN延后 | 仍可能错 | 视写法而定 | 2次 | 勉强 |
| collection嵌套查询 | 对 | 无 | N+1 | 谨慎 |
| 业务层两次查询组装 | 对 | 无 | 2次 | 强烈推荐 |
| DISTINCT / 窗口函数 | 上下文相关 | 不确定性高 | 2次 | 特定场景 |
4. 实操记录:我用业务层两次查询组装的全过程
几种方案对比之后,我在实际项目里最终选了业务层两次查询组装,下面把完整过程写出来,供你直接参考。项目背景是某电商后台的订单分页列表,每页50条,一个订单最多可能有20个商品明细。
4.1 编写实体与Mapper
实体定义简化后长这样:
public class Order { private Long id; private String orderNo; private Integer status; private List<OrderItem> items; } public class OrderItem { private Long id; private Long orderId; private String itemName; private BigDecimal price; }订单Mapper只需要负责分页查主表:
<select id="selectPage" resultType="Order"> SELECT id, order_no, status FROM orders <where> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY id DESC </select>这里故意用resultType而不是resultMap,因为不涉及子表映射,避免引入不必要的复杂度。
明细Mapper写一个批量查询方法:
<select id="selectByOrderIds" resultType="OrderItem"> SELECT id, order_id, item_name, price FROM order_items WHERE order_id IN <foreach collection="orderIds" item="orderId" open="(" separator="," close=")"> #{orderId} </foreach> ORDER BY order_id, id </select>注意一个细节:selectByOrderIds里的IN条件要注意两个问题。一是orderIds为空时直接返回空集合,避免SQL语法错误;二是在MySQL等数据库上,IN列表过长可能引起性能下降,一般超过500个ID就分批查,代码里写个小循环即可。
4.2 Service层组装逻辑
@Override public PageResult<Order> pageOrders(OrderQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<Order> orders = orderMapper.selectPage(query); List<Long> orderIds = orders.stream().map(Order::getId).collect(Collectors.toList()); if (orderIds.isEmpty()) { return new PageResult<>(orders, new PageInfo<>(orders).getTotal()); } List<OrderItem> items = orderItemMapper.selectByOrderIds(orderIds); Map<Long, List<OrderItem>> itemMap = items.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); orders.forEach(order -> order.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList()))); return new PageResult<>(orders, ((Page<Order>) orders).getTotal()); }需要注意PageInfo和Page的取舍:PageHelper.startPage之后,查询返回的List本身就是一个Page对象,可以直接从Page对象中拿total。如果后续对这个List做了stream操作,转换后的List不再是Page,所以要在组装之前先把total取出来,避免组装完成后丢失分页信息。
4.3 一坑:PageInfo的引用
有个细节容易踩:很多人习惯把orders直接丢给new PageInfo<>(orders)来拿total。如果orders已经是Page类型,这样没问题;如果orders是stream转换后的普通List,那PageInfo就只能拿到List.size(),total就变成当前页大小,分页信息又丢了。所以最稳的做法是尽早取出total,或者干脆在方法开头就把分页信息存到局部变量。
4.4 一坑:IN查询驱动结果顺序
由于明细是批量IN查询返回的,它的返回顺序不一定跟订单列表一致。组装的时候用的是HashMap按orderId分组,所以顺序天然不会乱。但如果明细需要有序展示(比如按SKU编号排序),记得在明细SQL里加ORDER BY,别依赖无排序时的默认顺序。
4.5 一次真实压测结果
最后放一个我本地压测的数据:orders表10000条,order_items表50000条,每页50条。方案三的两次查询总耗时稳定在38ms到55ms之间,count查询大概10ms,列表查询大概20ms,批量明细查询大概15ms,组装逻辑在1ms以内。对比原先直接JOIN的方式,deep page翻到第100页时耗时120ms以上,且结果完全错乱。
这组数据说明一点:方案三没有牺牲性能去换正确性,反而因为查询结构简单,数据库可以更好地命中索引。对绝大多数后台管理端的分页场景,这个方案完全够用。
5. 常见问题与排查技巧实录
问题本身的修法已经给了,但实践里围绕这个场景还有不少衍生问题,我把真实遇到的排查过程整理成速查表,方便你对照检查。
5.1 排查顺序建议
如果你正在被一对多分页问题折磨,按这个顺序排查:
- 第一步,直接打印MyBatis日志中的SQL,看count的SQL长什么样,确认它统计的是哪些表;
- 第二步,把count SQL单独在数据库里跑一遍,看total是否等于业务意义上的主表总数;
- 第三步,确认列表SQL的LIMIT加在哪个位置,如果结果集里同一个主记录持久重复,那就是JOIN放大了行数;
- 第四步,看resultMap折叠是否依赖了排序后的主键连续性,如果没有按主键排序,即使去掉分页也可能出现映射异常。
5.2 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| total明显大于预期且是明细行数级别 | JOIN导致count SQL统计了子表行数 | 换成业务层两次查询,或改用嵌套查询 |
| 列表出现重复主记录 | LIMIT截断了JOIN后的明细行 | 主表分页后再查明细,避免LIMIT作用在JOIN结果集 |
| total正确但列表少几条 | collection子查询非预期过滤或关联列失效 | 检查collection的column参数,确认关联字段 |
| 翻页后某条记录显示不完整 | 主记录被LIMIT截断,明细未完全映射 | 用两次查询避免明细被截断 |
| count查询中包含ORDER BY导致性能差 | PageHelper默认count会保留ORDER BY | 手动指定countSql,或者升级版本看是否自动处理 |
5.3 trample经验:嵌套查询并非银弹
方案二(collection嵌套查询)虽然避免了一对多行数膨胀,但如果主表数据量几千条,明细查询也走数据库,N+1问题会让接口变得很慢。我早期做过一个用户列表接口,每个用户要显示最近一条订单信息,用collection嵌套查询硬生生把接口从30ms拖到了2秒。后来改成SQL层通过窗口函数取最近一条订单,或者用方案三汇总查询,性能才回来。
这个经验说明一个道理:没有万能的框架配置,只有适不适合当前数据规模和业务形态的取舍。
5.4 关于PageHelper版本的差异
遇到过有些老项目用的PageHelper 4.x,新项目用5.x。在countSQL的处理上,新版对于排序子查询、distinct等场景的兼容性更强,但一对多场景下的问题并没有本质区别。如果你升级PageHelper后发现问题自动消失,多半是旧版本某些写法在count生成上有bug,而不是一对多问题被修复了。升级前先做好回归。
6. 写在最后的个人经验
做后端时间久了你会发现,PageHelper这类插件最坑人的地方不是它本身有bug,而是它把分页的复杂度隐藏得太深,深到我们容易忘记它背后是在拿SQL结果集的行数做文章。一对多分页错乱,本质是“SQL结果集行数”和“业务对象条数”在概念上起了冲突。
所以我现在的编码习惯是:在遇到一对多展示需求时,第一反应不是去调resultMap和SQL,而是先问自己一句,业务上的分页主体是谁?主体是订单,就先分页订单;主体是用户,就先分页用户。子表信息永远是附属,附属信息就该用“批量查一次再组装”的方式补上。思路摆正了,代码自然就顺了。
最后分享一个小技巧:如果你的主表查询很复杂、分页条件多,用PageHelper的时候记得手动指定countSql,把count SQL写精确,这一步能帮你解决大量隐藏的count性能问题和总数不准问题。哪怕你还没遇到一对多问题,手动控制count永远比依赖自动生成要稳。