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

#Java#面试八股

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

结论先行:支持。延迟加载(lazy loading)针对 association/collection 的嵌套查询:主对象先查出来,关联对象先放一个代理对象,只有真正调用关联对象的 getter 时才发第二条 SQL 去加载。实现依赖 Javassist(默认)或 CGLIB 生成子类代理,配合 ResultLoader 与 ResultLoaderMap 完成「按需触发」。

1. 延迟加载发生在哪

SELECT * FROM t_order WHERE id = 1
        │
        ▼
Order 对象 { id=1, userId=9, user = 代理对象(占位) }
                                  │
        调用 order.getUser().getName()
                                  ▼
                   代理拦截 → ResultLoader.loadResult()
                                  │
                                  ▼
                   执行 UserMapper.selectById(9) → 填充真实 User

注意:延迟加载只对嵌套查询(select 属性指向另一条语句)有效,对 join 方式的 association/collection(resultMap 里直接配列)无效——因为 join 已经把数据一次性查回来了,没有「再查一次」的必要。

2. 三个关键开关

配置项默认值含义
lazyLoadingEnabledfalse全局开关,开启后才允许延迟加载
aggressiveLazyLoadingfalse(3.4.2+)true 时调用任意方法(含 toString、equals)都会触发加载;false 只在调用关联对象的 getter 时触发
fetchType(局部)继承全局lazy / eager,在 association/collection 上单独覆盖
mybatis:
  configuration:
    lazy-loading-enabled: true
    aggressive-lazy-loading: false
<resultMap id="OrderMap" type="com.example.Order">
    <id property="id" column="id"/>
    <result property="amount" column="amount"/>
    <!-- fetchType 覆盖全局配置 -->
    <association property="user" column="user_id"
                 javaType="com.example.User"
                 select="com.example.UserMapper.selectById"
                 fetchType="lazy"/>
    <collection property="items" column="id"
                ofType="com.example.OrderItem"
                select="com.example.OrderItemMapper.selectByOrderId"
                fetchType="lazy"/>
</resultMap>

3. 代理是怎么生成的

DefaultResultSetHandler 在映射关联属性时,如果判定为延迟加载,会调用 createProxy():

// 简化逻辑
if (isLazy) {
    // 1. 记录一条待加载任务
    resultLoaderMap.put(property, new ResultLoader(configuration, executor,
            mappedStatement, parameterObject, targetType, columnValue));
    // 2. 创建代理:Javassist 子类,拦截 getter
    Object proxy = proxyFactory.createProxy(resultObject, resultLoaderMap, configuration);
    metaObject.setValue(bean, property, proxy);
}

ResultLoaderMap 是「属性名 → 加载器」的映射。代理对象被调用 getter 时,LazyLoader.load() 从 map 里取出 ResultLoader,用保存的 Executor 和参数执行嵌套查询,把结果 set 回真实对象,并从 map 中移除(只加载一次)。

JavassistProxyFactory 与 CglibProxyFactory 的区别:前者生成字节码子类并重写 getter,后者用 MethodInterceptor。JDK 动态代理不行,因为目标类通常不实现接口。

4. 一个必须知道的坑

toString()、equals()、序列化都会触发延迟加载。尤其是:

  • Controller 直接返回实体,Jackson 序列化时把代理对象的所有字段都读一遍,N+1 全部触发;
  • aggressiveLazyLoading=true 时甚至连 hashCode() 都会触发;
  • 调试时 IDE 的变量面板会调用 toString(),看起来「没用的字段也被查了」。

所以生产上更推荐:关联查询用 join + resultMap 一次查完,或者 DTO 明确只暴露需要的字段。延迟加载适合「详情页只偶尔看关联信息」的低频场景,不适合列表页。

5. 延迟加载与一级缓存的关系

嵌套查询会走同一个 Executor,所以在同一个 SqlSession 内,多条主对象共享关联查询结果时可以命中一级缓存。一旦 SqlSession 关闭(Spring 下事务结束),代理对象再触发加载会抛 LazyInitializationException(MyBatis 对应的是 ExecutorException:Error getting lazy loading result)。

解决方式有三种:在事务内访问、改用 join 一次性查、或者关闭延迟加载。

6. 常见追问

追问一:fetchType="lazy" 配在全局关闭时还有效吗? 无效。lazyLoadingEnabled=false 是总开关,局部 fetchType 不能突破它。

追问二:一级缓存能减少延迟加载的 SQL 次数吗? 能,但只限同一 SqlSession。列表页 N 条订单若属于同一个用户,第二次加载会命中一级缓存;属于不同用户则仍是 N 次查询。

追问三:延迟加载会不会导致事务提前结束的问题? 会。代理对象需要在 SqlSession 存活期间触发,若在事务外访问就会失败。设计上应保证「关联数据在事务边界内取完」,或直接 join 查询。

追问四:column 属性可以是多个列吗? 可以,用 column="{userId=user_id, type=order_type}" 形式传多个参数给嵌套语句,MyBatis 会组装成 ParamMap。

追问五:嵌套查询和 join 映射怎么选? 列表/高频接口用 join,一次 SQL 拿全,避免 N+1;详情页、关联数据体积大且不一定用到时用嵌套查询 + 延迟加载,减少单次查询的数据量。

小结

延迟加载的实现就是三件套:ResultLoaderMap 记录待加载任务、Javassist/CGLIB 生成代理、getter 触发时执行嵌套 SQL。它省的是「不必要的数据传输」,代价是可能把 N+1 推迟到序列化阶段才爆发,所以用之前先想清楚这个关联是不是真的「可能不用」。

笔记加载中…