1. 问题本质与常规方案选型
1.1 一对多分页到底难在哪
把“一对多关联查询”和“分页返回”放在一起,听起来是个再普通不过的需求,但真上手做过的同学都知道,这里面的坑能让人写出三天三夜的排查日记。我先用最直白的场景还原一下这个问题。
假设现在有一个订单主表和一个订单明细表,一张订单对应多条明细。页面要做成一个订单列表,每条订单后面要能看到它的全部明细项,同时列表本身要分页。如果你直接写一条常规的关联查询,把主表和明细表JOIN在一起,出来的结果集长什么样呢——同一张订单的字段会在每一行明细上重复出现。比如订单A有3条明细,那么查询结果里就有3行,这3行的订单ID、订单金额、下单时间全是重复的。
问题就来了:数据库层的分页是基于“行”来算的。你用LIMIT 10去查,得到的是10行记录,但这10行可能只对应3张订单。换句话说,分页的语义在“一对多JOIN”之后已经失真了。你要的是“每页10张订单”,结果数据库按“每页10行明细”给你切了。页面上第一条订单可能只有2条明细,第二条订单的明细被切断到了下一页,或者更夸张一点,一条订单在上一页和下一页各出现一次。
这就是一对多分页的核心矛盾:分页发生在行粒度,而业务需要的是主记录粒度。
如果你在应用层做二次处理,也就是查出完整的一对多数据集之后,在内存里按订单分组、再手工切分页,那就要看数据量了。几千条数据这么玩完全没问题,但到了几万、几十万条,Java堆内存和GC延迟会先教你做人,而且每次请求都要把所有数据查出来,数据库的压力也扛不住。
还有一种常见的做法是写子查询,也就是先对主表单独分页,再通过WHERE主表ID IN (分页后的ID列表)去查明细,最后在应用层组装。这种方案思路是对的,避开了一大堆坑,但在MyBatis里怎么把两次查询的结果优雅地拼成完整的嵌套对象,并不是每个人都写得利索。
所以“mybatis中一对多关联查询返回分页数据”这个标题看起来朴实无华,背后其实是好几层技术选择的叠加:怎么做分页、怎么写映射、怎么控制查询次数、怎么保证性能。
1.2 市面主流方案的优劣对比
在我实际接触过的项目里,大家面对这个问题基本就三条路。我先把三条路的优劣势摆出来,后面再展开讲我认为最适合大多数场景的那条。
第一种是直接用嵌套结果映射,也就是ResultMap里配置collection标签,把主表和明细表一次性JOIN查出来。实现起来最简单,XML里写一个查询,配置好映射关系,返回的是完整的嵌套对象。配合PageHelper做物理分页的时候,问题就是前面说的行数错乱。一旦PageHelper在COUNT查询时把JOIN后的大表去COUNT,性能也容易出问题,明细表数据多的时候COUNT本身可能就很慢。
第二种是嵌套查询,也就是ResultMap的collection里配置select属性,让MyBatis在加载每条主记录时,额外发一条SQL去查明细。由于先对主表分页,分页结果是正确的,明细是逐条查出来的,不会错乱。但这个方案有著名的N+1查询问题——一页10条订单就意味着10次额外查询,明细表数据量大或者关联层级深的时候,数据库连接池和时间损耗都很可观。开启懒加载可以缓解一部分问题,但懒加载本身在序列化、事务边界、会话关闭这些场景里又会引出一堆新问题,比如Jackson序列化时触发懒加载导致session closed的报错,做过的都知道。
第三种是分步查询加手动组装,也就是先在Service层分页查出主表的当前页数据,拿到ID集合后,再用一条IN查询把所有明细一次性查出来,最后在内存里把明细按主表ID分组、装配到主对象上。这种方案查询次数固定为两次,没有N+1问题,分页语义天然正确,唯一的问题是需要在Service层写一段装配代码。很多人觉得这样不如ResultMap“自动”来得高级,但在性能和可维护性上,它其实是综合最优的。
我本人的建议是:除非你的明细数据量极小且系统并发极低,否则直接上第三种方案。不是因为ResultMap不行,而是因为“自动映射”在跨表、分页、性能这三维度叠加时,往往变成“自动制造麻烦”。后面我会给出完整代码,你照着改就能用。
2. 数据模型与核心代码实现
2.1 建表和实体类设计
为了让这篇内容能直接落地,我设计了一个贴近真实业务的数据模型。主表叫t_order,存订单基础信息;明细表叫t_order_item,存订单的每项商品信息。两张表通过order_id关联。
先看建表语句,我删掉了无关字段,保留最能说明问题的部分。
CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` varchar(32) NOT NULL COMMENT '订单编号', `customer_name` varchar(64) DEFAULT NULL COMMENT '客户姓名', `total_amount` decimal(10,2) DEFAULT NULL COMMENT '订单总额', `status` tinyint(4) DEFAULT NULL COMMENT '状态:1待支付 2已支付 3已取消', `create_time` datetime DEFAULT NULL COMMENT '下单时间', PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';CREATE TABLE `t_order_item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '明细ID', `order_id` bigint(20) NOT NULL COMMENT '所属订单ID', `product_name` varchar(128) DEFAULT NULL COMMENT '商品名称', `price` decimal(10,2) DEFAULT NULL COMMENT '商品单价', `quantity` int(11) DEFAULT NULL COMMENT '购买数量', `subtotal` decimal(10,2) DEFAULT NULL COMMENT '小计金额', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';注意我给明细表的order_id建了索引,这是很容易被忽略的一点。一对多查询在按主表ID集合查明细时,走索引和全表扫描的性能差距是数量级的。如果你的明细表已经存在且没加索引,先去补上,否则后续所有优化都是白搭。
实体类我用了最常规的写法。Order类里嵌套一个List<OrderItem>,这是“一对多”在Java对象模型中的直接体现。
public class Order { private Long id; private String orderNo; private String customerName; private BigDecimal totalAmount; private Integer status; private Date createTime; private List<OrderItem> items; // 一对多关联的明细列表 // getter/setter 省略 }public class OrderItem { private Long id; private Long orderId; private String productName; private BigDecimal price; private Integer quantity; private BigDecimal subtotal; // getter/setter 省略 }有人会问,Order里要不要放一个List<OrderItem>?这取决于你返回给前端的JSON结构。如果前端需要的确实是一棵树形结构,订单下面挂明细,那就必须有这个字段。如果用分步查询方案,这个字段就是Service层手动装配的目标。如果你只需要订单主表的数据,明细另外单独接口返回,那这个字段完全可以不加,问题会简单很多。后面我会按“必须要树形结构”的场景来写。
2.2 分步查询方案的分页处理逻辑
这一节是全文最核心的部分,我先把整体思路捋清楚。
第一步,单独查询主表t_order,分页条件、过滤条件都在这一步完成。因为你查的是单表,每个订单只对应一行记录,所以数据库分页的结果是精准的——一页10条就是10张订单。
第二步,从这10条订单中提取出所有ID,形成List<Long> orderIds。
第三步,用WHERE order_id IN (?, ?, ...)一次性查出所有明细。这里有几条明细就是几条,跨订单的全部在一个查询里拿回来。
第四步,在Service层把明细按orderId分组,然后通过Order对象里的setItems()方法,把对应明细装配到每一张订单上。
这套流程的核心优点在于:分页正确,因为分页在单表上做,语义不会被JOIN扭曲。查询次数固定,永远只有2条SQL,不随数据量增加。而且性能可控,两条SQL各自走索引和分页,没有笛卡尔积的隐患。可读性极佳,每条SQL的职责单一,出现问题很容易定位。
有人会担心,手写装配代码是不是比ResultMap麻烦。我的回答是:这段装配代码也就20行左右,而且是完全可控的。相比之下,ResultMap在遇到分页错乱和性能问题时,排查成本远高于这20行代码。
第二步那个IN查询,如果分页后主表ID数量为0,要做一个短路处理,直接返回空结果,别再去执行IN查询。看似是细节,但能避免一个毫无意义的数据库往返。
3. Mapper接口与XML完整实现
3.1 Mapper接口方法设计
我先定义Mapper接口。主表查询需要支持多条件过滤,所以查询条件单独用一个类来承载,而不是把参数散着传,这样后期扩展字段不用改方法签名。
public interface OrderMapper { /** * 分页查询订单主表 */ List<Order> selectOrderPage(OrderQuery query); /** * 统计订单总数,用于分页插件计算总页数 */ Long countOrder(OrderQuery query); /** * 根据订单ID集合批量查询明细 */ List<OrderItem> selectItemsByOrderIds(@Param("orderIds") List<Long> orderIds); }OrderQuery类的字段根据实际过滤需求来定,我在这里放几个常见条件作为示例。
public class OrderQuery { private String orderNo; // 订单编号,支持模糊查询 private Integer status; // 订单状态 private String customerName; // 客户姓名,支持模糊查询 private Integer pageNum; // 页码,从1开始 private Integer pageSize; // 每页条数 }这里有个设计取舍值得说一下:我把“查询主表分页”和“查询总数”拆成了两个方法。PageHelper这类插件能自动拦截分页SQL并生成COUNT语句,但自动生成的COUNT在复杂SQL上偶尔会抽风,改写错误或者性能不佳。自己写COUNT语句的最大好处是完全可以掌控,统计逻辑永远和列表查询保持一致的过滤条件。
3.2 XML映射文件的细节写法
XML部分,我直接给出完整内容,然后逐段解释关键点。
<resultMap id="OrderResultMap" type="com.example.entity.Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="customerName" column="customer_name"/> <result property="totalAmount" column="total_amount"/> <result property="status" column="status"/> <result property="createTime" column="create_time"/> </resultMap> <resultMap id="OrderItemResultMap" type="com.example.entity.OrderItem"> <id property="id" column="item_id"/> <result property="orderId" column="order_id"/> <result property="productName" column="product_name"/> <result property="price" column="price"/> <result property="quantity" column="quantity"/> <result property="subtotal" column="subtotal"/> </resultMap> <select id="selectOrderPage" resultMap="OrderResultMap"> SELECT id AS order_id, order_no, customer_name, total_amount, status, create_time FROM t_order <where> <if test="orderNo != null and orderNo != ''"> AND order_no LIKE CONCAT('%', #{orderNo}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="customerName != null and customerName != ''"> AND customer_name LIKE CONCAT('%', #{customerName}, '%') </if> </where> ORDER BY create_time DESC LIMIT #{pageSize} OFFSET #{offset} </select> <select id="countOrder" resultType="long"> SELECT COUNT(*) FROM t_order <where> <if test="orderNo != null and orderNo != ''"> AND order_no LIKE CONCAT('%', #{orderNo}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="customerName != null and customerName != ''"> AND customer_name LIKE CONCAT('%', #{customerName}, '%') </if> </where> </select> <select id="selectItemsByOrderIds" resultMap="OrderItemResultMap"> SELECT id AS item_id, order_id, product_name, price, quantity, subtotal FROM t_order_item WHERE order_id IN <foreach collection="orderIds" item="orderId" open="(" separator="," close=")"> #{orderId} </foreach> </select>关于LIMIT的写法,我需要特别强调:LIMIT #{pageSize} OFFSET #{offset}这种写法在某些数据库方言里有性能隐患,深分页时偏移量大会导致全表扫描前offset行。但MySQL 8.0.16以上版本已经对LIMIT/OFFSET做了优化,如果你的项目还在用5.x版本,建议对深分页场景额外考虑索引覆盖或游标方案。不过这是另一个话题,日常管理后台场景下,这个写法完全够用。
关于AS别名这件事,我多说一句。MyBatis的自动映射是基于列名和属性名的驼峰转换,我在resultMap里显式配置了column属性,所以别名不是必须的。但为什么还要写别名呢?因为显式别名能让SQL的可读性更好,也避免某些数据库返回大写列名导致映射出错。用了resultMap之后,即使列名和属性对不上,也能通过column属性精确指定。这里我给主键都加了别名,比如id AS order_id,是为了让resultMap中的column字段意义更明确,也方便团队其他人一眼看懂映射关系。
3.3 Service层装配逻辑代码
Mapper准备好之后,Service层是“分步查询”方案真正发挥价值的地方。我直接给出一个完整的实现,注释标明了每一步在做什么、为什么这么做。
@Service public class OrderService { @Resource private OrderMapper orderMapper; /** * 分页查询订单并装配明细 */ public PageResult<Order> pageOrders(OrderQuery query) { // 1. 参数预热:页码、页大小的默认值处理 int pageNum = query.getPageNum() == null ? 1 : query.getPageNum(); int pageSize = query.getPageSize() == null ? 10 : query.getPageSize(); // 2. 计算偏移量,传给SQL int offset = (pageNum - 1) * pageSize; query.setOffset(offset); query.setPageSize(pageSize); // 3. 查询总数和当前页主表数据 Long total = orderMapper.countOrder(query); if (total == null || total == 0) { return PageResult.empty(pageNum, pageSize); } List<Order> orderList = orderMapper.selectOrderPage(query); // 4. 抽取当前页订单ID,短路处理 List<Long> orderIds = orderList.stream() .map(Order::getId) .collect(Collectors.toList()); if (orderIds.isEmpty()) { return PageResult.of(orderList, total, pageNum, pageSize); } // 5. 一次性查询这些订单的全部明细 List<OrderItem> allItems = orderMapper.selectItemsByOrderIds(orderIds); // 6. 内存分组并装配 Map<Long, List<OrderItem>> itemMap = allItems.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); for (Order order : orderList) { List<OrderItem> items = itemMap.getOrDefault(order.getId(), Collections.emptyList()); order.setItems(items); } // 7. 返回分页结果对象 return PageResult.of(orderList, total, pageNum, pageSize); } }PageResult是我自定义的一个通用分页返回结构,字段就是list、total、pageNum、pageSize,你也可以直接用Hutool里的PageResult或者自己项目里已有的公共类,不影响核心逻辑。
这里最值得注意的一行是Collectors.groupingBy(OrderItem::getOrderId)。它会把明细列表按orderId做分组,生成一个Map<Long, List<OrderItem>>。然后遍历订单列表时,通过getOrDefault取出该订单的明细,没有明细就返回空列表。这样即使某些订单没有明细,返回的JSON里items字段也是一个空数组,而不是null。前后端联调时,空数组比null友好得多,前端不需要到处判空。
4. 分页插件的正确使用方式
4.1 使用分页插件时的参数设置
刚才的Service层是手动计算offset和分页,没有依赖任何分页插件。但很多团队的项目里已经集成了PageHelper这类插件,那也不能浪费。我单独说一下在PageHelper场景下,这套方案应该怎么调整。
如果你要保留PageHelper来做主表分页,那selectOrderPage方法里就不需要LIMIT语句了,分页插件会自动追加。具体改动是:在查询之前调用PageHelper.startPage(pageNum, pageSize),然后执行查询,最后返回的Page对象里既包含数据列表也包含总数。我把调整后的核心代码写一下。
public PageResult<Order> pageOrdersWithHelper(OrderQuery query) { // PageHelper.startPage必须在查询语句之前调用 PageHelper.startPage(query.getPageNum(), query.getPageSize()); // 这里执行的是不带LIMIT的selectOrderPage List<Order> orderList = orderMapper.selectOrderPage(query); // 获取Page对象中的总数 Page<Order> page = (Page<Order>) orderList; long total = page.getTotal(); if (total == 0 || orderList.isEmpty()) { return PageResult.empty(query.getPageNum(), query.getPageSize()); } // 后续明细装配逻辑和上一节完全一样 List<Long> orderIds = orderList.stream() .map(Order::getId) .collect(Collectors.toList()); List<OrderItem> allItems = orderMapper.selectItemsByOrderIds(orderIds); Map<Long, List<OrderItem>> itemMap = allItems.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); for (Order order : orderList) { order.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList())); } return PageResult.of(orderList, total, query.getPageNum(), query.getPageSize()); }这里有几个必须刻进脑子里的使用规范。
PageHelper.startPage方法只对它后面紧跟着的第一条查询语句生效。如果你的代码在startPage和selectOrderPage之间插入了任何其他数据库操作,分页效果就会跑到那条SQL上,然后你会在一个完全无关的查询结果里看到LIMIT关键字。这个问题极其隐蔽,排查起来特别耗时。
PageHelper还有一个“物理分页”和“逻辑分页”的概念。默认情况下它对查询做物理分页,也就是把LIMIT拼到SQL里。但对于含有group by的SQL,PageHelper的COUNT语句生成逻辑偶尔会出问题,统计出来的总数不是分组后的行数。虽然本项目没有GROUP BY,但如果你在团队内推广这套方案时遇到总数不对,优先怀疑这里。
不建议同一个请求里多次调用startPage。如果你的Service里一个方法要分页查询好几张表,第一次startPage只作用于第一条SQL,第二条SQL如果想分页需要再次调用。但我在实际项目里见过有人在循环里调startPage,这个基本属于事故现场,必须避免。
4.2 分页结果的总数一致性问题
不管用不用PageHelper,总数计算都有个一致性隐患。在并发环境下,如果你先查COUNT再查列表,两次查询之间主表数据发生了变化,那总数和列表就对不上了。有三种处理思路。
第一种最简单,就是接受这种不一致,因为管理后台场景下,分页数据本来就是某一瞬间的快照,用户翻页时总数偶尔有小幅出入,几乎不会有人察觉。
第二种是放到同一个事务里,配合数据库的隔离级别来控制。在MyBatis里可以用Spring的@Transactional包住查询方法,让COUNT和列表查询在同一个事务快照中执行。InnoDB默认的REPEATABLE READ隔离级别下,同一个事务内的多次查询读到的是同一个快照,总数自然一致。代价是事务会延长连接占用时间,高并发下会增加连接池压力。
第三种是在一条SQL里同时返回总数和数据,也就是用窗口函数。MySQL 8.0及以上版本可以用COUNT(*) OVER()得到总行数,这样一条SQL既拿到列表又拿到总数,完美一致。
SELECT id AS order_id, order_no, customer_name, total_amount, status, create_time, COUNT(*) OVER() AS total_count FROM t_order <where> ... </where> ORDER BY create_time DESC LIMIT #{pageSize} OFFSET #{offset}查出来的每一行都会带上同一个total_count值,你取第一行的这个字段就是总数。这个方案少了一次COUNT查询,但MyBatis的resultMap里需要加一个totalCount字段映射,而且MySQL 5.7及以下版本用不了。权衡下来,常规项目用第一种或第二种就已经足够,追求极致性能且确定在MySQL 8.0以上,再用窗口函数。
5. 常见问题与性能排查实录
5.1 明细被截断或明细为空
我在技术支持群里见过不止一次这样的提问:明明数据库里订单有明细,查出来却只显示一条,或者显示为空。
如果是用嵌套结果映射加PageHelper的方案,出现“只显示一条”的概率极高。本质原因在于PageHelper的分页是基于JOIN后的结果集行数来算的,LIMIT切在明细行上,一个订单的多条明细被切散了,反映到页面上就是明细不完整。解决方案就是切换到前面讲的分步查询方案,或者放弃PageHelper,改用子查询先分页主表再关联。
如果出现“明细为空”,先检查两个地方。第一,数据库里order_id是否真的匹配。第二,检查MyBatis的resultMap里明细表主键的column配置是否写对了。尤其要留意:如果明细查询用的是SELECT id而不是SELECT id AS item_id,但resultMap里配置的column是item_id,映射就会失败。这类问题不属于一对多分页本身,但会在这个场景下集中暴露,因为结果集结构比较复杂,排查时容易一头扎进死胡同。
5.2 PageHelper线程安全与串页问题
PageHelper在实现上使用了ThreadLocal来传递分页参数。这意味着分页参数和当前线程绑定,一旦忘记调用PageHelper.clearPage()清空,或在一个异步线程池里使用,就可能出现一个请求的分页参数污染另一个请求的情况。典型症状是:用户A请求的分页数据里,突然出现了用户B的LIMIT条件。
规避手段很简单:在try-finally里确保PageHelper的清理执行,或者干脆用前面写的手动offset方案,彻底不依赖ThreadLocal。我个人在维护高并发项目时,更倾向于手写offset,少一个隐式状态就少一类疑难杂症。
另一个容易被忽略的是PageHelper的reasonable参数。把它设为true后,如果用户请求的页码超过总页数,PageHelper会自动把页码修正为最后一页。看起来是个贴心功能,但在对账、导出、定时任务这类对数据完整性要求极高的场景里,这种“贴心”会掩盖问题,建议按项目实际情况决定是否开启。
5.3 IN查询的列表长度与性能
当主表分页后每页100条,那IN后面的列表就是100个ID。如果每页500条,就是500个ID。有些数据库对IN列表的长度有限制,比如Oracle的IN列表上限是1000,MySQL虽然没有硬性限制,但超过几百个ID时,SQL解析和索引选择的效率会下降。
解决思路有两个方向。第一,把IN列表控制在合理范围内,如果确定业务的每页大小不会太大,这个风险基本可控。第二,如果明细查询的结果集实在太大,比如一张订单有几千条明细,同时页面上又要展示全部,那就要考虑明细这个维度是否适合一次性加载,是否改用“先展示主表,点击订单再加载明细”的交互方式。很多管理后台最终都走了这个方案,不是技术做不到,而是用户体验和性能之间需要做一个平衡。
6. 写在最后的个人心得
在我经手的项目里,“一对多分页”这个需求出现频率非常高。每次团队里有人踩坑后跑来找我,我基本都能猜到用的是嵌套结果映射加PageHelper。这个方案在demo里跑起来非常顺手,因为数据量小、分页错觉不明显,等上了生产环境,数据量一大,各种问题集中爆发,只能在日志里一点点扒。
我现在遇到这个需求,默认就写分步查询。代码只多20行,但换来了分页语义的绝对正确和查询次数的绝对可控。用ResultMap自动装配固然显得“高级”,但“高级”如果建立在隐性问题之上,那不如退一步,用朴素但可靠的方式把业务撑住。
如果你正在被这个问题困扰,我建议直接把我写的主表Mapper、明细Mapper、Service三件套贴到项目里,跑一遍看效果。等跑通了,再去考虑优化:比如把明细查询的IN改成分批查询、给订单列表加上缓存、或者用窗口函数替代COUNT查询。这些优化都是建立在正确的基础方案之上的,先把地基打对,比什么都重要。