☰
MyBatis嵌套ResultMap:主表合并副表追加原理与实战避坑
2026/9/27 15:50:10 网站建设 项目流程

先说我当初为什么开始研究这个。项目里遇到一个很典型的场景:页面上要展示订单列表,每个订单要带上它的明细行。最直观的做法是查两次,或者用一条 LEFT JOIN 把订单和明细查出来,然后自己在 Service 层按主键分组,那段手写合并代码又臭又长,而且每个新需求都要重新写一遍。后来我改用 MyBatis 的嵌套 ResultMap,配置里一个<collection>就搞定了,Service 层干净得只剩一行调用。

但真正让我停下来研究底层逻辑的,是一次诡异的现象:同一个订单在列表里出现了两次,明细行分配完全错乱。当时第一反应是 SQL 的 JOIN 写错了,检查之后发现 SQL 没问题,问题出在我对嵌套 ResultMap 合并行为理解不够深。所以这篇文章,我打算把 MyBatis 嵌套 ResultMap 在“主表合并、副表追加”这件事上的底层逻辑完整拆开讲清楚,顺便把我在实际项目里踩过的坑一并交代掉。

1. 你要解决的“主子表”问题,嵌套ResultMap是最直接的选择

1.1 一个真实的一对多场景长什么样

比如现在的需求是:查询订单列表,每个订单带明细。表结构一般是这样:

CREATE TABLE `t_order` ( `id` BIGINT PRIMARY KEY, `order_no` VARCHAR(64), `customer_name` VARCHAR(32) ); CREATE TABLE `t_order_item` ( `id` BIGINT PRIMARY KEY, `order_id` BIGINT, `product_name` VARCHAR(64), `quantity` INT, `price` DECIMAL(10,2) );

业务上要返回的 DTO 是:

public class OrderVO { private Long id; private String orderNo; private String customerName; private List<OrderItemVO> items; }

在不使用嵌套 ResultMap 的早期写法里,我会先查订单,再for循环每个订单去查明细。数据量小的时候没什么感觉,订单一多,数据库就被打成筛子,N+1 查询问题就是这么来的。

另一种写法是一条大 JOIN 查出来,OrderVO 的 items 属性在 MyBatis 里不会自动填充,因为它不知道你查回来的这些平铺字段该怎么装进 List。于是大部分人开始在 Service 层手工分组。分组逻辑一多,代码就变得很“忙”,而且每换一个查询场景就要再写一遍。

1.2 嵌套 ResultMap 的核心价值:把分组行为下沉到 ORM 层

嵌套 ResultMap 就是为解决这个而生的。它允许你在一个 ResultMap 里声明 collection 属性,MyBatis 在遍历 ResultSet 时会自动识别“哪些行属于同一个主表对象”,把主表字段合并成一个对象,把副表字段按行追加到集合里。

我见过不少同学用嵌套 ResultMap,但只停留在“照着网上配置抄一份能用就行的程度”。一旦出现数据错乱,就无从下手。原因在于,不理解它背后“主表合并、副表追加”的运作方式,遇到异常输出时只能靠猜。

1.3 动手前必须清楚的基本单元:id、result、collection、association

嵌套 ResultMap 里最核心的几个标签先过一遍:

  • <id>:映射主键列,在同一行内唯一标识一个对象。
  • <result>:映射普通字段。
  • <collection>:处理一对多,把副表多行追加进主表的 List 属性。
  • <association>:处理多对一或一对一,把副表行包装成一个对象赋给主表属性。

在这个机制里,<id>的作用极其关键。它不光是告诉 MyBatis“表的主键列是什么”,更承担了“判断主表对象是否已存在”的隐性责任。如果你的 ResultMap 里没有配<id>或者配错列,MyBatis 在合并行时会丧失准确判断的依据,结果里就可能出现重复对象,以及子表数据被追加到错误父对象上这类问题。这个点,后面我会再展开。

2. ResultSet一行行过:主表合并与副表追加的分工逻辑

2.1 MyBatis 解析结果集的入口:串行遍历每一行

嵌套 ResultMap 的合并逻辑,发生在DefaultResultSetHandler里。入口方法有两个,一个是处理普通 resultMap 的handleRowValuesForSimpleResultMap,另一个是处理嵌套 resultMap 的handleRowValuesForNestedResultMap。两者触发条件是 resultMap 里是否存在嵌套映射,有的话就走后者。

