做后台管理系统尤其是订单、账单、评价这类主子表查询的时候几乎每个用MyBatis的人都会撞上一个怪问题一条SQL把订单和订单明细一起查出来collection映射也写得没错数据看着都在偏偏分页页码和总条数完全对不上。更邪门的是明明一页只该显示10个订单页面上却经常冒出十几二十行翻到最后一页只出来零碎几条。这个问题的本质不在“有没有分页”而在“先分页还是先关联”。MyBatis里一对多关联查询返回分页数据之所以能难住一批人是因为SQL裸结果集的“行”和业务对象里的“对象树”天生就错了一层。这篇文章我不讲虚的直接复盘我在这类场景里用过的两套写法以及各自踩过的坑。1. 一对多直接分页先看清那个最常见的翻车位1.1 一次典型的JOIN collection翻车现场某后台系统要展示订单列表每行订单需要带出明细条数和商品名称。我最初没想太多直接写了一条SQLSELECT o.*, d.* FROM orders o LEFT JOIN order_detail d ON d.order_id o.id WHERE o.deleted 0 ORDER BY o.create_time DESC, o.id DESC然后在Mapper里配了resultMap用 把明细折叠到OrderVO的detailList属性里。数据确实能查出来但页面总条数完全不是订单表的总数。orders表一共500条记录页面上却显示总条数1371前几页订单重复出现最后一页甚至只有半页内容。第一反应是MyBatis映射写错了。但把SQL复制到数据库管理工具里跑一遍发现返回的就是被明细行撑大的结果集。问题根本不在映射而是JOIN之后SQL返回的每一行是“订单一条明细”的组合不是一个订单对象。分页插件对“查总数”和“取一页数据”都是基于SQL结果集的行数来做的 再怎么会折叠也只能折叠那些已经拿到手的行没法把总数重新修正回500。1.2 为什么总数会膨胀SQL行与业务对象不是一回事用一个具体的数字说明。假设主表有2个订单订单101有3条明细订单102有1条明细。业务上分页pageSize2预期结果是第一页返回101、102两个订单。但如果直接JOINSQL先返回4行行号订单id明细id1101d12101d23101d34102d4此时如果数据库执行LIMIT 0, 2物理行取的是前两行也就是订单101的两条明细折叠后第一页只有订单101一个对象。如果LIMIT写在JOIN之后分页操作的粒度已经从“订单”变成了“订单明细的物理行”。想要拿到业务上的两个订单LIMIT的offset就完全不等于订单的页码。更麻烦的是count。大部分MySQL分页插件拦截到这条JOIN SQL后会包一层countSELECT count(0) FROM ( SELECT o.*, d.* FROM orders o LEFT JOIN order_detail d ON d.order_id o.id ... ) tmp这层count统计的也是JOIN后的物理行数也就是1371。总数膨胀的直接原因就是这个 折叠发生在内存中而count发生在数据库里两者统计的压根不是同一个东西。1.3 分页错乱的另外两个隐藏症状除了总数膨胀还有两个症状经常被归咎于“网络慢”或“数据错了”实际上都是JOIN分页引起的。第一明细数量不均匀导致同一笔订单跨页。如果一个订单有20条明细它会把下一页的订单挤到更后面。上一页边界可能落在某个订单的第13条明细上下一页又有同一笔订单的第14条明细用户翻页时就会觉得“这笔单据怎么出现了好几次”。第二排序不稳定导致每页数据刷新后不一致。JOIN后结果集如果没有带主键兜底排序MySQL的物理顺序在数据变更后可能改变。同一笔订单今天在第一页明天出现在第二页排查起来非常痛苦。所以做一对多分页之前先记住一个判断标准分页的对象必须是主表那一行而不是JOIN之后的物理行。谁先被执行谁才应该承担分页职责。2. 方案一主表分页 从表IN查询 服务层手动合并2.1 核心思路与步骤既然JOIN会撑大结果集最简单可靠的办法就是把关联拆开先查主表分页再查从表然后在Service层做合并。这样分页插件影响的是orders这种真实业务行总数精确到订单数明细查询完全不影响分页逻辑。具体步骤就三步按条件查订单主表得到当前页的订单列表这一步正常分页可以用PageHelper也可以手写LIMIT。从当前页订单列表里提取所有订单ID带着这批ID去查order_detail表只查一次IN。在Service层把明细按照订单ID分组挂到对应OrderVO的detailList属性上。这个方案牺牲了一次查询换来了完全可控的分页结果也是我在大多数管理后台里最常用的写法。2.2 Mapper层代码怎么拆主表查询的XML保持简单就是一个标准的订单列表查询select idselectOrderPage resultTypecom.demo.v3.OrderVO SELECT o.id, o.order_no, o.total_amount, o.create_time FROM orders o where o.deleted 0 if testorderNo ! null and orderNo ! AND o.order_no LIKE CONCAT(%, #{orderNo}, %) /if /where ORDER BY o.create_time DESC, o.id DESC /select在代码里可以直接用PageHelper.startPage(pageNum, pageSize)来做分页也可以手写LIMIT参数。这里主表查询本身没有子查询也没有JOIN分页插件统计总数就是精确的订单条数。从表查询的XML针对性很强select idlistByOrderIds resultTypecom.demo.v3.OrderDetailVO SELECT d.id AS detailId, d.order_id AS orderId, d.product_name AS productName, d.quantity AS quantity FROM order_detail d WHERE d.deleted 0 AND d.order_id IN foreach collectionorderIds itemorderId open( separator, close) #{orderId} /foreach ORDER BY d.order_id, d.id /select注意从表查询里要把order_id一起查出来这是Service层做分组的依据。如果省略了这个字段后面合并时根本不知道明细属于哪个订单。2.3 Service层合并的两个关键细节合并代码看起来很直白但有两个细节我踩过坑。PageOrderVO page orderMapper.selectOrderPage(query, pageNum, pageSize); ListOrderVO records page.getRecords(); ListLong orderIds records.stream() .map(OrderVO::getId) .collect(Collectors.toList()); if (CollectionUtils.isEmpty(orderIds)) { return page; } ListOrderDetailVO detailList orderDetailMapper.listByOrderIds(orderIds); MapLong, ListOrderDetailVO detailMap detailList.stream() .collect(Collectors.groupingBy(OrderDetailVO::getOrderId)); records.forEach(order - order.setDetailList( detailMap.getOrDefault(order.getId(), Collections.emptyList()) ));第一个细节是groupingBy之后顺序问题。Collectors.groupingBy默认返回HashMap不保证按明细原始顺序排列。如果业务上要求明细按创建时间或ID排序最稳的做法是在XML里已经把ORDER BY写清楚然后用带序的分组方式MapLong, ListOrderDetailVO detailMap detailList.stream() .collect(Collectors.groupingBy( OrderDetailVO::getOrderId, LinkedHashMap::new, Collectors.toList() ));第二个细节是空集合处理。主表有订单但明细被逻辑删除或者这个订单确实没有明细时getOrDefault的第二个参数要传空集合不要传null。很多前端组件对null的列表属性直接报错渲染时还得层层判空。这里提前给个空集合后续代码会顺很多。这个方案的优点非常明确SQL简单、分页精确、Mapper职责单一。缺点是比JOIN多一次数据库查询但如果主表分页后每页只有10到50条记录IN查询的代价几乎可以忽略。3. 方案二子查询先切分页再join一张SQL里的取舍3.1 标准写法和resultMap如果你对SQL执念比较深希望一次查询返回完整结果集另一条路线是把分页提前到JOIN之前的子查询里。SELECT p.id, p.order_no, p.total_amount, d.id AS detail_id, d.product_name AS product_name, d.quantity AS quantity FROM ( SELECT id, order_no, total_amount FROM orders WHERE deleted 0 ORDER BY create_time DESC, id DESC LIMIT #{offset}, #{pageSize} ) p LEFT JOIN order_detail d ON d.order_id p.id ORDER BY p.id DESC, d.id ASC这里的关键在于LIMIT放在内层子查询中子查询只返回当前页的订单主键外层JOIN明细时无论每笔订单有多少明细都不会影响已经限定好的订单范围。比如pageSize10子查询永远只返回10个订单ID外层JOIN后即使每个订单有几十条明细最多也只是把这10个订单的明细全部拼出来分页边界不受影响。对应的resultMap仍然要配置collectionresultMap idorderWithDetailMap typecom.demo.v3.OrderVO id propertyid columnid/ result propertyorderNo columnorder_no/ result propertytotalAmount columntotal_amount/ collection propertydetailList ofTypecom.demo.v3.OrderDetailVO id propertydetailId columndetail_id/ result propertyproductName columnproduct_name/ result propertyquantity columnquantity/ /collection /resultMap注意外层排序要稳定。如果内层子查询用了create_time排序外层就不要再用另一个无关字段排序把顺序打乱否则分页结果内部顺序会显得很随机。3.2 MySQL对派生表的合并问题这条SQL看起来完美但MySQL优化器不会一直按字面含义执行。派生表derived table在MySQL 5.7和8.0里都可能被优化器尝试合并到外查询一旦合并LIMIT可能被推到外层分页边界就又变回“先JOIN后分页”。我一个实际项目里就遇到过内层LIMIT 0,10外层JOIN明细结果EXPLAIN执行计划显示出驱动表根本不是子查询结果而是全表扫描后再合并页面上数据依旧翻车。解决办法是检查执行计划确认LIMIT在JOIN之前生效必要时显式禁用派生表合并SELECT /* NO_MERGE(p) */ p.id, p.order_no, ... FROM ( SELECT id, order_no, total_amount FROM orders ... LIMIT #{offset}, #{pageSize} ) p LEFT JOIN order_detail d ON d.order_id p.idMySQL 8.0对优化器hint的写法支持度更好但版本之间存在语法差异。如果线上MySQL是5.7遇到派生表被合并的情况更稳妥的做法是先查主表ID再套一层JOIN或者干脆用方案一的两次查询不要在一条SQL里跟优化器硬碰硬。3.3 窗口函数分页替代写法如果数据库支持窗口函数还可以用ROW_NUMBER给主表生成行号再根据行号范围分页。这种写法对Oracle、SQL Server这类数据库非常自然MySQL 8.0以后也能用。SELECT tmp.id, tmp.order_no, tmp.total_amount, d.id AS detail_id, d.product_name AS product_name FROM ( SELECT o.*, ROW_NUMBER() OVER (ORDER BY o.create_time DESC, o.id DESC) AS rn FROM orders o WHERE o.deleted 0 ) tmp LEFT JOIN order_detail d ON d.order_id tmp.id WHERE tmp.rn BETWEEN #{start} AND #{end} ORDER BY tmp.rn, d.id这里的start其实就是(pageNum - 1) * pageSize 1end是pageNum * pageSize。窗口函数在扫描主表时就已经把行号算好JOIN明细不会影响行号的稳定性。缺点是窗口函数要全表排序数据量上来之后开销不小适合订单量在几十万以内的场景。MySQL 8.0以前用不了窗口函数时就只能用LIMIT子查询方案或方案一的拆分查询。4. resultMap折叠逻辑与分页插件的兼容细节4.1 collection折叠的关键id列经常有人被这样一个现象困扰SQL和resultMap看起来都对但页面列表里同一个订单重复出现多次每个订单的明细又确实不一样。多半是因为resultMap里没有配置 。MyBatis折叠一对多结果时靠resultMap里的 属性判断两行是否属于同一个父对象。如果只写了 没有写 MyBatis每读取一行都认为是新对象于是同一个订单会随着每条明细重复创建。前面方案二的resultMap里我特别加了id propertyid columnid/这个id列不是数据库主键那么简单它告诉MyBatis“只要id相同就复用同一个OrderVO把明细往detailList里塞”。判断一个结果映射是否正确先看三处外层resultMap有没有idcollection里ofType的类型是否正确从SQL里查出来的列名能否对应上每个property。这三项缺一个数据都是“能用但不对劲”。4.2 分页插件内部发生了什么PageHelper这类分页插件原理是在Executor执行前拦截SQL。先执行一条count SQL拿到总数再把原SQL拼上LIMIT最后返回Page对象。问题就出在count和LIMIT都作用在处理后的SQL上。如果Mapper方法返回的结果是一个复杂resultMap而SQL又是JOIN后的结果集PageHelper生成的count SQL往往是把原select包一层SELECT count(0) FROM ( SELECT o.*, d.* FROM orders o LEFT JOIN order_detail d ON d.order_id o.id ) tmp这个总数就是膨胀后的物理行数显然不可信。更麻烦的是分页插件在处理含有子查询的SQL时有时候会把LIMIT拼错位置插到不该插的地方。所以遇到一对多关联分页我不建议无脑依赖分页插件最好独立验证一次count和LIMIT的准确性。如果你非要用PageHelper推荐做法是把分页拆开主表分页用一个简单查询让插件处理从表用一个不带分页的IN查询。这样插件面对的是最简单的主表查询count和LIMIT都不会出问题。复杂SQL交给插件是最容易在文档里看不到的地方翻车的。4.3 懒加载select可能引入N1还有一种写法是在collection里配置select属性让它按需加载明细collection propertydetailList ofTypecom.demo.v3.OrderDetailVO selectcom.demo.v3.OrderDetailMapper.selectByOrderId columnid/这种配置在单独查询订单详情时很优雅但在分页列表里基本是灾难。分页返回10条订单前端只要一渲染明细字段MyBatis立刻执行10次selectByOrderId这就是N1。如果列表有50条记录页面一加载就是50条额外SQL。数据库连接池稍微紧张一点接口响应时间直接翻倍。我的建议是列表分页场景坚持用批量IN查询不要用懒加载select。懒加载适合详情页单对象查询不适合列表页批量场景。如果一定要用也要确保明细查询走了缓存或者确认明细只在少数行被真正访问到否则性能隐患很大。5. 从表IN参数、排序兜底与空集合判断5.1 本页ID列表可能为空处理策略方案一里有个容易被忽略的地方主表查询返回的记录可能为空比如筛选条件太苛刻或者当前页超出总页数。此时orderIds为空集合如果还继续执行IN查询就会拼出WHERE order_id IN ()。这个SQL在MySQL里语法错误在有些数据库里虽然能执行但返回空结果白白浪费一次查询。代码里要提前短路if (CollectionUtils.isEmpty(orderIds)) { return page; }这套处理逻辑放在Service层从表查询根本不需要参与。很多开发在Mapper XML里用 拼IN时遇到空集合会报错原因就是没在调用前做这个判断。5.2 IN列表过大和分批查询当pageSize很小比如10条或者20条一次IN查询没有任何压力。但有些后台列表允许用户自己设置每页200条甚至导出功能也会复用这套逻辑此时IN的ID数量可能非常大。MySQL对IN的数量没有硬性的65535上限但IN列表越大SQL解析成本越高。超过500到1000个ID时更稳妥的做法是分批查询。比如每500个主表ID查一次明细把多次查询结果合并后再统一分组ListOrderDetailVO allDetails new ArrayList(); for (int i 0; i orderIds.size(); i 500) { ListLong batchIds orderIds.subList(i, Math.min(i 500, orderIds.size())); allDetails.addAll(orderDetailMapper.listByOrderIds(batchIds)); }分批IN和一次性IN对最终数据一致性没有任何影响因为主表当前页的ID集合在查询期间是固定的。真正需要注意的是如果订单数据在查询过程中被并发修改可能出现主表某个订单已经逻辑删除但明细还在的情况导致合并结果里多出孤立明细。这时候要么接受这种极小概率的中间态要么在明细查询里再带一层主表状态条件看业务对一致性的要求有多高。5.3 排序与分页稳定性分页最怕的是翻页不稳定。用户第一页看到订单A点击第二页又看到订单A或者第一页和第二页之间漏了一个订单这在后台系统里很容易被当成严重Bug。稳定性靠排序保证。凡是分页用的SQL都要在排序字段之外加主键兜底ORDER BY o.create_time DESC, o.id DESCcreate_time可能相同但id一定不同。加不加id兜底在数据量小的时候看不出区别数据量大且并发写入频繁的时候物理顺序会变少了id排序分页就可能乱。从表查明细时也要注意排序ORDER BY d.order_id, d.id这样同一个订单下的明细顺序固定展示层不会出现这次刷新和上次刷新顺序不一致的情况。5.4 明细内存与列裁剪一次性把本页所有订单的明细全部查出来内存占用取决于明细行数和字段大小。有些订单表有备注、JSON扩展字段一次查几百条明细再把整行塞进VO内存和网络传输都会被拖慢。经验做法是从表查询只查列表页需要展示的字段不要随手select *。如果明细字段非常多比如订单明细里有商品图片、规格参数、多段文本建议在从表查询里显式指定需要的列。另一个办法是主表列表页只显示“明细条数”和“总金额”这类从主表或冗余字段就能得到的信息不展示明细明细列表。用户想看具体明细时再点开详情单独加载。这种交互调整往往比分页SQL优化更直接。6. 数据量上来之后游标分页与分页插件的取舍思路6.1 游标Cursor适合什么场景当数据量真的很大比如几十万订单要导出或者后台任务要全量扫描订单和明细做统计时再用LIMIT分页逐页查就不合适了。MyBatis提供了Cursor返回类型可以把查询结果流式加载到Java迭代器里边读边处理不要求一次性把所有结果装进内存。Select(SELECT o.id, o.order_no, d.id AS detailId, d.product_name FROM orders o LEFT JOIN order_detail d ON d.order_id o.id WHERE o.deleted 0 ORDER BY o.id) CursorOrderRow scanOrderRows();使用Cursor时要注意它保持数据库连接打开遍历期间最好不要在同一个连接上执行其他数据库操作否则会产生奇怪的资源占用。遍历完必须放在try-with-resources里关闭。这种模式适合导数和后台批处理不适合网页分页因为网页分页需要的是“第几页到第几页”不是一个全量流再手动跳过前N条。6.2 不同场景下的方案选择我在实际项目里总结下来场景和写法的匹配关系大概是这样的场景推荐做法普通管理列表数据量在万级以下方案一主表分页 IN查从表数据量中等MySQL 8.0需要单SQL返回结果方案二子查询先分页再JOIN标准SQL数据库SQL Server或Oracle窗口函数 BETWEEN分页大数据量导出或全量扫描Cursor流式读取列表只展示订单汇总不展示明细内容主表冗余统计字段详情页单独查询方案一写起来多一个Mapper方法但胜在每一环都在掌控之中。方案二最大风险是优化器行为必须花时间看执行计划。窗口函数方案分页语义清晰但全表排序成本高。没有完美写法只有基于数据量和数据库版本的选择。6.3 一些实操经验最后分享几个代码评审时我会特别留意的点。第一看到resultMap里没有id配置先让我补上这是折叠一对多对象的基础。第二看到复杂JOIN SQL还要用PageHelper先让我确认count SQL独立可查不要等上线后用户反馈总条数不对。第三分页业务里看到N1的懒加载select改批量查询再谈其他优化。我自己在一个订单后台项目中把方案一和方案二都完整实践过。最终线上选型是列表页用方案一导出用Cursor订单详情页用嵌套resultMap单查。每个场景都只处理自己最擅长的工作反而比强行统一成一个“分页插件全家桶”要稳定得多。MyBatis这个框架给了你手写SQL的自由也给了你避开复杂映射坑的余地关键是怎么在自由里做取舍。