1. 引言
在使用 MyBatis 进行一对多、多对一查询时,你是否好奇过:为什么有时候关联查询会立即执行,有时候却等到真正访问时才去查?
这背后其实藏着一整套代理对象生成、拦截、延迟触发 的精密流程。本文将通过一个 User 与 Order 的典型例子,逐步拆解 MyBatis 从调用 Mapper 方法到属性访问的完整链路,帮你彻底吃透延迟加载 与积极加载的内部机制。
1.1 环境假设
假设我们有两张表:user(用户)和 orders(订单),一个用户可以有多个订单。对应的实体类大致如下:
public class User {
private Integer id;
private String name;
private Integer age;
private List<Order> orders; // 关联属性
// getter / setter 略
}
Mapper 接口中有一个简单的查询方法:
User selectById(Integer id);
对应的 resultMap 中配置了 <collection> 关联映射,并可能指定了局部加载策略 fetchType。
2. 阶段一:调用 Mapper 方法,拿到主对象引用
当我们执行下面这行代码时,事情并不像表面看起来那么简单:
User user = userMapper.selectById(1);
这一个操作背后,MyBatis 内部至少完成了 4 个关键步骤。
2.1 立即执行主查询 SQL
MyBatis 首先会根据 selectById 所绑定的 SQL 语句执行主查询,例如:
SELECT id, name, age FROM user WHERE id = 1
此时只会查出 user 表的基本字段(id、name、age),关联属性如 orders 并不会出现在这条 SQL 里(除非使用了连接查询,但不在本文讨论范围)。
2.2 创建原始 User 对象
用上一步的查询结果构造一个普通 Java 对象 (POJO),填充 id、name、age 等标量字段。
此时,orders 属性仍然为 null(或者是一个未加载的占位符,取决于配置)。
2.3 决定是否为该 User 生成代理 ------ 全局设置 + 局部 fetchType 综合判定
这是整个机制中最关键的一步,也是容易被误解的地方。
MyBatis 并不仅仅看某一个配置,而是**综合全局延迟加载开关以及每个关联属性自己的 fetchType**来决定最终行为。
-
全局配置:
mybatis-config.xml中的<setting name="lazyLoadingEnabled" value="true/false"/> -
局部配置:
resultMap内<association>或<collection>上的fetchType属性(可取lazy或eager)
最终生效的加载策略 = 局部 fetchType 覆盖全局设置(如果局部未配置,则沿用全局)。
真实场景中的判定流程如下:
2.3.1 情况一:只要结果对象中存在至少一个有效的延迟加载关联,就创建代理
这里的"有效"是指:
-
全局
lazyLoadingEnabled=true且该关联未显式设置fetchType="eager" -
或者 该关联显式设置了
fetchType="lazy"(此时无论全局是true还是false,都会强制延迟加载¹)
只要最终存在至少一个关联属性被判定为"延迟加载",MyBatis 就会基于 CGLIB 创建一个 User 的代理对象 ,包裹住第二步构建的原始 User 实例。
对于标记为 lazy 的关联,SQL 此时不执行;对于同一对象中标记为 eager 的关联,SQL 会在创建代理时一并立即执行并注入数据。
¹注意:在 MyBatis 3.4.1 及以上版本,即使全局 lazyLoadingEnabled=false,局部 fetchType="lazy" 依然可以生效并触发代理生成;但如果全局 aggressiveLazyLoading 为 true,行为会有所不同,这里不展开。
2.3.2 情况二:所有关联都是积极加载(eager),且没有强制延迟属性
如果全局 lazyLoadingEnabled=false(已关闭延迟加载),且所有关联都没有通过局部 fetchType="lazy" 强制开启延迟,那么最终判定下来:没有任何一个关联需要延迟加载 。
这种情况下,MyBatis 会直接执行所有关联的查询,填充到原始对象中,然后返回原始的 User 对象,不再生成代理。因为没有需要拦截的延迟方法了。
✅ 核心结论
代理对象的生成不是"有结果映射就生成",而是"有需要延迟加载的关联时才生成"。
局部fetchType可以覆盖全局lazyLoadingEnabled,从而强制让某个关联变成懒加载/积极加载,进而影响是否生成代理。
2.4 返回给调用方
最终,userMapper.selectById(1) 返回给你的 user 可能是代理对象 (存在延迟关联时),也可能是原始对象 (纯 eager 且无强制 lazy 时)。
调用方无需关心背后是哪种,但后续对属性的访问行为会因此不同。
3. 代理对象的本质 ------ 拦截器的 intercept 方法究竟做了什么?
3.1 你拿到的代理对象背后是什么?
当你拿到 User user = userMapper.selectById(1) 时,如果生成了代理,这个 user 实际是 CGLIB/Javassist 动态生成的子类实例,内部携带着一个 方法拦截器(MyBatis 3.5 之前默认 CGLIB,3.5 + 默认 Javassist,全程不使用 JDK 动态代理)。
每次你对这个 user 调用任何一个方法------ 无论是 getName()、getOrders() 还是 toString()------ 都会被拦截器的 intercept 方法拦住,延迟加载的核心逻辑完全集中在这一点。
3.2 拦截器内部的判断流程
步 1:拦截到方法调用
方法被调用,拦截器首先拿到被调用方法的名称、参数等信息。
步 2:是否为 Object 基础方法?
先排除一些不应该触发加载的方法,例如 toString()、hashCode()、equals(Object)、clone() 等。如果当前调用的是这类方法,直接放行给真实对象处理,避免意外的 SQL 执行(配合 aggressiveLazyLoading=false 生效)。
步 3:判断方法对应哪个属性
拦截器会维护一个映射:哪些方法(getter/setter)对应 resultMap 中配置的 <association> 或 <collection>。例如 getOrders() 被标记为关联到 orders 集合属性。
步 4:加载策略生效
如果该属性最终策略是 eager(积极加载)→ 关联数据早在对象初始化时就已经执行并注入,检查时发现属性已有值→ 直接返回,不查 SQL,不走延迟加载逻辑。
如果该属性最终策略是 lazy(延迟加载)→ 检查当前属性是否为未加载占位状态→ 未加载:触发关联 SQL 执行,拿到结果后通过 setOrders() 注入真实对象→ 返回结果给调用方→ 下次再调用,发现已加载,不再重复查询。
3.3 拦截器与延迟加载的配置开关关系
必须强调:拦截器是否触发延迟查询,本质上取决于 "当前这个关联属性的最终加载策略",而这个策略是:
全局 lazyLoadingEnabled
局部 fetchType
以及 aggressiveLazyLoading 等设定
三者综合的结果。
只有当最终判定该关联为 lazy 时,拦截器的 intercept 才会在首次访问时执行 "判未加载 → 查 SQL → 注入 → 返回" 的一套流程。如果最终判定为 eager,拦截器直接返回已填好的数据,等同于无额外开销。
3.4 为什么 intercept 如此重要?
因为它是整个延迟加载的唯一 "开关点"。MyBatis 不修改你的实体类代码,也不要求你写任何加载逻辑,全部通过这个拦截器在运行时动态织入。明白这一点之后,你就能清楚:
为什么延迟加载属性在 Debug 时看起来像 null,但一调用就有数据
为什么有时候调用 toString() 会意外多发 SQL(与 aggressiveLazyLoading=true 有关)
为什么必须用代理对象而不能直接用 new User() 来获得延迟加载能力
4. 阶段二:访问主对象的各个属性
4.1 🅐 访问普通属性 ------ user.getName()
普通属性是指不涉及关联映射的标量字段(如 name、age)。
-
如果当前是代理对象:调用
getName()先进入invoke()拦截方法,拦截器判定不是关联属性 getter → 直接调用原始对象的方法返回值(零 SQL)。 -
如果是原始对象:直接返回字段值,无代理、无
invoke()调用。
无论哪种,name 在阶段一早已查好,不会产生任何额外数据库查询。
4.2 🅑 访问关联属性(延迟加载) ------ user.getOrders()
这是延迟加载发挥作用的核心场景,也是性能优化的关键。
底层核心:CGLIB 代理拦截器 invoke() 方法
MyBatis 为代理对象绑定了 MethodInterceptor 拦截器,所有方法调用都会先进入 invoke() 方法,这是延迟加载的底层入口!
-
进入
invoke()拦截:代理拦截器捕获getOrders()方法调用; -
判定属性类型:拦截器识别出这是延迟加载的关联属性 getter;
-
校验数据状态:检查
orders属性值为null(未加载); -
invoke() 方法触发关联 SQL:执行配置的子查询,查询订单数据;
-
数据注入:查询完成后,
invoke()方法调用setOrders()填充数据; -
返回结果:
invoke()方法将加载完成的数据返回给调用方。SELECT * FROM orders WHERE user_id = 1
重点规则:
-
invoke() 方法仅首次调用触发 SQL 查询;
-
之后再次访问,直接返回已加载数据,
invoke()不再执行查询; -
不调用关联 getter →
invoke()不执行 → 永远不查关联库。
4.3 🅒 访问关联属性(积极加载) ------ user.getOrders()
如果某关联最终被判定为 eager (无论是因为全局 eager、局部 fetchType="eager",还是强制积极加载),那么:
-
它的 SQL 在阶段一第 3 步(或生成代理时)就已执行完毕,属性早已填充好。
-
当调用
getOrders()时,代理(或原始对象)发现属性已有值,直接返回,不会再查 SQL。
对调用方而言,这与访问普通属性几乎没有区别,唯一的代价是初始化时多消耗了一次查询。
5. 一张图总结全流程
selectById(1)
│
▼
执行主查询 SQL (查 user 表)
│
▼
创建原始 User 对象 (orders=null)
│
├──【判定】是否存在需要延迟加载的关联?
│ ├─ 是 (至少一个 lazy 生效)
│ │ └─ 创建 CGLIB 代理 + 绑定 invoke() 拦截器
│ │ ├─ 对所有 eager 关联:立即查 SQL 并注入
│ │ └─ 对所有 lazy 关联:暂时不动
│ │
│ └─ 否 (所有关联都是 eager)
│ └─ 所有关联立即查 SQL 并注入
│ 返回原始对象(无代理)
│
▼
返回对象(代理 or 原始)
│
├─ user.getName() ────▶ invoke()拦截 → 非关联属性 → 直接返回值,不查库
│
├─ user.getOrders() [lazy, 首次]
│ └─ invoke() 拦截 → null → 查关联 SQL → setOrders → 返回
│
├─ user.getOrders() [lazy, 再次] → 直接返回(不查 SQL)
│
└─ user.getOrders() [eager] → 直接返回(SQL 早已执行)
5.1 补充:MyBatis 延迟加载仅使用 CGLIB 代理,不使用 JDK 动态代理
-
JDK 动态代理只能代理实现了接口的类,而 MyBatis 实体类(User/Order)是普通 POJO,无任何接口,无法使用 JDK 代理;
-
CGLIB 代理基于继承机制生成子类代理,无需目标类实现接口,是 MyBatis 延迟加载的唯一选择;
-
JDK 代理仅用于 MyBatis Mapper 接口代理,与延迟加载的实体代理无关。
6. 补充说明:全局配置与局部覆盖的正确用法
在实际项目中,推荐在 mybatis-config.xml 中统一配置延迟加载:
<settings>
<!-- 开启全局延迟加载 -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 禁用激进延迟加载,避免在 equals/hashCode/toString 等方法中意外触发 SQL -->
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
然后在特定的 resultMap 中,可以按需用 局部 fetchType 覆盖全局行为:
<resultMap id="userMap" type="User">
<id column="id" property="id"/>
<result column="name" property="name"/>
<!-- 强制延迟加载,即使全局是 false 也会生效(MyBatis 3.4.1+) -->
<collection property="orders"
select="com.example.mapper.OrderMapper.findByUserId"
column="id"
fetchType="lazy"/>
<!-- 某关联强制积极加载,即使全局开启延迟 -->
<association property="detail"
select="..."
column="id"
fetchType="eager"/>
</resultMap>
清晰记住这条规则: 局部 fetchType 优先级高于全局 lazyLoadingEnabled,代理生成只看最终是否需要"延迟加载某个关联"。
7. 结语
MyBatis 的延迟加载机制远非简单的"开或关",而是通过 全局开关 + 局部覆盖 → 最终加载策略 → 决定是否生成代理 → invoke() 方法拦截触发 这样一条严谨的链路,把关联查询的时机控制得恰到好处。
理清这套逻辑之后,你不仅能写出效率更高的代码,还能在面对各种"为什么 SQL 此时才执行"的疑问时,立刻从全局/局部配置中定位到根因。