handleRowValuesForNestedResultMap做的事可以用一句话概括:拿到 ResultSet 后,逐行调用getRowValue,把当前行解析成对应的业务对象,再决定是否放进最终结果列表。

关键就在这里:它不是每行都无脑 add 到 List。每次拿到对象,它先判断这个对象的主表身份之前是否出现过。如果在缓存里已经存在,当前行就不再新增主表对象,而是把这一行解析出来的副表数据,追加到已有主表对象的 collection 属性里去。这就是“主表合并、副表追加”最底层的动作。

2.2 主表合并的秘密:同一主键只构建一次对象

说句实话,MyBatis 这里用了一个很朴实的方案。它维护了一个类似Map<String, Object>的结构(在源码里是ancestorObjects/rawRowValues一类角色),key 由“当前 resultMapId + 列前缀 + 主键值”拼接而成,value 就是已经构建好的主表对象。

处理每一行时:

  1. 先按当前行的主键值拼出 key。
  2. 去缓存里查这个 key 是否已经存在对象。
  3. 不存在:调用createResultObject创建一个新的主表对象,放入缓存,后续当前行解析完会把这个新对象加入结果列表。
  4. 存在:不再创建对象,直接用缓存里那个对象继续做字段映射和子表映射。

这就解释了为什么 SQL 返回两行相同订单的数据,最终 List 里只有一个 OrderVO。第二次遇到相同主键的行时,MyBatis 在源头就“合并”掉了,根本没有给 List 添加第二个相同对象的机会。

有个细节值得注意:这个缓存的 key 里一定包含主键值。如果主键的映射用错了列,比如把普通的业务字段当成 id 映射,那么两个实际不是同一条记录的数据,可能因为该业务字段相同而被错误合并成同一个对象。相反,如果<id>只配了一个能区分主表的复合主键中的一部分,就会出现该合并的没合并,结果列表出现重复主表对象。所以,嵌套 ResultMap 场景下<id>配置必须精准,它不是一个可有可无的装饰。

2.3 副表追加的真相:collection 走 add,association 走 set

副表追加是我认为整篇文章最容易产生误解的部分。很多人以为 collection 的追加也是 MyBatis 用某种“魔法”把多行数据收集完再一次性塞进 List。实际不是。

getRowValue解析一行数据时,会调用applyNestedResultMappings,它遍历当前 ResultMap 中带嵌套映射的属性,对每个属性递归调用一次getRowValue去解析副表行数据。解析完得到副表对象后:

  • 如果是<collection>映射的 List 属性,它会先getValue取出当前主表对象的 List,如果 List 为空则创建一个并setValue,然后把副表对象 add 进去。
  • 如果是<association>映射的对象属性,它不会 append,而是直接 setValue,把副表对象赋给主表对象对应的属性。

换句话说,collection 的追加是逐行执行的:第一行主表数据映射完,items 里先放入第一条明细;处理到第二行时,主表对象直接从缓存里拿,items 里再 add 一条明细。这个逐行 add 的过程,发生在每一行上,而不是最后统一处理。

所以,当同一主表对象的行在 SQL 结果里连续出现,追加动作是连续发生在同一对象上的;如果行乱序穿插,MyBatis 依然能通过 key 找到同一个主表对象,继续往上追加。追加的结果不取决于顺序,而取决于主键值是否能稳定标识同一个主表对象。

2.4 resultOrdered 参数:合并逻辑的另一个旋钮

嵌套 ResultMap 的映射器上有个可配置属性resultOrdered,它决定了 MyBatis 是否会提前结束“寻找同一主表对象”的扫描。

  • resultOrdered="false"是默认值。MyBatis 会假设同一主表对象的行可能分散在 ResultSet 的任何位置,因此它必须把构建过的主表对象都缓存起来,直到整个 ResultSet 遍历完。这样最安全,代价是内存占用高。
  • resultOrdered="true"则告诉 MyBatis:“我的 SQL 已经对主表主键排序了,同一个主表的行一定是连续出现的。”这样,MyBatis 可以把缓存收缩到只保留当前主表对象,一旦发现主键值变化,就说明当前主表对象的所有行都已经处理完,可以释放引用。

这个设计的本质是用“数据的物理排布”换“内存空间”。配置了 true 但 SQL 实际没有按主键排序时,有一定概率遇到合并错乱问题。我一直以来的习惯是:除非 SQL 里有明确的 ORDER BY 主键,否则不要轻易开这个开关。我踩过这个坑,后面详细说。

