MyBatis 高级映射与延迟加载完全指南
一、概述
在实际企业级开发中,数据库表之间往往存在复杂的关联关系(一对一、一对多)。MyBatis 作为优秀的持久层框架,通过 resultMap 的高级映射 功能,提供了优雅的解决方案来处理这些关联关系。
同时,为了优化性能,MyBatis 引入了 延迟加载(Lazy Loading) 机制,允许我们在真正需要关联数据时才去查询数据库,避免不必要的性能开销。
本文将深入剖析 MyBatis 的高级映射(association、collection)及延迟加载的配置与使用,并结合 N+1 查询问题给出最佳实践。
二、高级映射:关联关系处理
在 MyBatis 中,处理关联关系主要依赖 <resultMap> 的两个子标签:
<association>:处理 一对一(One-to-One) 关系。<collection>:处理 一对多(One-to-Many) 关系。
这两种关联映射都支持两种不同的实现方式:
- 嵌套结果(Nested Results) :通过一次
JOIN查询,将主表和关联表的数据一次性查出来,然后进行映射(性能最优)。 - 嵌套查询(Nested Queries):先查主表,然后通过另一条 SQL 语句去查关联表(配合延迟加载,实现按需查询)。
2.1 一对一映射(association)
场景:每辆车(Car)都有一个发动机(Engine)。
实体类
java
public class Car {
private Long id;
private String carNum;
private String brand;
private Engine engine; // 一对一关联
// getter/setter...
}
public class Engine {
private Long id;
private String type; // V8, 电动
private Integer power; // 功率
}
方式一:嵌套结果(JOIN 查询,推荐)
原理:一次 SQL 搞定,性能最高。
xml
<resultMap id="CarEngineMap" type="car">
<id property="id" column="id"/>
<result property="carNum" column="car_num"/>
<result property="brand" column="brand"/>
<!-- 一对一:映射关联的 Engine 对象 -->
<association property="engine" javaType="Engine">
<id property="id" column="engine_id"/>
<result property="type" column="engine_type"/>
<result property="power" column="engine_power"/>
</association>
</resultMap>
<select id="selectCarWithEngine" resultMap="CarEngineMap">
SELECT
c.id, c.car_num, c.brand,
e.id AS engine_id,
e.type AS engine_type,
e.power AS engine_power
FROM t_car c
LEFT JOIN t_engine e ON c.id = e.car_id
WHERE c.id = #{id}
</select>
方式二:嵌套查询(配合延迟加载)
原理 :先查 Car,再根据 car_id 单独查 Engine。
xml
<resultMap id="CarEngineMapLazy" type="car">
<id property="id" column="id"/>
<result property="carNum" column="car_num"/>
<result property="brand" column="brand"/>
<!-- select:指向另一个 Mapper 的查询方法,column:传给子查询的列名 -->
<association property="engine"
javaType="Engine"
select="com.xie.mapper.EngineMapper.selectByCarId"
column="id"
fetchType="lazy"/> <!-- lazy:延迟加载 -->
</resultMap>
<select id="selectCarLazy" resultMap="CarEngineMapLazy">
SELECT id, car_num, brand FROM t_car WHERE id = #{id}
</select>
2.2 一对多映射(collection)
场景:一辆车(Car)有多个配件(Part)。
实体类
java
public class Car {
private Long id;
private String carNum;
private String brand;
private List<Part> parts; // 一对多关联
}
public class Part {
private Long id;
private String name;
private Double price;
}
方式一:嵌套结果(JOIN 查询,推荐)
xml
<resultMap id="CarPartsMap" type="car">
<id property="id" column="id"/>
<result property="carNum" column="car_num"/>
<result property="brand" column="brand"/>
<!-- 一对多:ofType 指定集合中元素的类型 -->
<collection property="parts" ofType="Part">
<id property="id" column="part_id"/>
<result property="name" column="part_name"/>
<result property="price" column="part_price"/>
</collection>
</resultMap>
<select id="selectCarWithParts" resultMap="CarPartsMap">
SELECT
c.id, c.car_num, c.brand,
p.id AS part_id,
p.name AS part_name,
p.price AS part_price
FROM t_car c
LEFT JOIN t_part p ON c.id = p.car_id
WHERE c.id = #{id}
</select>
注意 :
collection必须使用ofType指定集合泛型类型,而不是javaType(javaType默认就是List)。
方式二:嵌套查询(配合延迟加载)
xml
<resultMap id="CarPartsMapLazy" type="car">
<id property="id" column="id"/>
<result property="carNum" column="car_num"/>
<result property="brand" column="brand"/>
<collection property="parts"
ofType="Part"
select="com.xie.mapper.PartMapper.selectByCarId"
column="id"
fetchType="lazy"/>
</resultMap>
三、延迟加载(Lazy Loading)
3.1 什么是延迟加载?
延迟加载是指:当 MyBatis 查询主对象时,不会立即查询关联对象,只有当我们调用关联对象的 getter 方法(如 car.getEngine())时,MyBatis 才会触发关联查询。
3.2 配置方式
全局配置(mybatis-config.xml)
xml
<settings>
<!-- 开启延迟加载(默认 false) -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 激进加载(默认 true):如果为 true,只要主对象被访问,所有关联对象都会被加载。
设为 false 则只会在显式调用 getter 时加载,推荐关闭 -->
<setting name="aggressiveLazyLoading" value="false"/>
<!-- 触发延迟加载的方法列表,用逗号分隔 -->
<setting name="lazyLoadTriggerMethods" value="equals,clone,hashCode,toString"/>
</settings>
局部配置(在 association / collection 中覆盖)
通过 fetchType 属性覆盖全局配置:
fetchType="eager":立即加载(不使用延迟)。fetchType="lazy":延迟加载(优先级高于全局配置)。
xml
<association property="engine"
select="...selectByCarId"
column="id"
fetchType="lazy"/> <!-- 该关联使用延迟加载 -->
3.3 核心原理(面试重点)
- MyBatis 为返回的
Car对象生成一个代理对象(通过 CGLIB 或 Javassist)。 - 当你调用
car.getEngine()时,代理对象会拦截该调用。 - 它判断
engine属性是否为null且未加载,如果是,则执行预先配置好的selectSQL,将查询结果赋值给该属性。 - 返回真实的结果。
四、N+1 查询问题与性能陷阱
4.1 什么是 N+1 问题?
当使用嵌套查询 时,如果主查询返回了 N 条记录,那么为了加载每条记录的关联对象,MyBatis 会额外执行 N 次子查询,总共执行 1 + N 次 SQL。
示例:
java
List<Car> cars = carMapper.selectAll(); // 查询 10 辆车 (1 次 SQL)
for (Car car : cars) {
// 访问 parts 时,会触发 10 次额外的 SQL (N 次 SQL)
System.out.println(car.getParts().size());
}
即便开启了延迟加载,N+1 次查询依然会发生,只不过从"启动时立即发生"推迟到了"遍历访问时发生"。
4.2 解决方案:使用嵌套结果(JOIN)
这是解决 N+1 问题的唯一根本方法。
通过一次 JOIN 查询,将所有数据(主表 + 关联表)一次性查出来,MyBatis 通过 resultMap 自动去重并组装成对象树。
xml
<select id="selectAllWithParts" resultMap="CarPartsMap">
SELECT c.*, p.*
FROM t_car c
LEFT JOIN t_part p ON c.id = p.car_id
</select>
此时只有 1 次 SQL,性能最高。
4.3 策略选择
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 数据量小 / 必须立即展示 | 嵌套结果(JOIN) | 性能最好,一次查出所有数据 |
| 主表数据量大 / 关联字段不常用 | 嵌套查询 + 延迟加载 | 节省内存和带宽,按需加载 |
| 同时加载多层关联 | 谨慎使用嵌套查询 | 容易导致 N+1 爆炸,优先使用 JOIN |
五、高级映射与延迟加载最佳实践
5.1 分步查询与延迟加载的实战组合
利用延迟加载,我们可以将复杂的多表关联查询拆分为多个独立的单表查询,提高代码复用性。
Mapper 接口:
java
public interface CarMapper {
Car selectById(Long id); // 只查 Car
}
public interface EngineMapper {
Engine selectByCarId(Long carId); // 只查 Engine
}
XML 组合:
xml
<resultMap id="CarMap" type="car">
<id column="id" property="id"/>
<!-- 此处相当于在 CarMapper 中调用了 EngineMapper 的方法 -->
<association property="engine"
select="com.xie.mapper.EngineMapper.selectByCarId"
column="id"
fetchType="lazy"/>
</resultMap>
这种写法使得 SQL 逻辑清晰,且 selectByCarId 方法可以在其他地方复用。
5.2 警惕 aggressiveLazyLoading
在旧版 MyBatis 中,如果 aggressiveLazyLoading=true,只要调用了主对象的任何 getter 方法(即使不是关联属性的 getter),都会触发所有延迟加载关联的查询。
建议 :在 MyBatis 3.x 及以上,保持 aggressiveLazyLoading=false(虽然默认已改为 false,但最好显式配置),仅在访问具体关联对象时触发查询。
5.3 在 Spring 环境中处理 Session 问题
延迟加载需要 SqlSession 保持开启状态。如果在 Spring MVC 的 Controller 中直接返回实体类,而视图层渲染时才触发延迟加载,此时 Session 可能已关闭,会抛出 LazyInitializationException。
解决方案:
- 使用 Spring 的
OpenSessionInViewFilter(OSIV)模式,将 Session 生命周期延长到请求结束。 - 在 Service 层使用
@Transactional,确保事务提交前完成所有关联对象的加载(触发加载)。
六、常见问题排查
| 异常信息 | 原因 | 解决方案 |
|---|---|---|
LazyInitializationException |
Session 已关闭后尝试加载懒加载数据 | 配置 OSIV,或确保在 Session 开启期间访问关联数据 |
明明配置了 lazy=true,但还是立即执行了查询 |
aggressiveLazyLoading=true 或调用了 hashCode/equals 等方法 |
设置 aggressiveLazyLoading=false |
| 一对多关联中数据重复 | JOIN 查询时未配置 <id> 标签 |
务必在 resultMap 中配置 <id>,MyBatis 依靠它去重 |
| 查询性能极差 | 使用嵌套查询且命中 N+1 场景 | 改用嵌套结果(JOIN)一次性查询 |
七、总结
| 知识点 | 核心要点 |
|---|---|
一对一(association) |
使用 javaType,处理主表与附属表的关系 |
一对多(collection) |
使用 ofType,处理主表与子表集合的关系 |
| 嵌套结果(Nested Results) | 使用 JOIN,一次 SQL,性能最优,推荐优先使用 |
| 嵌套查询(Nested Queries) | 拆分 SQL,代码解耦,但存在 N+1 风险 |
| 延迟加载 | 通过 lazyLoadingEnabled 开启,配合 fetchType 精细化控制 |
| N+1 问题 | 嵌套查询下的性能陷阱,必须用 JOIN 重构 |
一句话总结
在 MyBatis 中,优先使用
resultMap+JOIN进行关联映射(嵌套结果)以获取最佳性能;当关联数据庞大且访问频率低时,借助select属性实现分步查询,并开启延迟加载(嵌套查询)来优化内存与响应时间。
掌握高级映射与延迟加载的原理,不仅能让你写出高性能的数据库访问代码,更能让你在系统性能调优时游刃有余。