作为 Java 后端开发,你是否曾经纠结过:查询用户信息时,要不要把用户关联的订单、地址一起查出来?全部查询性能肯定受影响,可不查又怕后面用到时反复访问数据库。这种"查不查"的两难抉择,其实可以通过 MyBatis 的延迟加载机制漂亮解决。那么问题来了,MyBatis 到底支持延迟加载吗?它背后的实现原理又是什么?
MyBatis 的延迟加载支持情况
MyBatis 确实支持延迟加载(Lazy Loading)功能,这是一种按需加载的策略,可以有效减轻系统负担,提高查询效率。
简单来说,当我们查询一个实体时,对于它的关联对象,不立即从数据库中加载,而是在第一次真正使用到关联对象时才去数据库查询。这样做可以避免一次性加载过多数据,尤其是在关联关系较多或数据量较大的情况下。
延迟加载的配置方式
MyBatis 提供了两个全局参数来控制延迟加载:
lazyLoadingEnabled
:设置为 true 时开启延迟加载功能aggressiveLazyLoading
:设置为 false 时,按需加载对象属性(只有当调用该属性的 getter 方法时才加载);设置为 true 时,任何对对象方法的调用都会触发所有标记为延迟加载的属性加载
举个简单例子,当aggressiveLazyLoading=true
时:
因此,生产环境中通常建议保持aggressiveLazyLoading=false
,避免不必要的性能损耗。
除了全局配置外,还可以在关联查询中单独设置:
通过fetchType
属性可以覆盖全局的延迟加载设置,值为lazy
表示使用延迟加载,eager
表示立即加载。
延迟加载的触发条件
延迟加载并非任何操作都会触发,具体的触发条件包括:
- 调用延迟属性的 getter 方法:如
user.getOrderList()
- 对延迟集合属性进行操作:如
orderList.size()
、orderList.isEmpty()
、遍历操作等 - 仅获取代理对象引用不会触发加载:必须调用其方法才会触发
延迟加载的实现原理
MyBatis 的延迟加载主要是通过动态代理实现的。这里涉及两种代理模式:
- JDK 动态代理
- CGLIB 动态代理
字节码层面的代理原理
理解代理选择的核心,需要了解底层实现原理:
- JDK 动态代理:基于接口实现,通过
java.lang.reflect.Proxy
类在运行时生成接口的代理类。它要求目标类必须实现至少一个接口。 - CGLIB 动态代理:基于字节码生成技术,通过创建目标类的子类来实现代理。CGLIB 在运行时动态修改字节码,重写目标类的方法以插入延迟加载逻辑。
简单理解:JDK 代理是"实现接口",CGLIB 代理是"继承类"。这就是为什么实现了接口的类优先使用 JDK 代理,而普通类只能用 CGLIB 代理。
代理机制的选择
MyBatis 会根据目标类是否实现接口选择使用不同的代理机制:
- 如果目标类实现了接口,MyBatis 会优先使用 JDK 动态代理(性能更好且符合 Java 标准)
- 如果目标类没有实现接口,则使用 CGLIB 动态代理
注意:MyBatis 3.2.8+完全支持 JDK/CGLIB 代理自动切换,早期版本可能需要手动配置代理工厂。MyBatis 自 3.3.0 起,若检测到 classpath 中无 CGLIB 依赖,会自动引入
mybatis-cglib-proxy
模块(基于 CGLIB 3.2.5),因此 Maven 项目通常无需额外配置。若使用 Gradle 或手动管理依赖,需确保相关 jar 包存在。
动态代理实现优化
JDK 和 CGLIB 代理处理逻辑中有很多相似部分,可以抽取公共方法处理:
ResultLoaderMap:延迟加载的核心容器
ResultLoaderMap
是 MyBatis 用于管理延迟加载任务的容器,它存储了属性名与对应的ResultLoader
的映射关系。每个延迟属性对应一个ResultLoader
,当属性被访问时,通过ResultLoader
执行对应的子查询并填充数据。
ResultLoaderMap
是会话级(SqlSession
)容器,线程安全由SqlSession
的线程隔离性保证,无需额外同步。在高并发场景下,每个请求使用独立SqlSession
,避免线程间数据污染。
延迟加载的实际案例
让我们通过一个用户(User)和订单(Order)的例子来看看延迟加载如何工作:
实体类定义
MyBatis 配置
- 首先在 MyBatis 全局配置中启用延迟加载:
- 然后在 Mapper 文件中配置:
执行过程与事务
工具类及代码演示
首先,需要一个 MyBatis 工具类来获取 SqlSession:
注意:需要在类路径下添加
mybatis-config.xml
配置文件,配置数据源和 Mapper 扫描。
然后,使用这个工具类编写延迟加载示例:
延迟加载的优缺点
优点
- 性能提升:避免一次性加载过多不必要的数据,减少内存占用
- 按需加载:只有真正需要使用关联数据时才会查询,减少不必要的 IO 操作
- 降低系统压力:特别是在复杂关联关系或大数据量场景下,可以显著降低系统负担
缺点
- N+1 问题:当需要遍历一个集合并访问每个元素的延迟加载属性时,会导致主查询 1 次+每个对象的延迟查询 N 次,总共 N+1 次查询
- 代理对象序列化问题:延迟加载的代理对象序列化时可能会出现问题,尤其是 CGLIB 代理对象
- 会话关闭后无法加载:延迟加载依赖活动的数据库会话,SqlSession 关闭后无法再加载
解决 N+1 问题的方法
延迟加载可能导致的 N+1 问题可以通过以下方式解决:
1. 使用显式即时加载
在明确需要关联数据的场景下,可以显式指定即时加载:
需要注意的是,fetchType="eager"
并不是在 SQL 层面使用 JOIN 查询,而是在主查询完成后立即执行关联查询。本质上是"分步加载",但不需要等到属性被访问时才加载。
2. 使用 MyBatis 的批量查询功能
MyBatis 提供了多种批量查询方式来解决 N+1 问题:
a) 使用 multiple column 参数传递多个值进行批量查询
b) 手动批量查询优化
注意:虽然 MyBatis 提供了
batchSize
配置,但它主要用于优化批量插入/更新操作,对延迟加载的 N+1 问题没有直接帮助。延迟加载的子查询仍然是单条执行的,需要通过上述手动批量查询方式优化。
3. N+1 问题的监控与预防
可以通过以下方式监控和预防 N+1 问题:
也可以使用成熟的监控工具,如 MyBatis Plus 的性能分析插件来监控 SQL 执行。
代理对象序列化问题及解决方案
延迟加载使用的代理对象在序列化时可能会遇到问题,尤其是 CGLIB 代理类。CGLIB 生成的代理类名称类似$$EnhancerByCGLIB$$xxx
,反序列化时需要相同的类路径和类定义。在分布式系统中(如微服务架构),这种代理类可能无法在不同节点间正确反序列化,导致ClassNotFoundException
异常。
解决方案包括:
1. 确保实体类实现 Serializable 接口
所有实体类都应该实现java.io.Serializable
接口,包括关联实体类。
2. 在序列化前触发延迟加载
确保在序列化前已经访问过延迟加载属性,将代理对象转换为真实对象:
3. 使用自定义序列化策略
使用 Jackson 或其他序列化工具的自定义序列化功能:
延迟加载与事务的关系
延迟加载依赖的SqlSession
需与事务作用域一致。如果事务提前提交或回滚,会导致后续的延迟加载无法执行:
在 Spring 环境中,可以使用OpenSessionInView
模式延长会话生命周期,但这可能导致数据库连接长时间占用,高并发系统中要谨慎使用。
延迟加载与缓存结合使用
MyBatis 的延迟加载与缓存机制可以协同工作,进一步提升性能:
一级缓存(会话级)
- 默认开启,作用域为 SqlSession
- 延迟加载的结果会存入一级缓存,同一会话内重复访问不会触发数据库查询
- 当执行 update、delete、insert 或调用 clearCache()时,一级缓存会被清空
二级缓存(全局)
- 需手动配置
<cache/>
或<cache-ref/>
- 延迟加载查询的结果也会被二级缓存缓存
- 跨会话访问时可以直接从二级缓存获取
readOnly=true
表示缓存对象不可变,MyBatis 会直接返回缓存对象引用,提升性能;readOnly=false
则返回对象副本,保证线程安全。
二级缓存存储的是完整对象(包括延迟加载后的数据),因此需确保延迟加载触发后的数据会被正确序列化并缓存。建议在getUserById
等主查询上配置缓存,延迟加载的子查询(如getOrdersByUserId
)可通过flushCache="true"
保证数据一致性。
延迟加载的适用场景
适合使用延迟加载的场景
- 关联数据使用频率低:如用户详情页的历史订单,只有用户点击"查看订单"时才需要加载
- 大数据量列表查询:只加载主数据,关联数据按需加载,避免一次性加载过多数据
- 层级数据结构:如树形结构,只需要加载当前节点数据,子节点按需加载
- 统计报表的明细数据:报表页面通常只展示汇总数据,详情数据按需加载
不适合使用延迟加载的场景
- 频繁访问关联数据:如订单详情页需同时展示用户和商品信息,此时即时加载更高效
- 批量数据处理:需要处理大量关联数据的场景,延迟加载会导致 N+1 问题
- 无状态服务:如 REST API,每个请求都会创建新的 Session,延迟加载可能导致会话关闭问题
- 高并发系统:延迟加载依赖会话,可能导致数据库连接长时间占用
复杂关联关系处理
多对多和嵌套加载处理
在处理复杂关联关系如多对多(用户-角色)或嵌套关系(用户-订单-商品)时,配置原理相似,但需要注意关联条件和层级结构:
在处理复杂关系时要点:
- 对于多对多关系:通常需要一个额外查询处理中间表连接
- 对于嵌套层级:需确保每层都正确配置延迟加载,并且会话保持活动状态直到所有层级都访问完毕
MyBatis 与 Hibernate 延迟加载对比
对于熟悉 Hibernate 的开发者,了解两者差异有助于更好地使用 MyBatis 的延迟加载:
特性 | MyBatis | Hibernate |
代理实现与性能 | 基于动态代理(JDK/CGLIB),代理对象创建速度快,但功能相对简单 | 基于字节码增强(Javassist/ByteBuddy),初始化较慢但运行性能好 |
加载方式 | 通过单独的 select 查询(需手动配置) | 支持 JOIN 方式和单表查询两种延迟加载 |
会话管理 | 需手动管理 SqlSession 生命周期 | 通过 Session/EntityManager 自动处理 |
配置方式 | XML 或注解,需明确设置 fetchType | 通过映射关系直接控制(如@OneToMany(fetch=FetchType.LAZY)) |
N+1 解决 | 需手动批量查询或配置关联查询 | 提供批处理机制(batch fetching)自动优化 |
实际应用建议
- 选择性启用:不是所有场景都适合使用延迟加载,需要根据业务特点选择
- 合理设置全局配置:
- 开发环境可以设置
lazyLoadingEnabled=true
方便调试 - 生产环境根据实际性能测试结果决定
- 尽量保持
aggressiveLazyLoading=false
,避免非预期的性能问题
- 结合缓存机制:MyBatis 的一级缓存、二级缓存与延迟加载配合使用,可以进一步提升性能
- 在 Service 层管理好会话:确保访问延迟加载属性时 SqlSession 仍然处于打开状态,或考虑使用 Spring 的
OpenSessionInView
模式 - 性能测试:在生产环境部署前,对延迟加载的性能影响进行充分测试,包括高并发场景
总结
我们来用表格总结一下 MyBatis 的延迟加载特性:
特性 | 描述 |
支持情况 | MyBatis 完全支持延迟加载功能 |
实现原理 | 基于动态代理机制(JDK 代理或 CGLIB 代理) |
延迟容器 | 使用 ResultLoaderMap 存储延迟加载任务 |
全局配置 |
|
局部控制 | 通过 |
触发条件 | 调用 getter 方法、集合操作方法(size/isEmpty)、遍历等 |
会话依赖 | 延迟加载依赖活动的 SqlSession 和事务 |
N+1 优化 | 批量查询、multiple columns 传参 |
序列化处理 | 实现 Serializable 接口、预先触发延迟加载、自定义序列化策略 |
与缓存结合 | 延迟加载结果会进入一/二级缓存,提升后续访问性能 |
适用场景 | 关联数据使用频率低、大数据量列表查询、层级数据结构 |