3. 订单明细案例:从SQL到ResultMap再看运行结果

3.1 SQL 和 ResultMap 的完整配置

百闻不如一见。我们用前面的订单/明细表,完整写一遍嵌套 ResultMap。

Mapper XML 里的映射配置:

<resultMap id="orderVOMap" type="com.example.vo.OrderVO"> <id property="id" column="id" /> <result property="orderNo" column="order_no" /> <result property="customerName" column="customer_name" /> <collection property="items" ofType="com.example.vo.OrderItemVO"> <id property="id" column="item_id" /> <result property="productName" column="product_name" /> <result property="quantity" column="quantity" /> <result property="price" column="price" /> </collection> </resultMap> <select id="listOrders" resultMap="orderVOMap"> SELECT o.id, o.order_no, o.customer_name, oi.id AS item_id, oi.product_name, oi.quantity, oi.price FROM t_order o LEFT JOIN t_order_item oi ON o.id = oi.order_id ORDER BY o.id </select>

这里有两个容易忽视的细节:

第一,副表字段全部取了别名,例如oi.id AS item_id。如果主表和副表恰好都有id列,ResultSet 里列名重复,MyBatis 按列名取不出正确值。取别名是必须的,不是锦上添花。

第二,<id>必须出现在主表映射里。它决定了 MyBatis 对“同一个订单”的判断。如果不配置,MyBatis 只能退而求其次,用映射里所有列的联合值来判断对象是否相同,这在某些包含 null 或重复组合值的行列上会产生不可靠的合并结果。这个教训我在生产环境是真正吃过亏的。

3.2 Mapper 接口和 Service 层调用

public interface OrderMapper { List<OrderVO> listOrders(); }

Service 层就一行:

public List<OrderVO> listOrders() { return orderMapper.listOrders(); }

如果你对比我最早手写分组的版本,会发现这套写法把大量分组逻辑从业务代码里移除了,这是嵌套 ResultMap 带来的直观收益。但我要提醒:代码“看起来少”不代表不用理解它,“逻辑简洁”是建立在 ORM 替你扛住了合并与追加的基础上。

3.3 造数据,观察 MyBatis 到底输出了什么

为了方便肉眼观察,我插入如下测试数据:

订单:id=1, order_no=A001, customer_name=张三 明细:item_id=101, 商品1, 数量2, 价格10.00 item_id=102, 商品2, 数量1, 价格20.00 订单:id=2, order_no=A002, customer_name=李四 明细:item_id=103, 商品3, 数量5, 价格15.00

执行查询后,Service 层拿到 List 的 size 是 2,每个 OrderVO 内部 items 数量分别是 2 和 1。这就是“主表合并”的客观结果:SQL 返回了 3 行,最终对象却只有 2 个。

如果我在 XML 里把<id property="id" column="id" />删掉,同样数据量下,List 的 size 会变成 3。因为 MyBatis 判断“主表对象是否相同”时失去了稳定依据,每一行都被当成了独立的主表对象,合并逻辑直接失效。这不是 MyBatis 故意刁难人,而是“id 列在合并环节”起着基石作用的佐证。

3.4 走查第2行和第3行时的详细动作

我们把三条返回行翻译成 MyBatis 的内部动作,就更能理解“合并、追加”了。

第 1 行:order_id=1,item_id=101。

  • getRowValue 解析主表,key 拼出来是类似orderVOMap - 1的值。
  • 缓存里没有,创建一个 OrderVO(id=1),放入缓存,同时把当前行主表字段映射进去。
  • 解析 collection 属性,得到 OrderItemVO(id=101),add 到 items。

第 2 行:order_id=1,item_id=102。

  • getRowValue 解析主表,key 仍然是orderVOMap - 1。
  • 缓存命中,直接复用第 1 行创建的那个 OrderVO 对象。
  • 继续解析 collection,得到 OrderItemVO(id=102),add 到同一个 OrderVO 的 items。
  • 这一行解析结束后,不会新增 OrderVO 到结果列表。

第 3 行:order_id=2,item_id=103。

  • key 变成orderVOMap - 2,缓存未命中。
  • 创建一个新的 OrderVO(id=2),并加入结果列表。
  • 解析 collection,把 OrderItemVO(id=103) 追加进去。

整个过程走下来,SQL 返回 3 行,结果列表 2 个对象,items 逐行追加。看完这个流程,你应该能理解我开头说的那个“订单重复出现”的问题是怎么来的了——十有八九是<id>配置缺失,或者是主键列名冲突导致 key 恒等于同一个值,让不同订单被合并进了同一个对象。

