Mybatis 是否支持延迟加载?实现原理是什么?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 概念理解:面试官不仅仅是想知道 "支不支持",更是想考察你是否理解延迟加载的触发时机——什么时候该加载,什么时候不该加载,以及它和立即加载的区别。

  2. 底层原理:看你能否说清楚 MyBatis 延迟加载的底层实现——通过动态代理拦截属性访问,在真正需要数据时才执行 SQL。

  3. 实践意识:考察你是否知道延迟加载的适用场景、配置方式,以及它可能带来的 N+1 查询问题。

核心答案

MyBatis 支持延迟加载。延迟加载(Lazy Loading)是指在查询主对象时,不立即加载关联的子对象,只有在真正访问子对象属性时,才触发 SQL 去查询。

维度说明
适用场景<association> 一对一、<collection> 一对多关联查询
配置方式fetchType = "lazy" 或全局配置 lazyLoadingEnabled = true
底层原理动态代理(CGLIB / Javassist)拦截属性访问,触发时才执行 SQL
前提条件必须使用 resultMap + 子查询(select 属性),不能是 JOIN 查询

一句话结论:延迟加载通过动态代理实现,关联对象在真正被访问时才触发 SQL 查询,减少不必要的数据库访问。

深度解析

一、延迟加载 vs 立即加载

两种加载策略的核心区别在于关联对象的查询时机

  • 立即加载:查询主对象时,一并执行关联对象的 SQL。不管你后面用不用得到关联数据,SQL 都执行了。适合关联数据大概率会用的场景。
  • 延迟加载:查询主对象时,只返回主对象数据,关联对象用一个代理对象占位。当你真正调用 user.getDept() 访问关联属性时,代理对象才触发 SQL 查询。适合关联数据不一定会用到的场景。

二、配置方式

方式一:全局配置

mybatis-config.xml 中开启全局延迟加载:

<settings>
    <!-- 开启延迟加载(默认 false) -->
    <setting name="lazyLoadingEnabled" value="true"/>

    <!-- 按需加载(默认 true,3.4.1 及以后版本)
         true:按需加载,访问任意延迟属性时只加载该属性
         false:访问任意属性时触发加载所有延迟属性 -->
    <setting name="aggressiveLazyLoading" value="false"/>
</settings>

方式二:局部配置(推荐)

resultMap<association><collection> 中单独指定,优先级高于全局配置

<resultMap id="userWithDeptMap" type="User">
    <id column="id" property="id"/>
    <result column="username" property="username"/>

    <!-- fetchType = "lazy" 表示延迟加载 -->
    <association property="dept" column="dept_id"
                 select="com.example.mapper.DeptMapper.selectById"
                 fetchType="lazy"/>

    <!-- fetchType = "eager" 表示立即加载(默认) -->
    <collection property="articles" column="id"
                select="com.example.mapper.ArticleMapper.selectByUserId"
                fetchType="eager"/>
</resultMap>

<!-- 主查询:只查用户 -->
<select id="selectById" resultMap="userWithDeptMap">
    SELECT id, username, dept_id FROM t_user WHERE id = #{id}
</select>

关键点:延迟加载必须使用 resultMap + select 子查询的方式,不能用 JOIN 查询。因为 JOIN 查询已经在一条 SQL 中把所有数据都查出来了,没有 "延迟" 的空间。

三、底层实现原理

MyBatis 延迟加载的底层通过动态代理实现,具体流程如下:

延迟加载的实现分为以下关键步骤:

  • 步骤一(主查询执行):执行主对象的 SQL 查询,获取主对象数据(如 User 的 idusernamedept_id)。
  • 步骤二(代理对象创建):MyBatis 检测到 <association> 配置了 fetchType="lazy",不执行子查询 SQL,而是通过 ProxyFactory 创建一个 Dept 接口的代理对象。代理对象内部持有一个 ResultLoader(结果加载器),记录了要执行的 SQL 和参数。
  • 步骤三(延迟触发):当开发者调用 user.getDept().getName() 时,代理对象拦截到方法调用。判断这是一个属性访问(getter 方法),触发 ResultLoader.load(),执行子查询 SQL,获取真实的 Dept 数据,并用真实对象替换代理对象。
  • 步骤四(结果替换):代理对象将自身的所有方法调用委托给真实对象,后续再次访问 Dept 属性时直接返回缓存数据,不会重复查询。

