延迟加载
导学
本节学习目标:
- 理解延迟加载解决的核心痛点:避免不必要的数据库查询,降低内存和网络开销
- 掌握全局开关
lazyLoadingEnabled与局部覆盖fetchType的配合使用 - 理解
aggressiveLazyLoading激进延迟加载的行为差异 - 了解
lazyLoadTriggerMethods触发方法的配置与影响 - 理解 CGLIB 代理的实现原理,明确延迟加载的生效边界
- 能够通过日志对比立即加载与延迟加载的 SQL 执行时序差异
定义
延迟加载(Lazy Loading)是 MyBatis 提供的一种按需查询机制。当开启延迟加载后,MyBatis 在首次查询主对象时不立即执行关联对象(association 或 collection)的子查询,而是返回一个 CGLIB 生成的代理对象。只有当代码真正访问关联属性(如调用 getMentor() 或遍历 getStudents())时,代理对象才会触发实际的 SQL 子查询,从数据库加载关联数据。
它解决的核心痛点:
- 关联对象并非每次都被使用,立即加载会造成不必要的数据库往返和内存占用
- 在复杂对象图中,级联加载可能导致一次性查询大量数据,拖慢主查询响应时间
- 允许开发者以统一的方式编写映射配置,由运行时行为决定实际加载时机
适用位置与核心属性
延迟加载的配置分为全局配置和局部配置两个层面。
全局配置(mybatis-config.xml)
| 属性 | 默认值 | 说明 |
|---|---|---|
lazyLoadingEnabled | false | 全局延迟加载开关。设为 true 后,所有嵌套 Select 方式的 association 和 collection 默认延迟加载 |
aggressiveLazyLoading | false(3.4.1 之前为 true) | 激进延迟加载。设为 true 时,访问主对象的任意方法都会触发所有未加载的关联属性加载 |
lazyLoadTriggerMethods | equals,clone,hashCode,toString | 指定哪些方法被调用时会触发延迟加载。默认情况下,调用 toString() 等方法会强制加载所有未初始化的关联属性 |
局部配置(映射文件)
| 属性 | 适用元素 | 说明 |
|---|---|---|
fetchType | <association>、<collection> | 局部覆盖全局配置:lazy 表示延迟加载,eager 表示立即加载。仅在嵌套 Select 方式下有效 |
配置示例(mybatis-config.xml):
<settings>
<!-- 开启全局延迟加载 -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 关闭激进延迟加载:只有真正访问关联属性时才加载 -->
<setting name="aggressiveLazyLoading" value="false"/>
<!-- 自定义触发方法:去掉toString,避免调试时意外触发 -->
<setting name="lazyLoadTriggerMethods" value="equals,clone,hashCode"/>
</settings>
核心原理
延迟加载触发时序图(CGLIB 代理机制)
MyBatis 的延迟加载依赖 CGLIB 字节码生成技术。当查询返回的主对象包含延迟加载的关联属性时,MyBatis 不会直接注入真实对象,而是注入一个 CGLIB 代理。代理内部持有子查询的上下文信息(SQL ID、参数值、配置对象),直到触发条件满足时才执行查询。
关键机制说明:
- 代理创建时机:MyBatis 在创建主对象后,对标记为延迟加载的关联属性字段,注入 CGLIB 代理实例而非真实对象。
- 触发判断:代理对象拦截所有方法调用。当检测到是“获取关联属性”的方法(如 getter)时,检查目标对象是否已加载。
- 子查询执行:若未加载,代理对象通过
Executor发起子查询,参数值来自主查询时column指定的列值。 - 结果缓存:首次加载后,真实对象被缓存到主对象内部,后续访问直接返回,不再重复查询。
- 作用域限制:延迟加载的代理对象绑定到创建它的
SqlSession。如果SqlSession已关闭,再次访问代理属性会抛出LazyInitializationException(类似 Hibernate 的懒加载异常)。
完整示例
场景说明
乐途公司学生管理系统中,查询学生列表时通常只需要学生基本信息,导师详情仅在查看学生详情页时才需要。我们将对比:
- 立即加载:查询学生时自动加载导师
- 延迟加载:查询学生时不加载导师,直到调用
getMentor()
操作前的数据库表结构及初始数据
沿用统一表结构:
-- 学生表
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20),
age INT,
major VARCHAR(20),
score DECIMAL(5,2),
class_id INT,
mentor_id INT
);
-- 导师表
CREATE TABLE mentor (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20),
title VARCHAR(20)
);
初始数据:
| id | name | age | major | score | class_id | mentor_id |
|---|---|---|---|---|---|---|
| 1 | 大翔 | 22 | 软件工程 | 89.50 | 1 | 1 |
| 2 | 白歌 | 21 | 计算机科学 | 92.00 | 1 | 2 |
| id | name | title |
|---|---|---|
| 1 | 黄俪 | 副教授 |
| 2 | 李眉 | 教授 |
MyBatis 全局配置
<!-- mybatis-config.xml -->
<configuration>
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/>
<setting name="lazyLoadTriggerMethods" value="equals,clone,hashCode"/>
</settings>
<!-- 其他配置省略... -->
</configuration>
完整的映射文件片段
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.flywing.mapper.StudentMapper">
<resultMap id="MentorMap" type="com.flywing.entity.Mentor">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="title" column="title"/>
</resultMap>
<resultMap id="StudentMap" type="com.flywing.entity.Student">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="age" column="age"/>
<result property="major" column="major"/>
<result property="score" column="score"/>
<result property="classId" column="class_id"/>
<result property="mentorId" column="mentor_id"/>
<!-- 默认跟随全局延迟加载策略 -->
<association property="mentor" javaType="com.flywing.entity.Mentor"
column="mentor_id"
select="selectMentorById"/>
</resultMap>
<!-- 局部强制立即加载的resultMap(用于对比) -->
<resultMap id="StudentEagerMap" type="com.flywing.entity.Student"
extends="StudentMap">
<association property="mentor" javaType="com.flywing.entity.Mentor"
column="mentor_id"
select="selectMentorById"
fetchType="eager"/>
</resultMap>
<select id="selectStudentsLazy" resultMap="StudentMap">
SELECT id, name, age, major, score, class_id, mentor_id
FROM student
</select>
<select id="selectStudentsEager" resultMap="StudentEagerMap">
SELECT id, name, age, major, score, class_id, mentor_id
FROM student
</select>
<select id="selectMentorById" resultMap="MentorMap">
SELECT id, name, title FROM mentor WHERE id = #{id}
</select>
</mapper>
实际执行结果
测试代码(延迟加载场景):
@Test
public void testLazyLoading() {
List<Student> students = mapper.selectStudentsLazy();
System.out.println("=== 主查询完成,尚未访问关联属性 ===");
for (Student s : students) {
System.out.println("学生: " + s.getName());
}
System.out.println("=== 开始访问导师属性 ===");
for (Student s : students) {
Mentor mentor = s.getMentor(); // 触发延迟加载
System.out.println("学生: " + s.getName() + ", 导师: " + mentor.getName());
}
}
延迟加载控制台 SQL 输出:
==> Preparing: SELECT id, name, age, major, score, class_id, mentor_id FROM student
==> Parameters:
<== Columns: id, name, age, major, score, class_id, mentor_id
<== Row: 1, 大翔, 22, 软件工程, 89.50, 1, 1
<== Row: 2, 白歌, 21, 计算机科学, 92.00, 1, 2
<== Total: 2
=== 主查询完成,尚未访问关联属性 ===
学生: 大翔
学生: 白歌
=== 开始访问导师属性 ===
==> Preparing: SELECT id, name, title FROM mentor WHERE id = ?
==> Parameters: 1(Integer)
<== Columns: id, name, title
<== Row: 1, 黄俪, 副教授
学生: 大翔, 导师: 黄俪
==> Preparing: SELECT id, name, title FROM mentor WHERE id = ?
==> Parameters: 2(Integer)
<== Columns: id, name, title
<== Row: 2, 李眉, 教授
学生: 白歌, 导师: 李眉
立即加载控制台 SQL 输出(selectStudentsEager):
==> Preparing: SELECT id, name, age, major, score, class_id, mentor_id FROM student
==> Parameters:
<== Columns: id, name, age, major, score, class_id, mentor_id
<== Row: 1, 大翔, 22, 软件工程, 89.50, 1, 1
====> Preparing: SELECT id, name, title FROM mentor WHERE id = ?
====> Parameters: 1(Integer)
<==== Columns: id, name, title
<==== Row: 1, 黄俪, 副教授
<== Row: 2, 白歌, 21, 计算机科学, 92.00, 1, 2
====> Preparing: SELECT id, name, title FROM mentor WHERE id = ?
====> Parameters: 2(Integer)
<==== Columns: id, name, title
<==== Row: 2, 李眉, 教授
<== Total: 2
分析
- 延迟加载时序:主查询完成后,控制台仅输出 1 条 SQL。直到代码调用
getMentor(),才逐条触发导师查询。如果循环中从未访问mentor属性,则导师查询一次都不会执行。 - 立即加载时序:主查询的每一行都会立即触发子查询,与
association嵌套 Select 的默认行为一致。 fetchType局部覆盖:StudentEagerMap中fetchType="eager"覆盖了全局lazyLoadingEnabled=true,使该查询强制立即加载。这种灵活性允许在全局懒加载策略下,对关键关联做例外处理。- 代理对象限制:延迟加载返回的
Student对象实际上是 CGLIB 代理的子类实例。如果尝试在SqlSession关闭后访问getMentor(),会抛出异常。因此延迟加载对象必须在同一会话内使用,或提前加载所需数据。
易错场景 / 常见误区
| 误区 | 正解 |
|---|---|
| 认为延迟加载对嵌套结果映射(JOIN 方式)也有效 | 延迟加载仅在嵌套 Select 方式下生效。JOIN 查询已一次性返回全部数据,不存在后续加载时机 |
开启 aggressiveLazyLoading=true 后,以为只有访问关联属性才加载 | 激进模式下,访问主对象的任意方法(包括 getName())都会触发所有未加载关联属性的查询,失去按需加载的意义 |
调试时调用 System.out.println(student) 发现关联属性被意外加载 | 默认 lazyLoadTriggerMethods 包含 toString(),打印对象会触发延迟加载。如需避免,应从触发方法列表中移除 toString |
SqlSession 关闭后,尝试访问延迟加载的关联属性 | 代理对象依赖 SqlSession 的 Executor 执行子查询。会话关闭后访问会抛异常。应在关闭前完成数据加载,或使用 EAGER 策略 |
认为 fetchType="lazy" 可以覆盖 aggressiveLazyLoading 的行为 | fetchType 只控制加载时机(立即/延迟),aggressiveLazyLoading 控制延迟加载的触发范围。二者是正交配置 |
面试考点
Q1:MyBatis 延迟加载的实现原理是什么?为什么返回的对象是 CGLIB 代理而不是原始类型?
A:MyBatis 使用 CGLIB 生成主对象和关联对象的代理子类。代理对象在字段级别拦截访问,当检测到关联属性未初始化时,通过
Executor发起子查询,加载真实数据后替换代理。使用代理而非原始类型的原因是:Java 无法在运行时动态替换对象引用,只有通过代理拦截方法调用,才能在“访问时”插入加载逻辑。
Q2:lazyLoadingEnabled=true 且 aggressiveLazyLoading=true 时,调用主对象的 getName() 会触发关联加载吗?为什么?
A:会。
aggressiveLazyLoading为true时,MyBatis 认为任何方法调用都可能需要完整对象状态,因此会一次性加载所有未初始化的关联属性。这在 3.4.1 之前是默认行为,但通常会导致性能劣化,现代项目建议关闭。
Q3:延迟加载的代理对象在 SqlSession 关闭后还能使用吗?与 Hibernate 的懒加载异常有什么异同?
A:不能。MyBatis 的延迟加载代理依赖创建它的
SqlSession及其Executor来执行子查询。会话关闭后再次访问未加载的关联属性,会抛出类似LazyInitializationException的异常(具体异常信息取决于 MyBatis 版本)。这与 Hibernate 的LazyInitializationException本质相同:都是会话作用域外的代理访问问题。解决方案也类似:在会话内完成加载、使用 Open Session in View 模式、或改为 EAGER 加载。
Q4:生产环境中,如何配置延迟加载才能既享受性能收益,又避免调试和日志中的意外触发?
A:推荐配置组合:(1)
lazyLoadingEnabled=true开启全局延迟加载;(2)aggressiveLazyLoading=false避免任意方法触发;(3)lazyLoadTriggerMethods=equals,clone,hashCode移除toString,防止日志和调试打印时意外加载;(4)对必须立即加载的关键关联,使用fetchType="eager"局部覆盖。
小结
延迟加载是 MyBatis 优化关联查询性能的重要武器。通过 CGLIB 代理机制,框架将关联对象的加载时机推迟到真正访问时,避免了不必要的数据库往返。全局配置 lazyLoadingEnabled 与局部 fetchType 的配合,提供了灵活的策略控制;而 aggressiveLazyLoading 和 lazyLoadTriggerMethods 则决定了延迟加载的触发边界。理解代理对象的生命周期限制(绑定 SqlSession),是避免运行时异常的关键。
下一章引子
延迟加载缓解了关联查询的性能压力,但它只是“推迟”了子查询的执行,并未减少总查询次数。如果业务场景需要一次性批量加载关联数据,嵌套 Select 方式固有的 N+1 查询问题 仍然是性能杀手。下一节将系统剖析 N+1 问题的成因、检测手段和根治方案。