作为 Java 后端开发,你是否曾经纠结过:查询用户信息时,要不要把用户关联的订单、地址一起查出来?全部查询性能肯定受影响,可不查又怕后面用到时反复访问数据库。这种"查不查"的两难抉择,其实可以通过 MyBatis 的延迟加载机制漂亮解决。那么问题来了,MyBatis 到底支持延迟加载吗?它背后的实现原理又是什么?
MyBatis 的延迟加载支持情况
MyBatis 确实支持延迟加载(Lazy Loading)功能,这是一种按需加载的策略,可以有效减轻系统负担,提高查询效率。
简单来说,当我们查询一个实体时,对于它的关联对象,不立即从数据库中加载,而是在第一次真正使用到关联对象时才去数据库查询。这样做可以避免一次性加载过多数据,尤其是在关联关系较多或数据量较大的情况下。
延迟加载的配置方式
MyBatis 提供了两个全局参数来控制延迟加载:
<settings>
<!-- 开启延迟加载功能 -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- 设置激进延迟加载策略 -->
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
lazyLoadingEnabled
:设置为 true 时开启延迟加载功能aggressiveLazyLoading
:设置为 false 时,按需加载对象属性(只有当调用该属性的 getter 方法时才加载);设置为 true 时,任何对对象方法的调用都会触发所有标记为延迟加载的属性加载
举个简单例子,当aggressiveLazyLoading=true
时:
User user = userMapper.getUserById(1);
user.getUsername(); // 仅想获取用户名,但会触发orderList等所有延迟加载属性的加载
// 或者
System.out.println(user); // 调用toString()方法,却触发了所有延迟属性的加载
因此,生产环境中通常建议保持aggressiveLazyLoading=false
,避免不必要的性能损耗。
除了全局配置外,还可以在关联查询中单独设置:
<!-- association关联查询时使用延迟加载 -->
<association property="author" column="author_id" select="selectAuthor" fetchType="lazy"/>
<!-- collection集合查询时使用延迟加载 -->
<collection property="posts" ofType="Post" column="id" select="selectPostsForBlog" fetchType="lazy"/>
通过fetchType
属性可以覆盖全局的延迟加载设置,值为lazy
表示使用延迟加载,eager
表示立即加载。
延迟加载的触发条件
延迟加载并非任何操作都会触发,具体的触发条件包括:
- 调用延迟属性的 getter 方法:如
user.getOrderList()
- 对延迟集合属性进行操作:如
orderList.size()
、orderList.isEmpty()
、遍历操作等 - 仅获取代理对象引用不会触发加载:必须调用其方法才会触发
User user = userMapper.getUserById(1);
// 以下操作不会触发延迟加载
List<Order> orderList = null;
orderList = user.getOrderList(); // 仅获取引用,不会触发加载
// 以下操作会触发延迟加载
int size = user.getOrderList().size(); // 调用size()方法触发加载
boolean isEmpty = user.getOrderList().isEmpty(); // 调用isEmpty()方法触发加载
for (Order order : user.getOrderList()) { // 遍历触发加载
// 处理订单
}
延迟加载的实现原理
MyBatis 的延迟加载主要是通过动态代理实现的。这里涉及两种代理模式:
- JDK 动态代理
- CGLIB 动态代理
字节码层面的代理原理
理解代理选择的核心,需要了解底层实现原理:
- JDK 动态代理:基于接口实现,通过
java.lang.reflect.Proxy
类在运行时生成接口的代理类。它要求目标类必须实现至少一个接口。 - CGLIB 动态代理:基于字节码生成技术,通过创建目标类的子类来实现代理。CGLIB 在运行时动态修改字节码,重写目标类的方法以插入延迟加载逻辑。
简单理解:JDK 代理是"实现接口",CGLIB 代理是"继承类"。这就是为什么实现了接口的类优先使用 JDK 代理,而普通类只能用 CGLIB 代理。
代理机制的选择
MyBatis 会根据目标类是否实现接口选择使用不同的代理机制:
// MyBatis ProxyFactory选择逻辑(简化版)
public class ProxyFactory {
private ProxyFactory() {
// Prevent Instantiation
}
public static Object createProxy(Object target, ResultLoaderMap lazyLoader, Configuration configuration,
ObjectFactory objectFactory, List<Class<?>> constructorArgTypes,
List<Object> constructorArgs) {
// target: 真实对象(如User实例)
// lazyLoader: 存储延迟加载任务的映射(属性名→加载器)
// 判断目标类是否为接口或者代理类
boolean isJdkProxy = target.getClass().getInterfaces().length > 0
&& !Proxy.isProxyClass(target.getClass());
if (isJdkProxy) {
// 使用JDK动态代理(优先选择,性能略优且符合Java标准)
return JdkProxyFactory.createProxy(target, lazyLoader, configuration, objectFactory,
constructorArgTypes, constructorArgs);
} else {
// 使用CGLIB动态代理(目标是非接口的普通类时)
return CglibProxyFactory.createProxy(target, lazyLoader, configuration, objectFactory,
constructorArgTypes, constructorArgs);
}
}
}
- 如果目标类实现了接口,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 代理处理逻辑中有很多相似部分,可以抽取公共方法处理:
// 公共方法处理逻辑
private Object handleSpecialMethods(Object target, Method method, Object[] args) throws Throwable {
final String methodName = method.getName();
if (methodName.equals("equals")) {
return target.equals(args[0]);
} else if (methodName.equals("hashCode")) {
return target.hashCode();
} else if (methodName.equals("toString")) {
return target.toString();
}
return null; // 不是特殊方法,返回null
}
// 然后在代理处理器中调用
Object result = handleSpecialMethods(target, method, args);
if (result != null) {
return result;
}
// 处理其他方法...
ResultLoaderMap:延迟加载的核心容器
ResultLoaderMap
是 MyBatis 用于管理延迟加载任务的容器,它存储了属性名与对应的ResultLoader
的映射关系。每个延迟属性对应一个ResultLoader
,当属性被访问时,通过ResultLoader
执行对应的子查询并填充数据。
ResultLoaderMap
是会话级(SqlSession
)容器,线程安全由SqlSession
的线程隔离性保证,无需额外同步。在高并发场景下,每个请求使用独立SqlSession
,避免线程间数据污染。
// ResultLoaderMap简化概念示意
public class ResultLoaderMap {
// 存储属性名到ResultLoader的映射
private final Map<String, LoadPair> loaderMap = new HashMap<>();
// 检查是否有指定属性的加载器
public boolean hasLoader(String property) {
return loaderMap.containsKey(property);
}
// 触发指定属性的加载
public void load(String property) throws SQLException {
LoadPair pair = loaderMap.get(property);
if (pair != null) {
pair.load(); // 执行SQL查询并填充结果
loaderMap.remove(property); // 加载后移除该加载器
}
}
}
// 加载器,包含了执行查询所需的全部信息
class LoadPair {
private final String property;
private final MetaObject metaResultObject;
private final ResultLoader resultLoader;
public void load() throws SQLException {
// 执行SQL查询获取结果
Object value = resultLoader.loadResult();
// 将结果设置到目标对象的属性上
metaResultObject.setValue(property, value);
}
}
延迟加载的实际案例
让我们通过一个用户(User)和订单(Order)的例子来看看延迟加载如何工作:
实体类定义
public class User implements Serializable { // 实现Serializable接口避免序列化问题
private Integer id;
private String username;
private List<Order> orderList;
// getter和setter方法
}
public class Order implements Serializable {
private Integer id;
private String orderNo;
private Double amount;
private Integer userId;
// getter和setter方法
}
MyBatis 配置
- 首先在 MyBatis 全局配置中启用延迟加载:
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="aggressiveLazyLoading" value="false"/>
</settings>
- 然后在 Mapper 文件中配置:
<mapper namespace="com.example.mapper.UserMapper">
<!-- 查询用户,延迟加载订单信息 -->
<select id="getUserById" resultMap="userResultMap" parameterType="int">
SELECT id, username FROM user WHERE id = #{id}
</select>
<!-- 根据用户ID查询订单列表 -->
<select id="getOrdersByUserId" resultType="com.example.entity.Order" parameterType="int">
SELECT id, order_no, amount, user_id FROM orders WHERE user_id = #{userId}
</select>
<resultMap id="userResultMap" type="com.example.entity.User">
<id property="id" column="id"/>
<result property="username" column="username"/>
<!-- 配置延迟加载 -->
<collection property="orderList" ofType="com.example.entity.Order"
column="id" select="getOrdersByUserId" fetchType="lazy"/>
</resultMap>
</mapper>
执行过程与事务
工具类及代码演示
首先,需要一个 MyBatis 工具类来获取 SqlSession:
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MyBatisUtil {
private static final Logger log = LoggerFactory.getLogger(MyBatisUtil.class);
private static final SqlSessionFactory sqlSessionFactory;
static {
try (InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml")) {
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
} catch (IOException e) {
log.error("MyBatis配置加载失败", e);
throw new RuntimeException("MyBatis配置加载失败", e);
}
}
public static SqlSession getSqlSession() {
return sqlSessionFactory.openSession();
}
}
注意:需要在类路径下添加mybatis-config.xml
配置文件,配置数据源和 Mapper 扫描。
然后,使用这个工具类编写延迟加载示例:
public class LazyLoadingDemo {
public static void main(String[] args) {
// 使用try-with-resources确保SqlSession正确关闭
try (SqlSession sqlSession = MyBatisUtil.getSqlSession()) {
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
// 查询用户信息
User user = userMapper.getUserById(1);
System.out.println("用户名: " + user.getUsername());
// 此时还没有执行订单查询的SQL
System.out.println("=== 分割线,以上SQL不包含订单查询 ===");
// 访问订单信息时,才会触发延迟加载,执行订单查询SQL
// 注意:延迟加载依赖活动的SqlSession,建议在会话关闭前完成所有延迟属性的访问
List<Order> orderList = user.getOrderList();
System.out.println("订单数量: " + orderList.size());
// 后续再次访问不会触发SQL查询,因为已缓存在一级缓存中
System.out.println("再次访问订单: " + user.getOrderList().size());
} // SqlSession自动关闭
// 注意:在此处访问user.getOrderList()会抛出异常
// 因为延迟加载依赖活动的SqlSession
}
}
延迟加载的优缺点
优点
- 性能提升:避免一次性加载过多不必要的数据,减少内存占用
- 按需加载:只有真正需要使用关联数据时才会查询,减少不必要的 IO 操作
- 降低系统压力:特别是在复杂关联关系或大数据量场景下,可以显著降低系统负担
缺点
- N+1 问题:当需要遍历一个集合并访问每个元素的延迟加载属性时,会导致主查询 1 次+每个对象的延迟查询 N 次,总共 N+1 次查询
- 代理对象序列化问题:延迟加载的代理对象序列化时可能会出现问题,尤其是 CGLIB 代理对象
- 会话关闭后无法加载:延迟加载依赖活动的数据库会话,SqlSession 关闭后无法再加载
解决 N+1 问题的方法
延迟加载可能导致的 N+1 问题可以通过以下方式解决:
1. 使用显式即时加载
在明确需要关联数据的场景下,可以显式指定即时加载:
<collection property="orderList" ofType="Order" column="id"
select="getOrdersByUserId" fetchType="eager"/>
需要注意的是,fetchType="eager"
并不是在 SQL 层面使用 JOIN 查询,而是在主查询完成后立即执行关联查询。本质上是"分步加载",但不需要等到属性被访问时才加载。
2. 使用 MyBatis 的批量查询功能
MyBatis 提供了多种批量查询方式来解决 N+1 问题:
a) 使用 multiple column 参数传递多个值进行批量查询
<!-- 配置批量查询的映射 -->
<collection property="orders" ofType="Order"
column="{userId=id, userName=username}" select="getOrdersByUserParams"/>
<!-- 批量查询方法接收多个参数 -->
<select id="getOrdersByUserParams" resultType="Order">
SELECT * FROM orders
WHERE user_id = #{userId}
AND create_by = #{userName}
</select>
b) 手动批量查询优化
// 手动批量查询优化示例
List<User> users = userMapper.getAllUsers();
List<Integer> userIds = users.stream().map(User::getId).collect(Collectors.toList());
List<Order> allOrders = orderMapper.getOrdersByUserIds(userIds); // 1次批量查询
// 建立用户-订单映射关系
Map<Integer, List<Order>> orderMap = allOrders.stream()
.collect(Collectors.groupingBy(Order::getUserId));
// 处理用户和订单
for (User user : users) {
List<Order> userOrders = orderMap.getOrDefault(user.getId(), Collections.emptyList());
System.out.println("用户" + user.getUsername() + "的订单数量: " + userOrders.size());
}
注意:虽然 MyBatis 提供了batchSize
配置,但它主要用于优化批量插入/更新操作,对延迟加载的 N+1 问题没有直接帮助。延迟加载的子查询仍然是单条执行的,需要通过上述手动批量查询方式优化。
3. N+1 问题的监控与预防
可以通过以下方式监控和预防 N+1 问题:
// 配置SQL监控
@Aspect
@Component
public class LazyLoadingMonitor {
private static final Logger log = LoggerFactory.getLogger(LazyLoadingMonitor.class);
// 可通过配置调整阈值
@Value("${mybatis.lazy.threshold:10}")
private long threshold;
@Around("execution(* com.example.entity.*.get*(..))")
public Object monitorLazyLoading(ProceedingJoinPoint pjp) throws Throwable {
String methodName = pjp.getSignature().getName();
Object target = pjp.getTarget();
// 判断是否可能触发延迟加载的getter方法
if (methodName.startsWith("get") && !methodName.equals("getClass")) {
// 记录方法调用前的时间
long start = System.currentTimeMillis();
Object result = pjp.proceed();
long end = System.currentTimeMillis();
// 如果执行时间过长,可能触发了延迟加载
long duration = end - start;
if (duration > threshold) {
log.warn("可能的延迟加载: 类={}, 方法={}, 执行时间={}ms",
target.getClass().getSimpleName(),
methodName,
duration);
}
return result;
}
return pjp.proceed();
}
}
也可以使用成熟的监控工具,如 MyBatis Plus 的性能分析插件来监控 SQL 执行。
代理对象序列化问题及解决方案
延迟加载使用的代理对象在序列化时可能会遇到问题,尤其是 CGLIB 代理类。CGLIB 生成的代理类名称类似$$EnhancerByCGLIB$$xxx
,反序列化时需要相同的类路径和类定义。在分布式系统中(如微服务架构),这种代理类可能无法在不同节点间正确反序列化,导致ClassNotFoundException
异常。
解决方案包括:
1. 确保实体类实现 Serializable 接口
所有实体类都应该实现java.io.Serializable
接口,包括关联实体类。
2. 在序列化前触发延迟加载
确保在序列化前已经访问过延迟加载属性,将代理对象转换为真实对象:
// 引入Jackson依赖
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class SerializationHelper {
private static final Logger log = LoggerFactory.getLogger(SerializationHelper.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
public static String prepareForSerialization(User user) {
try {
// 在序列化前触发所有延迟加载
if (user.getOrderList() != null) {
user.getOrderList().size(); // 触发延迟加载
}
// 现在user中的orderList已经是真实数据,可以安全序列化
return objectMapper.writeValueAsString(user);
} catch (JsonProcessingException e) {
log.error("序列化失败", e);
throw new RuntimeException("序列化失败", e);
}
}
}
3. 使用自定义序列化策略
使用 Jackson 或其他序列化工具的自定义序列化功能:
// 使用Jackson注解忽略代理相关属性
@JsonIgnoreProperties({"handler", "hibernateLazyInitializer"})
public class User implements Serializable {
// 实体类定义
}
延迟加载与事务的关系
延迟加载依赖的SqlSession
需与事务作用域一致。如果事务提前提交或回滚,会导致后续的延迟加载无法执行:
// 正确示例:在同一事务中完成延迟加载
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Transactional
public int getUserOrderCount(int userId) {
User user = userMapper.getUserById(userId);
// 在同一事务中访问延迟加载属性
return user.getOrderList().size();
}
}
在 Spring 环境中,可以使用OpenSessionInView
模式延长会话生命周期,但这可能导致数据库连接长时间占用,高并发系统中要谨慎使用。
延迟加载与缓存结合使用
MyBatis 的延迟加载与缓存机制可以协同工作,进一步提升性能:
一级缓存(会话级)
- 默认开启,作用域为 SqlSession
- 延迟加载的结果会存入一级缓存,同一会话内重复访问不会触发数据库查询
- 当执行 update、delete、insert 或调用 clearCache()时,一级缓存会被清空
二级缓存(全局)
- 需手动配置
<cache/>
或<cache-ref/>
- 延迟加载查询的结果也会被二级缓存缓存
- 跨会话访问时可以直接从二级缓存获取
<mapper namespace="com.example.mapper.UserMapper">
<!-- 启用二级缓存 -->
<cache eviction="LRU"
flushInterval="60000" <!-- 刷新间隔,单位毫秒 -->
size="1024" <!-- 引用数量 -->
readOnly="true"/> <!-- 只读设置 -->
<!-- 映射器配置 -->
</mapper>
readOnly=true
表示缓存对象不可变,MyBatis 会直接返回缓存对象引用,提升性能;readOnly=false
则返回对象副本,保证线程安全。
二级缓存存储的是完整对象(包括延迟加载后的数据),因此需确保延迟加载触发后的数据会被正确序列化并缓存。建议在getUserById
等主查询上配置缓存,延迟加载的子查询(如getOrdersByUserId
)可通过flushCache="true"
保证数据一致性。
延迟加载的适用场景
适合使用延迟加载的场景
- 关联数据使用频率低:如用户详情页的历史订单,只有用户点击"查看订单"时才需要加载
- 大数据量列表查询:只加载主数据,关联数据按需加载,避免一次性加载过多数据
- 层级数据结构:如树形结构,只需要加载当前节点数据,子节点按需加载
- 统计报表的明细数据:报表页面通常只展示汇总数据,详情数据按需加载
不适合使用延迟加载的场景
- 频繁访问关联数据:如订单详情页需同时展示用户和商品信息,此时即时加载更高效
- 批量数据处理:需要处理大量关联数据的场景,延迟加载会导致 N+1 问题
- 无状态服务:如 REST API,每个请求都会创建新的 Session,延迟加载可能导致会话关闭问题
- 高并发系统:延迟加载依赖会话,可能导致数据库连接长时间占用
复杂关联关系处理
多对多和嵌套加载处理
在处理复杂关联关系如多对多(用户-角色)或嵌套关系(用户-订单-商品)时,配置原理相似,但需要注意关联条件和层级结构:
<!-- 用户与角色的多对多关系 -->
<resultMap id="userWithRolesMap" type="com.example.entity.User">
<id property="id" column="id"/>
<result property="username" column="username"/>
<!-- 通过中间表查询关联角色 -->
<collection property="roles" ofType="com.example.entity.Role"
column="id" select="getRolesByUserId" fetchType="lazy"/>
</resultMap>
<!-- 嵌套延迟加载:订单-商品 -->
<resultMap id="orderMap" type="com.example.entity.Order">
<id property="id" column="id"/>
<result property="orderNo" column="order_no"/>
<!-- 嵌套层级的延迟加载 -->
<collection property="products" ofType="com.example.entity.Product"
column="id" select="getProductsByOrderId" fetchType="lazy"/>
</resultMap>
在处理复杂关系时要点:
- 对于多对多关系:通常需要一个额外查询处理中间表连接
- 对于嵌套层级:需确保每层都正确配置延迟加载,并且会话保持活动状态直到所有层级都访问完毕
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 存储延迟加载任务 |
全局配置 | lazyLoadingEnabled 和aggressiveLazyLoading 控制 |
局部控制 | 通过fetchType 属性覆盖全局设置 |
触发条件 | 调用 getter 方法、集合操作方法(size/isEmpty)、遍历等 |
会话依赖 | 延迟加载依赖活动的 SqlSession 和事务 |
N+1 优化 | 批量查询、multiple columns 传参 |
序列化处理 | 实现 Serializable 接口、预先触发延迟加载、自定义序列化策略 |
与缓存结合 | 延迟加载结果会进入一/二级缓存,提升后续访问性能 |
适用场景 | 关联数据使用频率低、大数据量列表查询、层级数据结构 |
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。