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. 三个关键开关
| 配置项 | 默认值 | 含义 |
|---|---|---|
lazyLoadingEnabled | false | 全局开关,开启后才允许延迟加载 |
aggressiveLazyLoading | false(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 推迟到序列化阶段才爆发,所以用之前先想清楚这个关联是不是真的「可能不用」。