资讯详情

MyBatis分页避坑:PageHelper一对多查询总数虚高与解决方案

📅 2026/10/14 17:27:19 | 华诺云谱 👁 阅读
MyBatis分页避坑:PageHelper一对多查询总数虚高与解决方案
折腾MyBatis分页这几年十个人里得有五六个会在“一对多”查询上栽跟头。具体症状很统一开了PageHelper之后明明查的主表数据总条数却莫名其妙多出一大截页面上的数据还一会儿缺明细、一会儿整单重复。最早我遇到这个坑的时候第一反应是某个字段写错了、ResultMap配对配错了后来排查到分页SQL那一层才发现问题出在PageHelper对SQL的自动改造上。今天就把这个问题拆开讲清楚顺便把几种我自己踩过坑之后验证过的处理方案都写出来大家照着用就行。这个问题最适合两类人来读一是刚把MyBatis和PageHelper搭起来、正在做联表查询的新手二是项目里已经有存量SQL、知道问题但不想大改代码的同学。前者的重点是理解分页插件和一对多映射为什么天生不对付后者的重点则是几种低成本的SQL改造和代码调整方案。下面的内容我从原理讲到实战方案再到排查清单尽量把容易忽略的细节都覆盖掉。1. 页面上总数多了一截先看看这个坑长什么样先描述一下我遇到过的真实场景。数据库里有张订单主表每笔订单又对应多条订单明细。业务上需要按订单分页展示列表每个订单带出它全部明细。于是很自然地写了类似这样的查询select idselectOrderList resultMapOrderWithItemsResult SELECT o.id, o.order_no, o.amount, i.id AS item_id, i.name AS item_name, i.price FROM orders o LEFT JOIN order_items i ON i.order_id o.id ORDER BY o.create_time DESC /selectResultMap里也用上了collection标签把一对多的明细都塞进Order.items。一切看起来没问题但配合PageHelper执行后就有意思了。比如订单表实际只有10条明细总共只有26条我分页设置startPage(1, 10)结果total却显示26第一页还只返回了7个订单。翻到第二页第一页出现过的订单又出来了而且它的明细只剩一半不到。这种表现基本不用怀疑就是典型的一对多查询把分页和计数全打乱了。我把现象归纳成三类大家对照一下总数虚高PageHelper返回的total不是主表记录数而是Join之后生成的明细行数。页面数据不齐某条主记录在下半页断掉了关联的子记录没能一次性组装完整看起来像“丢数据”。跨页数据重复同一条主记录由于子记录分散在不同PageHelper分页范围内可能在多页重复出现。很多人会把这类问题归类成“PageHelper的Bug”但严格说来这不是Bug而是分页插件运行在SQL层它并不知道MyBatis的ResultMap在Java对象组装时会把多行记录合并成一条主记录。PageHelper看到的是物理行映射层看到的是逻辑主对象两边对“一条数据”的理解不一样数目自然对不上。理解这一点后面所有解决方案的逻辑就都顺了。另外还要提醒一个很容易混淆的情况如果是一对一关联比如订单和客户Join后主键是唯一的一单只对应一行这种行为没有问题。真正出问题的一定是一行主数据对应多行子数据的结构。2. 数字为什么会错PageHelper的“改造SQL”行为与ResultMap的一对多映射冲突要处理这个问题就不能只看表面现象还是得先弄明白PageHelper在执行时到底对SQL做了什么。我最初也是把查询日志打开、一行一行对比SQL之后才恍然大悟的。2.1 PageHelper到底对SQL做了哪些事PageHelper是基于MyBatis拦截器机制实现的分页插件。当程序调用PageHelper.startPage(pageNum, pageSize)之后框架会拦截Executor层的查询方法在SQL真正执行前做两件事生成一条Count查询用于统计总数。把原查询SQL改造成带LIMIT或OFFSET的分页查询。以MySQL为例上面的订单连表明细SQL实际上会被框架改造成如下结构模拟示意-- 自动生成的Count查询 SELECT COUNT(0) FROM orders o LEFT JOIN order_items i ON i.order_id o.id ORDER BY o.create_time DESC -- 自动改造的分页查询 SELECT o.id, o.order_no, o.amount, i.id AS item_id, i.name AS item_name, i.price FROM orders o LEFT JOIN order_items i ON i.order_id o.id ORDER BY o.create_time DESC LIMIT 10看到这里就很清楚了Count查询统计的是Join之后的行数。一个订单有3条明细那这个订单就在结果集里占3行Count累计下来当然不是订单数。分页查询的LIMIT也是直接截断在细化行级别先截掉一部分明细行再交给ResultMap做组装那截断位置产生的对象自然就不完整。也就是说总数和分页结果都同时错了只不过它们错的方式不太一样。2.2 一对多情况下ResultMap怎么组对象这一块是MyBatis底层的组装机制。它会根据ResultMap中配置的主键id来判断某条主记录是否已经创建过对象。每读取一行物理查询结果都会比较主键值如果主键相同就在已有主对象上加子记录如果主键不同就新建一个主对象。这本身是MyBatis为了处理一对多映射而设计的机制。但问题在于分页插件根本不关心你的主键是什么它就是拿到了一个被Join多倍扩展的结果集然后按位置截取10条。如果第5条物理行和第12条物理行都属于同一个订单那么LIMIT 10就会把同一个订单的记录砍成两段放到两个页面上这就出现了跨页重复和明细缺失。2.3 真正的坑点我后来做了一次实验把同一个订单特意设置成拥有20条明细其他订单都只有1到2条明细然后把每页大小设成5。结果第一页只返回了一个带5条明细的订单第二页又出现了同一个订单带上它剩下的15条明细。这个结果很直观地说明了问题PageHelper的分页是对SQL结果行分页不是对主业务对象分页。只要子记录数量不均匀页面就会表现得很随机有时一条主记录占满几页有时一条主记录只有半截。明白这个之后就不再指望通过调分页参数来规避了。真正要做的是让分页作用在主表的行集上让关联加载发生在分页之后。下面几种方案都是围绕这个思路展开的。3. 治本方案拆开分页和关联加载让PageHelper只管主表如果项目的SQL是这条路径这是我最推荐的做法主表单独分页查询拿到分页结果后再按主键批量查关联数据最后在内存中把父子关系拼装回去。听上去多查了一次数据库但对于一对多这种结构来说这种方案不仅数量正确性能往往也是最优的因为它避免了Join造成的大结果集和大量重复传输。3.1 主表单独分页PageHelper只负责主表先写一个简单的主查询不再关联子表resultMap idOrderResult typeOrder id propertyid columnid/ result propertyorderNo columnorder_no/ result propertyamount columnamount/ result propertyitems columnitem_list jdbcTypeOTHER/ /resultMap select idselectOrderPage resultMapOrderResult SELECT id, order_no, amount FROM orders WHERE order_status #{status} ORDER BY create_time DESC /selectitems字段先不用ResultMap里的collection处理后续我们自己在Service层组装。分页调用还是原样PageHelper.startPage(1, 10); ListOrder orderPage orderMapper.selectOrderPage(query);这一步因为查询是从单表orders里直接查没有Join所以PageHelper的Count查询统计的就是订单总数LIMIT也是直接按订单行截取结果一定正确。3.2 按主键批量查询关联数据分页拿到10个订单后把订单ID收集起来去子表里一次查出来。这里用foreach构造IN查询即可select idselectItemsByOrderIds resultTypeOrderItem SELECT id, order_id, name, price FROM order_items WHERE order_id IN foreach collectionorderIds itemorderId open( separator, close) #{orderId} /foreach /select之所以不用逐条查询是因为那样会产生N1次数据库请求在分页条数比较大的时候会很难看。批量IN查询只需要一次请求就可以把所有需要的子记录全部拿回来。3.3 内存中按订单ID分组拼装拿到子记录列表后在内存里按order_id分组再回填到每个订单对象里。这一步的聚合逻辑很固定用Stream分组就可以ListOrder orderPage orderMapper.selectOrderPage(query); // role: 主表分页结果 if (orderPage ! null orderPage.size() 0) { ListLong orderIds orderPage.stream() .map(Order::getId) .collect(Collectors.toList()); ListOrderItem itemList orderItemMapper.selectItemsByOrderIds(orderIds); MapLong, ListOrderItem itemMap itemList.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); orderPage.forEach(order - order.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList()))); }这里要重点提醒一个细节PageHelper.startPage()之后返回的List其实是Page对象。很多同学习惯拼完数据后再去手动new PageInfo(orderPage)来取总数这是没问题的。但注意不要在拼装之后又调一次new PageInfo或者对orderPage做重新new操作好好保留原始分页返回的List对象因为Page对象里已经存好了total、pageNum这些信息。拼装只是往每个元素里填充子列表不改变集合本身这样分页元数据才不丢。从实际运行效果来看分页数量对了每个订单的明细也完整了接口响应数据一样不缺SQL量也稳定可控。这个方案在任何PageHelper版本下都成立因为PageHelper实际上只参与了最没有歧义的那一步主表查询。4. 折中手段沿用现有JOIN自定义COUNT让数字先变对有存量SQL的同学可能不愿意把原来已经写好的Join查询拆成两次。这种情况下还有一个思路保留原有的Join查询但是为查询单独指定一条正确的Count SQL让PageHelper不要自动生成基于Join行的Count而是使用我们手动写的、基于主表去重的Count。4.1 让PageHelper用你写的Count查询PageHelper支持一个比较实用的特性如果为某个查询方法配套定义一个名字带_count后缀的查询分页时会自动优先使用你定义的这个Count查询。比如原查询方法叫selectOrderList那么配套的定义如下select idselectOrderList resultMapOrderWithItemsResult SELECT o.id, o.order_no, o.amount, i.id AS item_id, i.name AS item_name, i.price FROM orders o LEFT JOIN order_items i ON i.order_id o.id WHERE o.order_status #{status} ORDER BY o.create_time DESC /select select idselectOrderList_count resultTypelong SELECT COUNT(DISTINCT o.id) FROM orders o WHERE o.order_status #{status} /select这里有几个可以说出理由的关键点Count查询中不需要Join子表因为统计主表记录数时Join只会造成干扰。用COUNT(DISTINCT o.id)可以进一步防御性保证结果正确。虽然这里SQL本身已经去掉了Join理论上不会重复统计但如果在复杂查询里改得不够干净加DISTINCT是一种低成本的保险。Count查询必须保证与原查询的WHERE过滤条件完全一致否则数量能对上都见鬼了。如果你不想把Count SQL写在XML里也可以在启动分页时自己指定一个Count值。PageHelper的startPage方法有一套支持手动传Count查询方式的重载可以把完整Count SQL作为参数传进去。但实际项目里我很少用这种方式因为字符串拼SQL既难维护又容易出错不如XML里配一个_count查询干净。4.2 只调Count还不够LIMIT还是会截断子记录这必须单独指出来因为经常有同学改了Count查询、看total终于正确了就以为问题解决了。事实上分页数据本身可能仍然是乱的。前面也提到过PageHelper改造的LIMIT会直接在Join结果集上截断这个截断跟ResultMap的主键组装逻辑互相冲突。即使Count写对了如果某个订单明细特别多它照样会被切分到不同页。所以这个方案真正的适用场景是每个主记录下挂的子记录数量本来就比较少且分布比较均匀。例如每条订单最多一两条明细、每个人挂几个标签这种场景下Join结果行数膨胀不严重LIMIT截断造成的影响也不大。这种情况下用_count查询修正总数是够用的。如果你的业务里一个主对象动不动下挂几十条、几百条子记录那就别在这里纠结了老老实实回第3章的拆分方案或者用下一章介绍的懒加载方案。5. 懒加载的collection也不失为一个不错的选择除了拆分查询之外还有一种常见做法是充分利用MyBatis自身的collection映射能力让主查询保持单表查询而子集合通过select属性另行加载。这样PageHelper接触到的SQL还是只有单表数量自然正确。5.1 ResultMap里配置select属性改造之后ResultMap里不再写子查询的Join而是配置一个独立的子表查询方法resultMap idOrderWithItemsResult typeOrder id propertyid columnid/ result propertyorderNo columnorder_no/ result propertyamount columnamount/ collection propertyitems columnid selectselectItemsByOrderId fetchTypelazy/ /resultMap select idselectOrderList resultMapOrderWithItemsResult SELECT id, order_no, amount FROM orders WHERE order_status #{status} ORDER BY create_time DESC /select select idselectItemsByOrderId resultTypeOrderItem SELECT id, order_id, name, price FROM order_items WHERE order_id #{id} /select这样PageHelper分页的就是订单主表Count统计的也是订单主表total一定是对的。collection标签里指定select后MyBatis会在需要填充items集合时把column指定的id值传给selectItemsByOrderId去查询。相当于每行主数据多查一次子表也就是N1查询。5.2 注意性能代价分页场景下其实委屈后冬N1听起来不好听但这里有个现实情况PageHelper一页一般就10条或20条如果一个页面只加载10个订单那N就是10子表查询也就10次数据库压力通常可以接受。加上fetchTypelazy如果列表接口不需要全部子明细还能推迟加载进一步减少无谓查询。不过有两个坑必须提醒**如果开启了fetchTypelazy要留意MyBatis是否在同一个事务里。**懒加载如果在Session关闭后才触发某些配置下会抛出懒加载初始化异常。一般要么保证Service层有事务要么把fetchType设为eager要么显示调用关联字段触发加载。**如果一页数据量调到几百条或者子查询本身比较复杂N1的压力就会上来。**这种时候还是回到第3章的批量IN查询更稳。懒加载方案适合页面条数少、希望改动最小的场景不追求极致性能。6. 要不要手动分页权衡和判断标准写到这里有同学肯定要问既然PageHelper在一对多场景下手脚不干净那我干脆不用PageHelper了自己写分页行不行答案是可以而且有些场景就应该手动分页。但手动分页不是无脑替代它需要你多写几行代码但换来的是对SQL行为的完全掌握。6.1 手动分页到底怎么实施最简单的办法是放弃PageHelper自己拆成Count和分页两步查询select idcountOrders resultTypelong SELECT COUNT(0) FROM orders WHERE order_status #{status} /select select idselectOrderPageManually resultTypeOrder SELECT id, order_no, amount FROM orders WHERE order_status #{status} ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /selectService层自己计算偏移量long total orderMapper.countOrders(query); int offset (pageNum - 1) * pageSize; ListOrder orderList orderMapper.selectOrderPageManually(query, offset, pageSize);这样分页的每一步都完全掌握在自己手里不存在任何自动改造SQL带来的意外。子列表依然按照第3章的批量IN查询或者懒加载方式来填充整个链路非常可控。Count查询和分页查询的WHERE过滤条件需要保持一致这个一致性手动写的时候也要仔细核对。6.2 判断标准速查根据我的经验遇到下面这些情况时手动分页或者拆分查询是更理智的选择主记录下挂的子记录数量悬殊有的1条有的几百条。查询结果还需要做多层嵌套比如订单、订单明细、明细里还有赠品。团队里对PageHelper的自动Count机制不熟悉担心后续有人改SQL导致分页出错。这个查询对性能要求很高不想让Join结果集的中间行数膨胀。反过来如果只是普通的一对一关联、多对一关联或者一对多但子记录数量少且稳定继续用_count查询方案配合PageHelper也完全没问题。技术选型不是越复杂越好而是在正确场景里选正确手段。7. 这些“尸体”拆解的排查实录和避坑清单最后把实际排查过程中积累的经验整理成一张速查表。以后遇到类似问题可以先对照症状判断原因再决定用哪一种方案。症状可能原因处理方向分页总数明显大于主表数据量PageHelper的自动Count统计了Join后的行数自定义Count查询或把主查询改成单表分页每条主记录的明细被切到不同页LIMIT直接截断了Join结果集拆散了同一主键的多行数据拆开分页和关联加载或使用select属性懒加载子集合同一主记录跨页面重复出现分页结果集里同一主键被截断到不同的物理行范围按主表主键分页关联数据单独批量查询有些主记录在页面上明细为空LIMIT截断位置正好落在该主记录的第一条明细之前同上重点是先分页主表再加载子表Count查询在先页数不在结果中开发者手动调用Count查询时也加了startPage导致分页参数被后续查询吃掉把startPage调用放在待分页查询的紧邻语句前避免中间插入其他查询再补充几个我踩过的非主流小坑属于那种文档里不容易写但实战里很气人的情况。第一个是PageHelper.startPage()的作用范围。它只对接下来第一条查询语句生效。如果在startPage之后、真正调用mapper之前中间插了别的数据库查询分页参数极可能会被后面的查询消费掉实际分页效果就完全乱了。我之前在一个复杂的Service里被人插入了一条日志查询结果日志查了分页真正业务查询没分页排查过程相当痛苦。所以记住startPage和待分页Mapper调用之间不要有任何SQL操作。第二个是得到分页结果后不要在后续处理中把返回的List再包一层新的ArrayList或者用PageInfo.of()。某些情况下PageHelper返回的Page里保存的总数、页码信息都跟着原对象走你一旦把元素复制到新集合分页元数据就丢了。正确做法是拿到查询结果后直接使用PageInfo或从原Page对象上取getTotal()然后在这个原列表上做后续数据组装。第三个是如果项目里同时存在多个数据库方言注意PageHelper能够自动识别方言自动生成LIMIT和OFFSET这个机制在大多数情况下是省心的但一旦出现语法兼容问题优先考虑手动分页别在自动生成SQL上死磕投入产出比不划算。8. 说点更实际的体会这几个方案我在不同项目里都实践过。早期为了省事我倾向于保留原生Join查询然后配_count查询去修正总数。后来遇到一个子记录数量极度不均匀的业务场景一个主对象挂几百条子记录总数虽然改对了页面数据还是经常横跳这才下决心把分页和关联加载彻底拆开。从那以后这种一对多分页查询我基本都走“主表分页 IN批量查子表 内存拼装”的模式稳定到可以长期不加注释也不会误操作。但我也要说一句公道话如果你新做的表结构本身就是一对多并且列表页查询条件比较复杂比如有多张关联表的筛选那就不要试图用一条大JOIN把什么都查出来再分页了。无论PageHelper还是其他分页插件都很难替你解决“对象粒度”和“SQL行粒度”不一致的问题。谁延迟加载、谁分批查询、谁在内存拼装这些决策在写SQL前就要想清楚而不是等上线后发现页面数据不对才被动去补。希望这篇文章能帮大家少走一点弯路。下次再遇到PageHelper一对多查询数量不对先停下来想一个问题分页插件聪明到能理解ResultMap的映射规则吗想清楚这一点解决方案基本也就浮出水面了。
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