深入理解MyBatis:collection集合封装的底层原理与实现细节

相信很多人在使用 MyBatis 做一对多关联查询时,都会用到resultMap中的collection标签,能轻松把数据库中扁平化的多行数据,封装成包含嵌套集合的 Java 对象。但你有没有好奇过,MyBatis 底层到底是怎么完成这个 "数据重组" 的?为什么同样的主键数据不会重复创建对象?今天我就结合实际案例,带大家一步步拆解collection集合封装的完整流程和核心机制。

一、先看一个最典型的一对多场景

我们先从最常见的 "用户 - 订单" 关系入手,这也是理解一对多封装的最佳案例。

假设我们执行一条关联查询 SQL,从数据库中查出了以下 3 条原始数据:

id username order_id order_name
1 李四 1001 洗衣机
1 李四 1002 空调
2 张三 1003 手机

很明显,这 3 条数据对应的业务逻辑是:

  • 用户id=1(李四)有 2 个订单(1001、1002)

  • 用户id=2(张三)有 1 个订单(1003)

我们的目标不是得到 3 个独立的行数据,而是封装成 2 个User对象,每个User对象内部包含一个List<Order>集合,存储该用户的所有订单。这正是collection标签要解决的核心问题。

二、常规的 MyBatis 映射写法

要实现上述效果,我们首先会在 Mapper XML 中定义对应的resultMap,通过collection标签指定集合属性的映射规则:

xml 复制代码
<!-- 订单对象的映射 -->
<resultMap id="OrderResultMap" type="com.example.entity.Order">
    <id property="orderId" column="order_id"/>
    <result property="orderName" column="order_name"/>
</resultMap>

<!-- 用户对象的映射,包含订单集合 -->
<resultMap id="UserWithOrdersResultMap" type="com.example.entity.User">
    <!-- 主键必须用id标签指定!这是底层去重的关键 -->
    <id property="id" column="id"/>
    <result property="username" column="username"/>
    <!-- 集合属性:orders,对应Order类型 -->
    <collection property="orders" ofType="com.example.entity.Order" resultMap="OrderResultMap"/>
</resultMap>

<!-- 关联查询SQL -->
<select id="selectUserWithOrders" resultMap="UserWithOrdersResultMap">
    SELECT u.id, u.username, o.order_id, o.order_name
    FROM user u
    LEFT JOIN order o ON u.id = o.user_id
</select>

对应的 Java 实体类结构如下:

java 复制代码
public class User {
    private Long id;
    private String username;
    private List<Order> orders; // 一对多集合属性
    // getter、setter省略
}

public class Order {
    private Long orderId;
    private String orderName;
    // getter、setter省略
}

写好这些后,调用 Mapper 方法就能直接得到List<User>,每个 User 都带着自己的订单集合。但 MyBatis 到底是怎么把 3 行数据变成 2 个 User 对象的?这就需要深入底层流程了。

三、核心!collection 底层封装的完整流程

MyBatis 处理collection的核心逻辑,本质上是 "基于主键的缓存去重 + 逐行数据填充"。整个过程围绕 "遍历 ResultSet 的每一行数据" 展开,我们就以上面的 3 条数据为例,一步步拆解:

第一步:处理第 1 行数据(id=1,order_id=1001)

  1. 检查主对象缓存 :MyBatis 会先提取当前行的主键值(这里是id=1),去内部的一个临时缓存中查找,是否已经存在主键为 1 的User对象。

  2. 创建主对象并填充基本属性 :第一次查询,缓存中没有,所以创建一个新的User对象,将id=1username=李四赋值给该对象的对应属性。

  3. 处理集合属性 :检查User对象的orders集合是否存在,不存在则创建一个空的ArrayList(默认实现)。

  4. 创建嵌套对象并加入集合 :提取当前行的嵌套对象主键(order_id=1001),同样检查缓存(嵌套对象也有自己的缓存),没有则创建新的Order对象,赋值orderId=1001orderName=洗衣机,然后将这个 Order 对象添加到orders集合中。

  5. 缓存主对象 :将创建好的User对象(id=1)放入临时缓存,供后续行使用。

此时,缓存中有 1 个 User 对象,其 orders 集合中有 1 个 Order 对象。

