MyBatis 关联查询的延迟加载与积极加载原理

1. 引言

在使用 MyBatis 进行一对多、多对一查询时,你是否好奇过:为什么有时候关联查询会立即执行,有时候却等到真正访问时才去查?

这背后其实藏着一整套代理对象生成、拦截、延迟触发 的精密流程。本文将通过一个 UserOrder 的典型例子,逐步拆解 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 表的基本字段(idnameage),关联属性如 orders 并不会出现在这条 SQL 里(除非使用了连接查询,但不在本文讨论范围)。

2.2 创建原始 User 对象

用上一步的查询结果构造一个普通 Java 对象 (POJO),填充 idnameage 等标量字段。

此时,orders 属性仍然为 null(或者是一个未加载的占位符,取决于配置)。

2.3 决定是否为该 User 生成代理 ------ 全局设置 + 局部 fetchType 综合判定

这是整个机制中最关键的一步,也是容易被误解的地方。

MyBatis 并不仅仅看某一个配置,而是**综合全局延迟加载开关以及每个关联属性自己的 fetchType**来决定最终行为。

  • 全局配置:mybatis-config.xml 中的 <setting name="lazyLoadingEnabled" value="true/false"/>

  • 局部配置:resultMap<association><collection> 上的 fetchType 属性(可取 lazyeager

最终生效的加载策略 = 局部 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" 依然可以生效并触发代理生成;但如果全局 aggressiveLazyLoadingtrue,行为会有所不同,这里不展开。

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()

普通属性是指不涉及关联映射的标量字段(如 nameage)。

  • 如果当前是代理对象:调用 getName() 先进入 invoke() 拦截方法,拦截器判定不是关联属性 getter → 直接调用原始对象的方法返回值(零 SQL)。

  • 如果是原始对象:直接返回字段值,无代理、无 invoke() 调用。

无论哪种,name 在阶段一早已查好,不会产生任何额外数据库查询


4.2 🅑 访问关联属性(延迟加载) ------ user.getOrders()

这是延迟加载发挥作用的核心场景,也是性能优化的关键。

底层核心:CGLIB 代理拦截器 invoke() 方法

MyBatis 为代理对象绑定了 MethodInterceptor 拦截器,所有方法调用都会先进入 invoke() 方法,这是延迟加载的底层入口!

  1. 进入 invoke() 拦截:代理拦截器捕获 getOrders() 方法调用;

  2. 判定属性类型:拦截器识别出这是延迟加载的关联属性 getter

  3. 校验数据状态:检查 orders 属性值为 null(未加载);

  4. invoke() 方法触发关联 SQL:执行配置的子查询,查询订单数据;

  5. 数据注入:查询完成后,invoke() 方法调用 setOrders() 填充数据;

  6. 返回结果: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 动态代理

  1. JDK 动态代理只能代理实现了接口的类,而 MyBatis 实体类(User/Order)是普通 POJO,无任何接口,无法使用 JDK 代理;

  2. CGLIB 代理基于继承机制生成子类代理,无需目标类实现接口,是 MyBatis 延迟加载的唯一选择;

  3. 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 此时才执行"的疑问时,立刻从全局/局部配置中定位到根因。

相关推荐
我滴老baby1 小时前
企业级工具链设计从单一工具到分层工具体系的架构实践
java·开发语言·架构
Hello.Reader1 小时前
算法基础(三)—— 插入排序从整理扑克牌到有序数组
java·算法·排序算法
罗超驿1 小时前
3.快乐数专题学习笔记——双指针法在LeetCode 202题中的应用
java·算法·leetcode·职场和发展
liann1191 小时前
Agent 内存马禁止 Attach JVM
java·jvm·安全·网络安全·系统安全·网络攻击模型·信息与通信
小雅痞1 小时前
[Java][Leetcode middle] 36. 有效的数独
java·算法·leetcode
代码漫谈1 小时前
JVM 参数调优:Spring Boot与JDK新特性的最佳结合
java·jvm·spring boot
卷毛的技术笔记2 小时前
双十一零点扛过10倍流量洪峰:Sentinel与Redis+Lua的分布式限流深度避坑指南
java·redis·分布式·后端·系统架构·sentinel·lua
逻辑驱动的ken2 小时前
Java高频面试考点场景题27
java·开发语言·面试·职场和发展·求职招聘
一氧化二氢.h2 小时前
【java】的数组列表和集合的区别是什么
java·开发语言