相信很多人在使用 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标签指定集合属性的映射规则:
<!-- 订单对象的映射 --><resultMapid="OrderResultMap"type="com.example.entity.Order"><idproperty="orderId"column="order_id"/><resultproperty="orderName"column="order_name"/></resultMap><!-- 用户对象的映射,包含订单集合 --><resultMapid="UserWithOrdersResultMap"type="com.example.entity.User"><!-- 主键必须用id标签指定!这是底层去重的关键 --><idproperty="id"column="id"/><resultproperty="username"column="username"/><!-- 集合属性:orders,对应Order类型 --><collectionproperty="orders"ofType="com.example.entity.Order"resultMap="OrderResultMap"/></resultMap><!-- 关联查询SQL --><selectid="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 实体类结构如下:
publicclassUser{privateLongid;privateStringusername;privateList<Order>orders;// 一对多集合属性// getter、setter省略}publicclassOrder{privateLongorderId;privateStringorderName;// getter、setter省略}写好这些后,调用 Mapper 方法就能直接得到List<User>,每个 User 都带着自己的订单集合。但 MyBatis 到底是怎么把 3 行数据变成 2 个 User 对象的?这就需要深入底层流程了。
三、核心!collection 底层封装的完整流程
MyBatis 处理collection的核心逻辑,本质上是“基于主键的缓存去重 + 逐行数据填充”。整个过程围绕 “遍历 ResultSet 的每一行数据” 展开,我们就以上面的 3 条数据为例,一步步拆解:
第一步:处理第 1 行数据(id=1,order_id=1001)
检查主对象缓存:MyBatis 会先提取当前行的主键值(这里是
id=1),去内部的一个临时缓存中查找,是否已经存在主键为 1 的User对象。创建主对象并填充基本属性:第一次查询,缓存中没有,所以创建一个新的
User对象,将id=1、username=李四赋值给该对象的对应属性。处理集合属性:检查
User对象的orders集合是否存在,不存在则创建一个空的ArrayList(默认实现)。创建嵌套对象并加入集合:提取当前行的嵌套对象主键(
order_id=1001),同样检查缓存(嵌套对象也有自己的缓存),没有则创建新的Order对象,赋值orderId=1001、orderName=洗衣机,然后将这个 Order 对象添加到orders集合中。缓存主对象:将创建好的
User对象(id=1)放入临时缓存,供后续行使用。
此时,缓存中有 1 个 User 对象,其 orders 集合中有 1 个 Order 对象。
第二步:处理第 2 行数据(id=1,order_id=1002)
检查主对象缓存:提取主键
id=1,发现缓存中已经存在对应的 User 对象,跳过主对象的创建和基本属性赋值(这就是为什么不会重复创建 id=1 的 User)。直接处理集合属性:发现
orders集合已经存在,不再创建新集合。创建新的嵌套对象:提取
order_id=1002,检查嵌套对象缓存,没有则创建新的 Order 对象,赋值后添加到已有的orders集合中。
此时,id=1 的 User 对象的 orders 集合中,已经有 2 个 Order 对象了。
第三步:处理第 3 行数据(id=2,order_id=1003)
检查主对象缓存:提取主键
id=2,缓存中不存在,创建新的 User 对象,填充id=2、username=张三。创建新的集合:检查
orders集合不存在,创建新的 ArrayList。创建嵌套对象并加入集合:提取
order_id=1003,创建 Order 对象并赋值,添加到集合中。缓存新的主对象:将 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 的几个重要注意事项
必须为主对象和嵌套对象指定主键(id 标签):这是保证对象不重复创建的核心,也是性能优化的关键。
SQL 查询必须包含所有映射的字段:尤其是主键字段,如果查询结果中没有主键值,MyBatis 无法进行缓存判断,会导致每一行都创建新对象。
避免笛卡尔积过大:如果一对多的两边数据量都很大,关联查询会产生大量的重复数据,影响性能。这种情况下建议使用 “分步查询”(
select属性)代替联合查询。集合的默认实现是 ArrayList:如果需要使用其他集合类型(比如 Set),可以通过
collection标签的javaType属性指定。
六、总结
MyBatis 的collection集合封装,本质上是一个 “先缓存主对象,再逐行填充集合” 的过程。它通过<id>标签定义的唯一标识来维护一个临时缓存,确保相同主键的主对象只会被创建一次,后续行数据只会往已有的集合中添加嵌套对象。