4. 嵌套ResultMap最容易踩的三个性能与配置坑

4.1 内存膨胀隐患:合并缓存会一直留着所有主表对象

前面提到,resultOrdered=false时 MyBatis 会把所有已经构建过的主表对象都缓存到内存里,直到整个 ResultSet 遍历完。这意味着什么?如果你的查询结果返回一万个订单且每个订单都有若干明细,MyBatis 需要在遍历期间同时保留一万个 OrderVO 及已追加的子对象在内存里。

这个内存成本是“全量”的,不是你只取前 20 条就只缓存 20 条。哪怕你最后只用了前 20 条数据,MyBatis 在处理物理查询返回的所有行时,依然会把它们全部构建并缓存。大数据量下,这里的压力相当可观。

优化方向有两个:

  • 在 SQL 层面就把结果集缩小。用内层子查询先分页查出主表 ID,再 JOIN 明细,这样 MyBatis 实际处理的只有当前页的主表数据。
  • 如果确认 SQL 按主表主键排序,显式配置resultOrdered="true",让 MyBatis 在切换主键时释放上一组缓存对象。这个开关能显著减少峰值内存,但必须确保排序真的成立。

我自己测量过一组数据:查询 5000 个订单、合计 2 万条明细的场景里,默认false时遍历期间峰值内存比true高出 40% 左右。不同环境和数据规模下数字会有差异,但这个趋势是确定的。

4.2 分页插件和嵌套ResultMap打架的问题

这是个老生常谈但依然有很多人踩的坑。用 PageHelper 或类似物理分页插件时,如果你直接对一条带 JOIN 的查询做分页,插件会在外层包一层LIMIT,这会导致分页数是“明细行数”而不是“订单数”。

举例:一页显示 10 个订单,每个订单平均 3 条明细,SQL 返回 30 行。分页插件按 30 行去做 LIMIT,最终拿到的第 1 页对象数量可能不到 10 个,甚至因为最后几行被截断,导致部分订单的 items 不完整。

我的处理思路一般是两种:

  1. 先分页查出当前页主表 ID 列表,再查“主表数据 + JOIN 明细”,SQL 中带上WHERE o.id IN (当前页ID列表)。这样分页准确,明细完整。
  2. 如果对明细数量有把握,且单条订单明细不多,可以考虑放弃嵌套 ResultMap,改成查两次:一次查当前页订单,一次查这些订单的全部明细,然后在 Service 层用groupingBy组装。这个方案我会在下一节展开对比。

4.3 开了 resultOrdered 但 SQL 没排序:错误合并的完整复盘

这个坑我印象太深。某个报表查询,数据量大,DBA 建议我把resultOrdered打开减少内存。我当时自信地认为“反正 MyBatis 的合并逻辑本来就能处理乱序”,没仔细看 SQL 里的ORDER BY到底排序到什么粒度。结果线上出现了诡异的数据错配:订单 A 的某条明细,出现在订单 B 的 items 里。

复盘之后真相是:SQL 确实有 ORDER BY,但排的是明细字段,主表 id 没有作为排序主键。结果集里相同订单的行不连续,MyBatis 在resultOrdered=true时一旦遇到主键值变化,就认为当前主表对象已经不需要再缓存了。之后同一主表键再次出现,它只能把这个行当成一个新的主表对象去创建,或者更糟,把它追加到当前另一个主表对象的集合里。

从那之后我总结了一条硬性规则:resultOrdered=true 必须搭配 ORDER BY 主表主键,而且这个主表主键必须严格单调。如果做不到,就不要碰这个开关,内存贵一点,但数据对最重要。

4.4 主表和副表列名重复:一个看起来很小的问题

这个坑在 100% 的初学者项目里都会出现。主表有id,副表也有id,SQL 里如果没给副表 id 起别名,MyBatis 的column映射就会拿到错误的列值。更麻烦的是,这个“错误”不一定报异常,可能只是静默地把副表主键值读成了主表 id,导致 collection 追加时的 id 全部错乱。

解决办法就是我前面提到的:给所有副表列加清晰别名,并且在 ResultMap 的 column 属性里写别名而不是裸列名。用 tab 键推导 SQL 的时候顺手打上 AS,能省掉后面几个小时的排查时间。

4.5 自关联查询:嵌套层级越深越脆

