ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MyBatis嵌套ResultMap:主表合并副表追加原理与实战避坑

MyBatis嵌套ResultMap:主表合并副表追加原理与实战避坑 先说我当初为什么开始研究这个。项目里遇到一个很典型的场景页面上要展示订单列表每个订单要带上它的明细行。最直观的做法是查两次或者用一条 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 ListOrderItemVO items; }在不使用嵌套 ResultMap 的早期写法里我会先查订单再for循环每个订单去查明细。数据量小的时候没什么感觉订单一多数据库就被打成筛子N1 查询问题就是这么来的。另一种写法是一条大 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 这里用了一个很朴实的方案。它维护了一个类似MapString, Object的结构在源码里是ancestorObjects/rawRowValues一类角色key 由“当前 resultMapId 列前缀 主键值”拼接而成value 就是已经构建好的主表对象。处理每一行时先按当前行的主键值拼出 key。去缓存里查这个 key 是否已经存在对象。不存在调用createResultObject创建一个新的主表对象放入缓存后续当前行解析完会把这个新对象加入结果列表。存在不再创建对象直接用缓存里那个对象继续做字段映射和子表映射。这就解释了为什么 SQL 返回两行相同订单的数据最终 List 里只有一个 OrderVO。第二次遇到相同主键的行时MyBatis 在源头就“合并”掉了根本没有给 List 添加第二个相同对象的机会。有个细节值得注意这个缓存的 key 里一定包含主键值。如果主键的映射用错了列比如把普通的业务字段当成 id 映射那么两个实际不是同一条记录的数据可能因为该业务字段相同而被错误合并成同一个对象。相反如果id只配了一个能区分主表的复合主键中的一部分就会出现该合并的没合并结果列表出现重复主表对象。所以嵌套 ResultMap 场景下id配置必须精准它不是一个可有可无的装饰。2.3 副表追加的真相collection 走 addassociation 走 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 是否会提前结束“寻找同一主表对象”的扫描。resultOrderedfalse是默认值。MyBatis 会假设同一主表对象的行可能分散在 ResultSet 的任何位置因此它必须把构建过的主表对象都缓存起来直到整个 ResultSet 遍历完。这样最安全代价是内存占用高。resultOrderedtrue则告诉 MyBatis“我的 SQL 已经对主表主键排序了同一个主表的行一定是连续出现的。”这样MyBatis 可以把缓存收缩到只保留当前主表对象一旦发现主键值变化就说明当前主表对象的所有行都已经处理完可以释放引用。这个设计的本质是用“数据的物理排布”换“内存空间”。配置了 true 但 SQL 实际没有按主键排序时有一定概率遇到合并错乱问题。我一直以来的习惯是除非 SQL 里有明确的 ORDER BY 主键否则不要轻易开这个开关。我踩过这个坑后面详细说。3. 订单明细案例从SQL到ResultMap再看运行结果3.1 SQL 和 ResultMap 的完整配置百闻不如一见。我们用前面的订单/明细表完整写一遍嵌套 ResultMap。Mapper XML 里的映射配置resultMap idorderVOMap typecom.example.vo.OrderVO id propertyid columnid / result propertyorderNo columnorder_no / result propertycustomerName columncustomer_name / collection propertyitems ofTypecom.example.vo.OrderItemVO id propertyid columnitem_id / result propertyproductName columnproduct_name / result propertyquantity columnquantity / result propertyprice columnprice / /collection /resultMap select idlistOrders resultMaporderVOMap 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 { ListOrderVO listOrders(); }Service 层就一行public ListOrderVO listOrders() { return orderMapper.listOrders(); }如果你对比我最早手写分组的版本会发现这套写法把大量分组逻辑从业务代码里移除了这是嵌套 ResultMap 带来的直观收益。但我要提醒代码“看起来少”不代表不用理解它“逻辑简洁”是建立在 ORM 替你扛住了合并与追加的基础上。3.3 造数据观察 MyBatis 到底输出了什么为了方便肉眼观察我插入如下测试数据订单id1, order_noA001, customer_name张三 明细item_id101, 商品1, 数量2, 价格10.00 item_id102, 商品2, 数量1, 价格20.00 订单id2, order_noA002, customer_name李四 明细item_id103, 商品3, 数量5, 价格15.00执行查询后Service 层拿到 List 的 size 是 2每个 OrderVO 内部 items 数量分别是 2 和 1。这就是“主表合并”的客观结果SQL 返回了 3 行最终对象却只有 2 个。如果我在 XML 里把id propertyid columnid /删掉同样数据量下List 的 size 会变成 3。因为 MyBatis 判断“主表对象是否相同”时失去了稳定依据每一行都被当成了独立的主表对象合并逻辑直接失效。这不是 MyBatis 故意刁难人而是“id 列在合并环节”起着基石作用的佐证。3.4 走查第2行和第3行时的详细动作我们把三条返回行翻译成 MyBatis 的内部动作就更能理解“合并、追加”了。第 1 行order_id1item_id101。getRowValue 解析主表key 拼出来是类似orderVOMap - 1的值。缓存里没有创建一个 OrderVO(id1)放入缓存同时把当前行主表字段映射进去。解析 collection 属性得到 OrderItemVO(id101)add 到 items。第 2 行order_id1item_id102。getRowValue 解析主表key 仍然是orderVOMap - 1。缓存命中直接复用第 1 行创建的那个 OrderVO 对象。继续解析 collection得到 OrderItemVO(id102)add 到同一个 OrderVO 的 items。这一行解析结束后不会新增 OrderVO 到结果列表。第 3 行order_id2item_id103。key 变成orderVOMap - 2缓存未命中。创建一个新的 OrderVO(id2)并加入结果列表。解析 collection把 OrderItemVO(id103) 追加进去。整个过程走下来SQL 返回 3 行结果列表 2 个对象items 逐行追加。看完这个流程你应该能理解我开头说的那个“订单重复出现”的问题是怎么来的了——十有八九是id配置缺失或者是主键列名冲突导致 key 恒等于同一个值让不同订单被合并进了同一个对象。4. 嵌套ResultMap最容易踩的三个性能与配置坑4.1 内存膨胀隐患合并缓存会一直留着所有主表对象前面提到resultOrderedfalse时 MyBatis 会把所有已经构建过的主表对象都缓存到内存里直到整个 ResultSet 遍历完。这意味着什么如果你的查询结果返回一万个订单且每个订单都有若干明细MyBatis 需要在遍历期间同时保留一万个 OrderVO 及已追加的子对象在内存里。这个内存成本是“全量”的不是你只取前 20 条就只缓存 20 条。哪怕你最后只用了前 20 条数据MyBatis 在处理物理查询返回的所有行时依然会把它们全部构建并缓存。大数据量下这里的压力相当可观。优化方向有两个在 SQL 层面就把结果集缩小。用内层子查询先分页查出主表 ID再 JOIN 明细这样 MyBatis 实际处理的只有当前页的主表数据。如果确认 SQL 按主表主键排序显式配置resultOrderedtrue让 MyBatis 在切换主键时释放上一组缓存对象。这个开关能显著减少峰值内存但必须确保排序真的成立。我自己测量过一组数据查询 5000 个订单、合计 2 万条明细的场景里默认false时遍历期间峰值内存比true高出 40% 左右。不同环境和数据规模下数字会有差异但这个趋势是确定的。4.2 分页插件和嵌套ResultMap打架的问题这是个老生常谈但依然有很多人踩的坑。用 PageHelper 或类似物理分页插件时如果你直接对一条带 JOIN 的查询做分页插件会在外层包一层LIMIT这会导致分页数是“明细行数”而不是“订单数”。举例一页显示 10 个订单每个订单平均 3 条明细SQL 返回 30 行。分页插件按 30 行去做 LIMIT最终拿到的第 1 页对象数量可能不到 10 个甚至因为最后几行被截断导致部分订单的 items 不完整。我的处理思路一般是两种先分页查出当前页主表 ID 列表再查“主表数据 JOIN 明细”SQL 中带上WHERE o.id IN (当前页ID列表)。这样分页准确明细完整。如果对明细数量有把握且单条订单明细不多可以考虑放弃嵌套 ResultMap改成查两次一次查当前页订单一次查这些订单的全部明细然后在 Service 层用groupingBy组装。这个方案我会在下一节展开对比。4.3 开了 resultOrdered 但 SQL 没排序错误合并的完整复盘这个坑我印象太深。某个报表查询数据量大DBA 建议我把resultOrdered打开减少内存。我当时自信地认为“反正 MyBatis 的合并逻辑本来就能处理乱序”没仔细看 SQL 里的ORDER BY到底排序到什么粒度。结果线上出现了诡异的数据错配订单 A 的某条明细出现在订单 B 的 items 里。复盘之后真相是SQL 确实有 ORDER BY但排的是明细字段主表 id 没有作为排序主键。结果集里相同订单的行不连续MyBatis 在resultOrderedtrue时一旦遇到主键值变化就认为当前主表对象已经不需要再缓存了。之后同一主表键再次出现它只能把这个行当成一个新的主表对象去创建或者更糟把它追加到当前另一个主表对象的集合里。从那之后我总结了一条硬性规则resultOrderedtrue 必须搭配 ORDER BY 主表主键而且这个主表主键必须严格单调。如果做不到就不要碰这个开关内存贵一点但数据对最重要。4.4 主表和副表列名重复一个看起来很小的问题这个坑在 100% 的初学者项目里都会出现。主表有id副表也有idSQL 里如果没给副表 id 起别名MyBatis 的column映射就会拿到错误的列值。更麻烦的是这个“错误”不一定报异常可能只是静默地把副表主键值读成了主表 id导致 collection 追加时的 id 全部错乱。解决办法就是我前面提到的给所有副表列加清晰别名并且在 ResultMap 的 column 属性里写别名而不是裸列名。用 tab 键推导 SQL 的时候顺手打上 AS能省掉后面几个小时的排查时间。4.5 自关联查询嵌套层级越深越脆自关联表用嵌套 ResultMap 也很常见比如部门-子部门、评论-回复。多级嵌套时每增加一层MyBatis 需要维护的缓存键组合就会更复杂列名冲突概率也更大。我见过一个三级嵌套的配置主表和子表用了同一批字段名结果查出来的树状结构里子对象的值串到了父对象上。建议是遇到二级以上嵌套优先拆成多次查询在应用层组装树实在要在 SQL 里一次查完所有层级的列都要起唯一别名并且每一级的id都不能缺。嵌套不是不能写是要用纪律约束着写。5. 哪些场景不该用嵌套ResultMap我的替代方案5.1 当“一次性加载全量明细”成为负担时嵌套 ResultMap 的一个隐含前提是你要一次性加载主表及其所有副表数据。若存在“主表很少明细非常多”的场景比如一张几千行的主表每行对应几千条明细一次 JOIN 会产生几百万行中间结果无论对数据库还是 MyBatis 都是一场灾难。这种场景下我的选择往往是两条独立的 SQLListOrderVO orders orderMapper.listOrders(); ListOrderItemVO items orderMapper.listItemsByOrderIds(orders.stream().map(OrderVO::getId).collect(Collectors.toList())); MapLong, ListOrderItemVO 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 propertyitems ofTypecom.example.vo.OrderItemVO selectcom.example.mapper.OrderItemMapper.listByOrderId columnid /这种写法在查询订单时不会立刻查明细而是访问 items 属性时才触发。好处是主表查询很快适合明细不一定被用到的场景。缺点是极易出现 N1 问题1000 个订单逐个触发明细查询就是 1000 次额外 SQL。我一般只在“确定明细一定被访问且数据量可控”时用它。否则一旦没控制好数据库压力报表很难看。很多 MyBatis 面试题也喜欢问延迟加载如何触发、如何关闭本质都是在考察你懂不懂它背后的 SQL 执行时机和连接保持问题。5.3 我对几种方案的综合对比顾虑到实际项目选型我把几种方案放在一张表里对比方案优点缺点适用场景嵌套 ResultMap一条 JOIN代码简洁Service 层无需分组逻辑内存占用高分页需额外处理配置错误难排查主表数据量可控明细不超过几百行/单两条 SQL 应用层分组内存可控明细加载精确调试直观Service 代码略多需要手工维护分组逻辑大结果集、明细行数大、需要分页collection select 延迟加载主表查询快明细按需加载N1 风险事务边界与连接释放需注意明细访问频率低、数量明确可控没有一种方案是普适的。我自己的选型标准很简单结果集是否超过内存承受基线。3000 行主表以下且单主表明细不超过 50 条我倾向嵌套 ResultMap再往上我就切两条 SQL。这套标准在过去几个项目里都比较稳。5.4 关于“能不用就不用”的个人建议最后说点主观的。嵌套 ResultMap 是一个非常强大但也非常“吃理解”的机制。不要在刚接触 MyBatis 的早期就盲目堆嵌套我见过很多项目里三层嵌套加延迟加载叠在一起出问题时连定位都困难。如果你能把“主表合并、副表追加”这八个字在脑子中还原成“逐行遍历 → key 查缓存 → 命中则追加到已有对象”的画面恭喜你你已经超过 80% 只知道抄配置文件的人。后面遇到再复杂的嵌套只要沿着这个思路去排查基本都能快速定位问题。我个人现在的习惯是底层把规则想清楚上层能不用就别用。一个项目里嵌套 ResultMap 保持在一到两层能拆查询的场景绝不硬凹 SQL这是我觉得最稳的平衡态。
返回列表