作为 Java 后端开发者,我们在使用 MyBatis 处理关联查询时,经常会遇到一个问题:查询一个主对象时,会同时查询出所有关联对象,即使这些关联对象我们根本用不到。这不仅会导致不必要的数据库查询,还会占用大量内存,影响系统性能。
而 延迟加载(懒加载) 正是解决这个问题的最佳方案。它允许我们在真正需要使用关联对象时,才去执行 SQL 查询加载数据,而不是一次性加载所有关联数据。
面试时,MyBatis 延迟加载更是高频考点,面试官会层层深挖:
- MyBatis 支持延迟加载吗?哪些关联查询支持延迟加载?
- 延迟加载的底层原理是什么?基于什么设计模式?
lazyLoadingEnabled和aggressiveLazyLoading有什么区别?- 为什么嵌套结果不支持延迟加载?
- 延迟加载有哪些常见坑点?如何避免?
这篇文章,我们就从基础使用→配置方式→底层原理→执行流程→坑点与最佳实践五个维度,彻底搞懂 MyBatis 延迟加载。不仅会讲清楚理论,更会结合源码和实战案例,让你看完既能轻松应对面试,又能在实际项目中正确使用延迟加载。

一、先搞懂:什么是延迟加载?
1. 延迟加载的定义
延迟加载(Lazy Loading),也叫懒加载,是一种按需加载 的设计思想。它的核心是:只有当真正需要使用某个对象时,才会去加载这个对象的数据。
在 MyBatis 中,延迟加载主要用于处理关联查询(一对一、一对多、多对多)。当我们查询主对象时,不会立即查询关联对象的数据,而是为关联对象生成一个代理对象。只有当我们调用关联对象的 getter 方法时,才会触发真正的 SQL 查询,加载关联对象的数据。
2. 为什么需要延迟加载?
我们用一个最常见的场景来对比:查询用户信息,同时查询用户的订单信息。
不使用延迟加载(立即加载)
sql
-- 一次性查询用户和所有订单
SELECT u.*, o.* FROM user u LEFT JOIN order o ON u.id = o.user_id WHERE u.id = 1
这种方式的问题:
- 如果用户有 1000 个订单,会一次性查询出所有订单数据
- 如果我们只需要用户的基本信息,不需要订单信息,这些订单查询就是完全浪费的
- 数据量越大,性能损耗越严重,甚至会导致内存溢出
使用延迟加载
sql
-- 第一步:只查询用户基本信息
SELECT * FROM user WHERE id = 1
-- 第二步:只有当调用user.getOrders()时,才执行订单查询
SELECT * FROM order WHERE user_id = 1
这种方式的优势:
- 只查询需要的数据,减少不必要的数据库查询
- 降低内存占用,提高系统性能
- 对于关联对象不常访问的场景,性能提升非常明显
3. MyBatis 对延迟加载的支持
MyBatis 是支持延迟加载的,但只支持嵌套查询(也叫子查询)的延迟加载,不支持嵌套结果的延迟加载。
这是一个非常重要的结论,也是面试最常考的点。很多人以为所有关联查询都支持延迟加载,其实不然。
| 关联查询方式 | 是否支持延迟加载 | 原理 |
|---|---|---|
嵌套查询(select属性) |
✅ | 先查询主表,关联对象用代理对象代替,需要时再执行子查询 |
嵌套结果(resultMap嵌套) |
❌ | 一次性执行多表联查,将结果映射到主对象和关联对象 |
二、延迟加载的配置方式
MyBatis 提供了两种配置延迟加载的方式:全局配置 和局部配置。局部配置的优先级高于全局配置。
1. 全局配置
在mybatis-config.xml文件中配置全局延迟加载参数:
XML
<settings>
<!-- 开启全局延迟加载,默认值为false -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 关闭侵入式延迟加载,默认值为false(MyBatis 3.4.1及以后) -->
<setting name="aggressiveLazyLoading" value="false"/>
<!-- 指定延迟加载使用的动态代理工厂,默认是cglib -->
<setting name="proxyFactory" value="cglib"/>
</settings>
两个核心参数详解
lazyLoadingEnabled:全局延迟加载开关。设置为true时,所有关联查询都会默认使用延迟加载;设置为false时,所有关联查询都会立即加载。aggressiveLazyLoading:侵入式延迟加载开关。这个参数非常重要,很多人搞不清它的作用:- 当设置为
true时:调用主对象的任何方法 (如toString()、hashCode()、equals())都会触发所有延迟加载属性的加载 - 当设置为
false时:只有调用对应延迟加载属性的 getter 方法时,才会触发该属性的加载
- 当设置为
注意 :MyBatis 3.4.1 及以后版本,aggressiveLazyLoading的默认值已经改为false,这是更合理的默认行为。在这之前的版本,默认值是true。
2. 局部配置
如果不想全局开启延迟加载,可以在单个关联查询上通过fetchType属性单独配置:
XML
<resultMap id="userResultMap" type="User">
<id column="id" property="id"/>
<result column="name" property="name"/>
<!-- 一对一关联,开启延迟加载 -->
<association property="dept" column="dept_id"
select="com.example.mapper.DeptMapper.getDeptById"
fetchType="lazy"/>
<!-- 一对多关联,关闭延迟加载(立即加载) -->
<collection property="orders" column="id"
select="com.example.mapper.OrderMapper.getOrdersByUserId"
fetchType="eager"/>
</resultMap>
fetchType属性有两个可选值:
lazy:延迟加载eager:立即加载
局部配置的优先级高于全局配置。即使全局关闭了延迟加载,局部设置fetchType="lazy"仍然会生效。
三、延迟加载的底层原理:动态代理
MyBatis 延迟加载的核心原理是动态代理 。当 MyBatis 发现某个关联属性需要延迟加载时,不会直接实例化这个属性,而是为它生成一个代理对象。当调用这个代理对象的 getter 方法时,才会触发真正的 SQL 查询,加载真实数据。
1. 动态代理的选择
MyBatis 提供了两种动态代理实现,通过proxyFactory参数指定:
- CGLIB 动态代理:默认实现,基于继承实现,可以代理任何非 final 类
- Javassist 动态代理:基于字节码生成实现,性能比 CGLIB 略高
为什么默认使用 CGLIB? 因为我们要代理的是实体类,而不是接口。JDK 动态代理只能代理接口,无法代理普通类,所以 MyBatis 选择了 CGLIB 和 Javassist 这两种可以代理类的动态代理实现。
2. 核心接口:ProxyFactory
MyBatis 定义了ProxyFactory接口,用于生成代理对象:
java
public interface ProxyFactory {
// 为目标对象生成代理
Object createProxy(Object target, ResultLoaderMap lazyLoader, Configuration configuration, ObjectFactory objectFactory, List<Class<?>> constructorArgTypes, List<Object> constructorArgs);
}
默认实现是CglibProxyFactory,它使用 CGLIB 的Enhancer类生成代理对象。
3. 代理对象的生成过程
当 MyBatis 处理结果集时,如果发现某个关联属性配置了延迟加载,会执行以下步骤:
- 不直接实例化关联对象
- 创建一个
ResultLoader对象,保存关联查询的 SQL 语句、参数和 Mapper 信息 - 调用
ProxyFactory.createProxy()方法,为关联对象生成一个代理对象 - 将代理对象设置到主对象的对应属性上
4. 代理对象的执行流程
当我们调用代理对象的 getter 方法时,会触发代理对象的拦截器方法,执行以下流程:
- 检查关联对象是否已经加载
- 如果没有加载,调用
ResultLoader.loadResult()方法执行 SQL 查询 - 将查询得到的真实对象替换代理对象
- 返回真实对象的对应方法结果
核心源码(CglibProxyFactory):
java
public static class CglibMethodInterceptor implements MethodInterceptor {
private final Object target;
private final ResultLoaderMap lazyLoader;
private final Configuration configuration;
private final ObjectFactory objectFactory;
private final List<Class<?>> constructorArgTypes;
private final List<Object> constructorArgs;
@Override
public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
// 检查是否是延迟加载的属性的getter方法
if (lazyLoader != null && !lazyLoader.isLoaded()) {
// 如果是getter方法,触发加载
if (isGetter(method)) {
lazyLoader.load();
}
// 如果aggressiveLazyLoading为true,任何方法调用都触发所有延迟加载
else if (configuration.isAggressiveLazyLoading()) {
lazyLoader.loadAll();
}
}
// 执行真实对象的方法
return methodProxy.invoke(target, args);
}
}
四、延迟加载的完整执行流程
我们以查询用户及其订单为例,完整拆解延迟加载的执行流程:
步骤 1:执行主查询
java
User user = userMapper.getUserById(1L);
MyBatis 执行主查询 SQL:
sql
SELECT * FROM user WHERE id = 1
步骤 2:结果集映射
DefaultResultSetHandler处理结果集,映射 User 对象的基本属性。当处理到orders属性时:
- 发现
orders配置了延迟加载 - 创建
ResultLoader对象,保存订单查询的信息:- Mapper 方法:
com.example.mapper.OrderMapper.getOrdersByUserId - 参数:
1(用户 ID)
- Mapper 方法:
- 调用
CglibProxyFactory生成List<Order>的代理对象 - 将代理对象设置到
user.orders属性上
此时,user对象已经返回,但user.orders是一个代理对象,还没有执行订单查询。
步骤 3:调用 getter 方法触发加载
java
// 此时才会触发订单查询
List<Order> orders = user.getOrders();
代理对象的intercept方法被调用:
-
检查
orders是否已经加载,发现没有加载 -
调用
ResultLoader.loadResult()方法 -
从 SqlSession 中获取
OrderMapper,执行getOrdersByUserId(1L) -
执行订单查询 SQL: sql
sqlSELECT * FROM order WHERE user_id = 1 -
将查询得到的真实
List<Order>对象替换代理对象 -
返回真实的订单列表
步骤 4:后续调用直接返回真实对象
java
// 第二次调用,直接返回真实对象,不会再执行SQL
List<Order> orders2 = user.getOrders();
此时,orders已经加载完成,后续调用会直接返回真实对象,不会再执行 SQL 查询。
五、两种延迟加载模式
根据aggressiveLazyLoading参数的不同,MyBatis 有两种延迟加载模式:
1. 侵入式延迟加载(aggressiveLazyLoading = true)
当调用主对象的任何方法 时,都会触发所有延迟加载属性的加载。
示例:
java
User user = userMapper.getUserById(1L);
// 调用toString()方法,会触发orders和dept两个延迟加载属性的加载
System.out.println(user.toString());
这种模式的缺点很明显:即使我们不需要关联对象,只要调用了主对象的任何方法,都会触发所有关联查询,失去了延迟加载的意义。
2. 按需延迟加载(aggressiveLazyLoading = false)
只有调用对应延迟加载属性的 getter 方法时,才会触发该属性的加载。
示例:
java
User user = userMapper.getUserById(1L);
// 调用toString()方法,不会触发任何延迟加载
System.out.println(user.toString());
// 只有调用getOrders()时,才会触发订单查询
List<Order> orders = user.getOrders();
// 调用getDept()时,才会触发部门查询
Dept dept = user.getDept();
这是推荐的模式,也是 MyBatis 3.4.1 及以后的默认模式。它真正实现了按需加载,只有在需要的时候才会执行查询。
六、常见坑点与避坑指南
1. 坑 1:嵌套结果不支持延迟加载
问题 :很多人以为所有关联查询都支持延迟加载,其实只有嵌套查询(select属性)支持,嵌套结果不支持。
错误示例:
XML
<!-- 嵌套结果,不支持延迟加载,会一次性查询所有数据 -->
<resultMap id="userResultMap" type="User">
<id column="id" property="id"/>
<result column="name" property="name"/>
<collection property="orders" ofType="Order">
<id column="order_id" property="id"/>
<result column="order_no" property="orderNo"/>
</collection>
</resultMap>
<select id="getUserById" resultMap="userResultMap">
SELECT u.*, o.id as order_id, o.order_no
FROM user u LEFT JOIN order o ON u.id = o.user_id
WHERE u.id = #{id}
</select>
原因:嵌套结果是通过多表联查一次性获取所有数据,然后在内存中进行结果映射,无法实现按需加载。
2. 坑 2:Session 关闭后访问延迟加载属性
问题 :如果在 SqlSession 关闭后访问延迟加载的属性,会抛出LazyInitializationException异常。
错误示例:
java
SqlSession sqlSession = sqlSessionFactory.openSession();
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
User user = userMapper.getUserById(1L);
sqlSession.close(); // 关闭SqlSession
// 此时访问延迟加载属性,会抛出异常
List<Order> orders = user.getOrders();
原因:延迟加载需要使用 SqlSession 来执行查询。如果 SqlSession 已经关闭,就无法执行 SQL 查询了。
解决方案:
- 在 SqlSession 关闭前访问所有需要的延迟加载属性
- 使用 Spring 整合 MyBatis,Spring 会自动管理 SqlSession 的生命周期,不会出现这个问题
- 关闭延迟加载,使用立即加载
3. 坑 3:实体类是 final 的
问题:如果实体类是 final 的,CGLIB 无法生成代理对象,导致延迟加载失效。
错误示例:
java
// final类,无法被CGLIB代理
public final class User {
private Long id;
private String name;
private List<Order> orders;
// getter和setter
}
原因:CGLIB 动态代理是基于继承实现的,无法继承 final 类。
解决方案:去掉实体类的 final 修饰符。
4. 坑 4:在 equals、hashCode、toString 中访问延迟加载属性
问题 :如果在实体类的equals()、hashCode()或toString()方法中访问了延迟加载的属性,会触发不必要的查询。
错误示例:
java
public class User {
private Long id;
private String name;
private List<Order> orders;
@Override
public String toString() {
return "User{" +
"id=" + id +
", name='" + name + '\'' +
", orders=" + orders + // 访问延迟加载属性
'}';
}
}
原因 :当调用user.toString()时,会访问orders属性,触发延迟加载,执行不必要的 SQL 查询。
解决方案 :在equals()、hashCode()和toString()方法中,只包含基本属性,不要包含延迟加载的关联属性。
5. 坑 5:N+1 查询问题
问题 :延迟加载本质上就是 N+1 查询问题。如果查询了 100 个用户,然后每个用户都调用getOrders(),会执行 1+100=101 次 SQL 查询。
原因:先执行 1 次主查询获取所有用户,然后每个用户执行 1 次订单查询。
解决方案:
- 如果需要访问所有用户的订单,使用嵌套结果的一次性查询
- 如果只有少数用户需要访问订单,使用延迟加载
- 使用批量查询优化,减少 SQL 查询次数
七、最佳实践
- 合理使用延迟加载:只在关联对象不常访问的场景下使用延迟加载。如果经常访问关联对象,使用嵌套结果的一次性查询性能更好。
- 关闭侵入式延迟加载 :将
aggressiveLazyLoading设置为false,实现真正的按需加载。 - 优先使用局部配置 :不要全局开启延迟加载,而是在需要的关联查询上单独使用
fetchType="lazy"配置,避免不必要的延迟加载。 - 避免在 Session 关闭后访问延迟加载属性:在 Spring 整合 MyBatis 的环境中,这个问题会自动解决;在原生 MyBatis 环境中,要确保在 Session 关闭前访问所有需要的属性。
- 实体类不要加 final 修饰符:避免 CGLIB 无法生成代理对象。
- 不要在 equals、hashCode、toString 中访问延迟加载属性:避免触发不必要的查询。
- 监控 SQL 执行:在开发环境中开启 SQL 日志,检查是否有不必要的延迟加载查询。
八、高频面试题解答
-
问:MyBatis 支持延迟加载吗?原理是什么? 答:MyBatis 支持延迟加载,但只支持嵌套查询的延迟加载。它的底层原理是动态代理:当发现某个关联属性需要延迟加载时,MyBatis 会为该属性生成一个代理对象。当调用代理对象的 getter 方法时,才会触发真正的 SQL 查询,加载真实数据。
-
问:
lazyLoadingEnabled和aggressiveLazyLoading有什么区别? 答:lazyLoadingEnabled是全局延迟加载开关,控制是否开启延迟加载;aggressiveLazyLoading是侵入式延迟加载开关,控制何时触发延迟加载。当aggressiveLazyLoading为 true 时,调用主对象的任何方法都会触发所有延迟加载属性的加载;为 false 时,只有调用对应属性的 getter 方法才会触发加载。 -
问:为什么嵌套结果不支持延迟加载? 答:嵌套结果是通过多表联查一次性获取所有数据,然后在内存中进行结果映射。它在查询时已经获取了所有关联数据,无法实现按需加载。只有嵌套查询是先查询主表,关联数据在需要时再单独查询,所以支持延迟加载。
-
问:MyBatis 使用什么动态代理实现延迟加载?为什么? 答:MyBatis 默认使用 CGLIB 动态代理,也支持 Javassist 动态代理。因为 JDK 动态代理只能代理接口,而我们要代理的是实体类,所以不能使用 JDK 动态代理。CGLIB 和 Javassist 都是基于字节码生成的动态代理,可以代理普通类。
-
问:延迟加载有什么优缺点? 答:优点是按需加载,减少不必要的数据库查询,降低内存占用,提高系统性能;缺点是会产生 N+1 查询问题,并且在 Session 关闭后无法访问延迟加载属性。
-
问:延迟加载的对象在 Session 关闭后为什么不能使用? 答:因为延迟加载需要使用 SqlSession 来执行 SQL 查询。如果 SqlSession 已经关闭,就无法获取数据库连接,也就无法执行查询了,会抛出
LazyInitializationException异常。 -
问:如何解决延迟加载的 N+1 问题? 答:如果需要访问所有关联对象,使用嵌套结果的一次性查询;如果只有少数对象需要访问关联对象,使用延迟加载;也可以使用批量查询优化,减少 SQL 查询次数。
九、总结
MyBatis 延迟加载是一个非常实用的性能优化特性,它通过动态代理实现了按需加载,减少了不必要的数据库查询和内存占用。
回顾一下全文的核心内容:
- MyBatis 只支持嵌套查询的延迟加载,不支持嵌套结果的延迟加载
- 延迟加载的底层原理是动态代理,默认使用 CGLIB 实现
- 两个核心参数:
lazyLoadingEnabled控制是否开启延迟加载,aggressiveLazyLoading控制何时触发加载 - 延迟加载会产生 N+1 查询问题,需要根据场景合理使用
- 常见坑点包括 Session 关闭后访问、实体类是 final 的、在 toString 中访问延迟加载属性等
理解了延迟加载的原理和坑点,你就能在实际项目中正确使用延迟加载,提高系统性能,同时避免常见的问题。