四、动态代理的实现细节

MyBatis 默认使用 Javassist 生成代理类(MyBatis 3.3 之前),3.3 之后支持切换为 CGLIB

<!-- 配置代理工具(默认 JAVASSIST) -->
<setting name="proxyFactory" value="CGLIB"/>
<!-- 或 -->
<setting name="proxyFactory" value="JAVASSIST"/>
代理方式说明
JAVASSIST(默认)通过字节码增强生成代理类,不需要接口
CGLIB通过生成子类实现代理,同样不需要接口

代理类的核心逻辑:重写所有 getter 方法,在 getter 被调用时判断关联数据是否已加载,未加载则触发 SQL 查询。

五、N+1 问题——延迟加载的双刃剑

延迟加载虽然可以减少不必要的查询,但在某些场景下会导致 N+1 查询问题

// 查询 100 个用户
List<User> users = userMapper.selectAll();  // 1 条 SQL

// 遍历访问每个用户的部门
for (User user : users) {
    System.out.println(user.getDept().getName());  // 每次循环触发 1 条 SQL!
}
// 总共执行了 1 + 100 = 101 条 SQL 💥

解决方案

方案说明
改用 JOIN 查询如果关联数据大概率会用到,直接用 JOIN 一次查完
批量加载MyBatis 3.5.9+ 支持 lazyLoadTriggerMethods 配置批量触发
控制访问粒度在业务代码中避免在循环中访问延迟加载属性

六、常见误区

  1. 误区一:"延迟加载在任何场景下都能用"
    • 不是。延迟加载必须使用 resultMap + select 子查询的方式。如果你用 JOIN 查询把数据一次性查出来了,延迟加载就没有意义了。
  2. 误区二:"延迟加载一定比立即加载好"
    • 不是。如果关联数据大概率会被访问到,延迟加载反而会增加 SQL 次数(主查询 + 子查询 vs 一条 JOIN)。延迟加载适合 "关联数据不一定会用" 的场景。
  3. 误区三:"开启了全局延迟加载,所有查询都是延迟的"
    • 不是。只有在 resultMap 中使用 select 子查询配置的关联映射才会延迟。普通字段映射和 resultType 不受影响。

面试高频追问

  1. 追问一aggressiveLazyLoading 配置有什么用?
    • aggressiveLazyLoading = true 时,访问主对象的任意属性(不仅仅是延迟属性)都会触发所有延迟属性的加载。MyBatis 3.4.1 之后默认为 false(按需加载),只有访问延迟属性本身时才触发加载。
  2. 追问二:延迟加载和 MyBatis 二级缓存能一起用吗?
    • 可以,但需要注意:延迟加载触发的子查询也会走缓存。如果主对象在二级缓存中,关联对象的延迟加载可能读到过期的缓存数据。复杂场景下建议关闭二级缓存。
  3. 追问三:Spring 整合 MyBatis 后,延迟加载会不会失效?
    • 可能会。如果 Controller 层直接访问延迟属性,但 SqlSession 已经关闭了,就会报错。解决方案:使用 OpenSessionInViewFilter(Spring MVC)或 OpenSessionInViewInterceptor(Spring Boot),让 SqlSession 在整个请求期间保持打开。

常见面试变体

  • 变体一:"MyBatis 的延迟加载怎么配置?"
  • 变体二:"MyBatis 延迟加载的底层是怎么实现的?用了什么设计模式?"
  • 变体三:"什么是 N+1 查询问题?怎么解决?"

记忆口诀

延迟加载靠代理,代理拦截 getter 触发查;resultMap + select 子查询,fetchType="lazy" 是开关;不访问不查询,用了才触发,小心循环里 N+1。

总结

MyBatis 支持延迟加载,通过在 resultMap 中配置 fetchType="lazy" + select 子查询实现。底层原理是使用动态代理(Javassist / CGLIB)创建关联对象的代理,拦截 getter 方法调用,在真正访问关联属性时才触发 SQL 查询。需要注意 N+1 问题和 SqlSession 生命周期管理。