第二步:处理第 2 行数据(id=1,order_id=1002)

  1. 检查主对象缓存 :提取主键id=1,发现缓存中已经存在对应的 User 对象,跳过主对象的创建和基本属性赋值(这就是为什么不会重复创建 id=1 的 User)。

  2. 直接处理集合属性 :发现orders集合已经存在,不再创建新集合。

  3. 创建新的嵌套对象 :提取order_id=1002,检查嵌套对象缓存,没有则创建新的 Order 对象,赋值后添加到已有的orders集合中。

此时,id=1 的 User 对象的 orders 集合中,已经有 2 个 Order 对象了。

第三步:处理第 3 行数据(id=2,order_id=1003)

  1. 检查主对象缓存 :提取主键id=2,缓存中不存在,创建新的 User 对象,填充id=2username=张三

  2. 创建新的集合 :检查orders集合不存在,创建新的 ArrayList。

  3. 创建嵌套对象并加入集合 :提取order_id=1003,创建 Order 对象并赋值,添加到集合中。

  4. 缓存新的主对象:将 id=2 的 User 对象放入缓存。

最终结果

遍历完所有行数据后,MyBatis 将缓存中的所有 User 对象收集起来,返回List<User>。最终我们得到的就是:

  • 1 个 id=1 的 User,orders 集合有 2 个元素

  • 1 个 id=2 的 User,orders 集合有 1 个元素

完美符合我们的业务预期。

四、关键机制:为什么必须指定 id 标签?

有人可能会问:如果我把resultMap中的<id>标签换成普通的<result>标签,会发生什么?

答案是:会导致主对象重复创建,集合数据错乱

这是因为,<id>标签标记的是对象的唯一标识,MyBatis 正是通过这个唯一标识来生成 "行 ID",作为临时缓存的 key。如果没有指定<id>,MyBatis 会把当前行的所有字段值拼接起来作为 key,这样即使主键相同,只要其他字段有一点点差异(比如嵌套对象的字段),就会被认为是不同的对象,从而重复创建主对象。

同样的,嵌套对象(比如上面的 Order)也建议指定<id>标签,否则嵌套对象也会出现重复创建的问题。

五、使用 collection 的几个重要注意事项

  1. 必须为主对象和嵌套对象指定主键(id 标签):这是保证对象不重复创建的核心,也是性能优化的关键。

  2. SQL 查询必须包含所有映射的字段:尤其是主键字段,如果查询结果中没有主键值,MyBatis 无法进行缓存判断,会导致每一行都创建新对象。

  3. 避免笛卡尔积过大 :如果一对多的两边数据量都很大,关联查询会产生大量的重复数据,影响性能。这种情况下建议使用 "分步查询"(select属性)代替联合查询。

  4. 集合的默认实现是 ArrayList :如果需要使用其他集合类型(比如 Set),可以通过collection标签的javaType属性指定。

六、总结

MyBatis 的collection集合封装,本质上是一个 "先缓存主对象,再逐行填充集合" 的过程。它通过<id>标签定义的唯一标识来维护一个临时缓存,确保相同主键的主对象只会被创建一次,后续行数据只会往已有的集合中添加嵌套对象。

相关推荐
Cheng小攸1 小时前
协议分析与分析工具(二)
开发语言·php
贺国亚1 小时前
06-奢侈零售VIP-Clienteling-Agent
开发语言·python·零售
我命由我123451 小时前
Android 开发问题:获取到的 Android ID 发生了变化
android·java·开发语言·java-ee·android studio·android jetpack·android runtime
lazy H1 小时前
Spring Boot 连接 MySQL 失败怎么办?常见报错原因和解决方法总结
spring boot·后端·学习·mysql·spring
Solis程序员1 小时前
Raft:分布式系统的定海神针
java·分布式·kafka·rabbitmq·agent·raft
我登哥MVP1 小时前
SpringCloud Alibaba 核心组件解析:服务调用和负载均衡
java·spring boot·后端·spring·spring cloud·java-ee·负载均衡
nix.gnehc1 小时前
Python 内存管理深度解析
开发语言·python
云烟成雨TD1 小时前
Agent Scope Java 2.x 系列【13】权限系统
java·人工智能·agent
uoKent1 小时前
Redis环境搭建与redis-cli基础操作
数据库·redis·缓存