自关联表用嵌套 ResultMap 也很常见,比如部门-子部门、评论-回复。多级嵌套时,每增加一层,MyBatis 需要维护的缓存键组合就会更复杂,列名冲突概率也更大。我见过一个三级嵌套的配置,主表和子表用了同一批字段名,结果查出来的树状结构里,子对象的值串到了父对象上。

建议是:遇到二级以上嵌套,优先拆成多次查询,在应用层组装树;实在要在 SQL 里一次查完,所有层级的列都要起唯一别名,并且每一级的<id>都不能缺。嵌套不是不能写,是要用纪律约束着写。

5. 哪些场景不该用嵌套ResultMap,我的替代方案

5.1 当“一次性加载全量明细”成为负担时

嵌套 ResultMap 的一个隐含前提是:你要一次性加载主表及其所有副表数据。若存在“主表很少,明细非常多”的场景,比如一张几千行的主表,每行对应几千条明细,一次 JOIN 会产生几百万行中间结果,无论对数据库还是 MyBatis 都是一场灾难。

这种场景下我的选择往往是两条独立的 SQL:

List<OrderVO> orders = orderMapper.listOrders(); List<OrderItemVO> items = orderMapper.listItemsByOrderIds(orders.stream().map(OrderVO::getId).collect(Collectors.toList())); Map<Long, List<OrderItemVO>> itemMap = items.stream().collect(Collectors.groupingBy(OrderItemVO::getOrderId)); orders.forEach(o -> o.setItems(itemMap.getOrDefault(o.getId(), Collections.emptyList())));

不要觉得这是“倒退”。在处理大数据量时,这是可控且高效的做法。两条 SQL 的数据库访问次数是固定的,不随明细数量增长,内存中也没有“全部行展开”的中间态。代码多几行,但换来的是性能和心智上的安全感。

5.2 延迟加载的核心陷阱:逻辑清晰但性能难控

嵌套 ResultMap 还有一个变体:不写嵌套映射,而是用 association/collection 的select属性触发懒加载。

<collection property="items" ofType="com.example.vo.OrderItemVO" select="com.example.mapper.OrderItemMapper.listByOrderId" column="id" />

这种写法在查询订单时不会立刻查明细,而是访问 items 属性时才触发。好处是主表查询很快,适合明细不一定被用到的场景。缺点是极易出现 N+1 问题:1000 个订单逐个触发明细查询,就是 1000 次额外 SQL。

我一般只在“确定明细一定被访问,且数据量可控”时用它。否则,一旦没控制好,数据库压力报表很难看。很多 MyBatis 面试题也喜欢问延迟加载如何触发、如何关闭,本质都是在考察你懂不懂它背后的 SQL 执行时机和连接保持问题。

5.3 我对几种方案的综合对比

顾虑到实际项目选型,我把几种方案放在一张表里对比:

方案优点缺点适用场景
嵌套 ResultMap(一条 JOIN)代码简洁,Service 层无需分组逻辑内存占用高,分页需额外处理,配置错误难排查主表数据量可控,明细不超过几百行/单
两条 SQL + 应用层分组内存可控,明细加载精确,调试直观Service 代码略多,需要手工维护分组逻辑大结果集、明细行数大、需要分页
collection + select 延迟加载主表查询快,明细按需加载N+1 风险,事务边界与连接释放需注意明细访问频率低、数量明确可控

没有一种方案是普适的。我自己的选型标准很简单:结果集是否超过内存承受基线。3000 行主表以下,且单主表明细不超过 50 条,我倾向嵌套 ResultMap;再往上,我就切两条 SQL。这套标准在过去几个项目里都比较稳。

5.4 关于“能不用就不用”的个人建议

最后说点主观的。嵌套 ResultMap 是一个非常强大但也非常“吃理解”的机制。不要在刚接触 MyBatis 的早期就盲目堆嵌套,我见过很多项目里三层嵌套加延迟加载叠在一起,出问题时连定位都困难。

如果你能把“主表合并、副表追加”这八个字在脑子中还原成“逐行遍历 → key 查缓存 → 命中则追加到已有对象”的画面,恭喜你,你已经超过 80% 只知道抄配置文件的人。后面遇到再复杂的嵌套,只要沿着这个思路去排查,基本都能快速定位问题。

我个人现在的习惯是:底层把规则想清楚,上层能不用就别用。一个项目里嵌套 ResultMap 保持在一到两层,能拆查询的场景绝不硬凹 SQL,这是我觉得最稳的平衡态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询