资讯详情

MyBatis一对多分页数据错乱:PageHelper总数不对、记录重复的根治方案

📅 2026/10/14 17:27:19 | 华诺云谱 👁 阅读
MyBatis一对多分页数据错乱:PageHelper总数不对、记录重复的根治方案
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不对那就低估了这个坑的破坏力。更隐蔽的是列表数据本身也会出问题。还是上面的SQLPageHelper会在原始SQL后面拼上一句LIMITSELECT 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 idOrderWithItemsMap typeOrder id columnid propertyid/ result columnorder_no propertyorderNo/ collection propertyitems ofTypeOrderItem selectcom.example.mapper.OrderItemMapper.selectByOrderId columnid/ /resultMap主查询的SQL就是纯粹的订单表查询SELECT id, order_no, create_time FROM orders WHERE create_time BETWEEN ? AND ? ORDER BY create_time DESC这样PageHelper的count统计的就是订单表本身的条数总数完全正确LIMIT也作用在订单行上分页列表不会出现重复数据。代价是会产生N1次查询每个订单都要去查一次明细表这是典型的“用查询次数换正确性”的取舍。3.3 方案三业务层两次查询手工组装这也是我目前在大多数项目里推荐的方式算是对方案二的改良。思路是彻底放弃一次SQL查全的念头拆成两步第一步用PageHelper或自己写LIMIT查主表分页数据拿到当前页的订单列表第二步收集当前页所有订单ID一次性查这些ID的明细数据用IN查询一次性查出所有明细在Service层按orderId分组循环塞回对应的订单对象中。代码逻辑大概长这样// 1. 分页查主表 PageHelper.startPage(pageNum, pageSize); ListOrder orders orderMapper.selectPage(query); // 2. 当前页主表ID集合 ListLong orderIds orders.stream().map(Order::getId).collect(Collectors.toList()); // 3. 批量查明细内存中分组 ListOrderItem items orderItemMapper.selectByOrderIds(orderIds); MapLong, ListOrderItem itemMap items.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); // 4. 组装 orders.forEach(order - order.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList())));好处很明显两条SQL都简单索引容易命中总数正确、分页正确没有N1问题。坏处是多写一次查询和一次组装逻辑但是这不是什么负担反而让逻辑更直白。唯一要注意的是IN查询里的ID数量如果特别大注意分批一般几百个没任何问题。我实测过一个场景订单5000条订单明细20000条方案三的两条SQL加起来耗时不到50ms方案一的JOIN写法在分页深翻页时能到几百毫秒方案二的N1就更不用提了5000个订单就算走了缓存也很肉疼。3.4 方案四使用DISTINCT或窗口函数控制结果集行数有些场景下子表数据不多、不适合拆查询可以考虑在SQL层面控制结果集的行数。比如使用DISTINCT让主表行在结果集中只出现一次再配合LIMITSELECT 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次countlimit不推荐子查询分页JOIN延后仍可能错视写法而定2次勉强collection嵌套查询对无N1谨慎业务层两次查询组装对无2次强烈推荐DISTINCT / 窗口函数上下文相关不确定性高2次特定场景4. 实操记录我用业务层两次查询组装的全过程几种方案对比之后我在实际项目里最终选了业务层两次查询组装下面把完整过程写出来供你直接参考。项目背景是某电商后台的订单分页列表每页50条一个订单最多可能有20个商品明细。4.1 编写实体与Mapper实体定义简化后长这样public class Order { private Long id; private String orderNo; private Integer status; private ListOrderItem items; } public class OrderItem { private Long id; private Long orderId; private String itemName; private BigDecimal price; }订单Mapper只需要负责分页查主表select idselectPage resultTypeOrder SELECT id, order_no, status FROM orders where if teststatus ! null AND status #{status} /if /where ORDER BY id DESC /select这里故意用resultType而不是resultMap因为不涉及子表映射避免引入不必要的复杂度。明细Mapper写一个批量查询方法select idselectByOrderIds resultTypeOrderItem SELECT id, order_id, item_name, price FROM order_items WHERE order_id IN foreach collectionorderIds itemorderId open( separator, close) #{orderId} /foreach ORDER BY order_id, id /select注意一个细节selectByOrderIds里的IN条件要注意两个问题。一是orderIds为空时直接返回空集合避免SQL语法错误二是在MySQL等数据库上IN列表过长可能引起性能下降一般超过500个ID就分批查代码里写个小循环即可。4.2 Service层组装逻辑Override public PageResultOrder pageOrders(OrderQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListOrder orders orderMapper.selectPage(query); ListLong orderIds orders.stream().map(Order::getId).collect(Collectors.toList()); if (orderIds.isEmpty()) { return new PageResult(orders, new PageInfo(orders).getTotal()); } ListOrderItem items orderItemMapper.selectByOrderIds(orderIds); MapLong, ListOrderItem itemMap items.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); orders.forEach(order - order.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList()))); return new PageResult(orders, ((PageOrder) 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嵌套查询虽然避免了一对多行数膨胀但如果主表数据量几千条明细查询也走数据库N1问题会让接口变得很慢。我早期做过一个用户列表接口每个用户要显示最近一条订单信息用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永远比依赖自动生成要稳。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