MyBatis 高级映射与延迟加载完全指南
一、概述
在实际企业级开发中,数据库表之间往往存在复杂的关联关系(一对一、一对多)。MyBatis 作为优秀的持久层框架,通过resultMap的高级映射功能,提供了优雅的解决方案来处理这些关联关系。
同时,为了优化性能,MyBatis 引入了延迟加载(Lazy Loading)机制,允许我们在真正需要关联数据时才去查询数据库,避免不必要的性能开销。
本文将深入剖析 MyBatis 的高级映射(association、collection)及延迟加载的配置与使用,并结合N+1查询问题给出最佳实践。
二、高级映射:关联关系处理
在 MyBatis 中,处理关联关系主要依赖<resultMap>的两个子标签:
<association>:处理一对一(One-to-One)关系。<collection>:处理一对多(One-to-Many)关系。
这两种关联映射都支持两种不同的实现方式:
- 嵌套结果(Nested Results):通过一次
JOIN查询,将主表和关联表的数据一次性查出来,然后进行映射(性能最优)。 - 嵌套查询(Nested Queries):先查主表,然后通过另一条 SQL 语句去查关联表(配合延迟加载,实现按需查询)。
2.1 一对一映射(association)
场景:每辆车(Car)都有一个发动机(Engine)。
实体类
publicclassCar{privateLongid;privateStringcarNum;privateStringbrand;privateEngineengine;// 一对一关联// getter/setter...}publicclassEngine{privateLongid;privateStringtype;// V8, 电动privateIntegerpower;// 功率}方式一:嵌套结果(JOIN 查询,推荐)
原理:一次 SQL 搞定,性能最高。
<resultMapid="CarEngineMap"type="car"><idproperty="id"column="id"/><resultproperty="carNum"column="car_num"/><resultproperty="brand"column="brand"/><!-- 一对一:映射关联的 Engine 对象 --><associationproperty="engine"javaType="Engine"><idproperty="id"column="engine_id"/><resultproperty="type"column="engine_type"/><resultproperty="power"column="engine_power"/></association></resultMap><selectid="selectCarWithEngine"resultMap="CarEngineMap">SELECT c.id, c.car_num, c.brand, e.id AS engine_id, e.type AS engine_type, e.power AS engine_power FROM t_car c LEFT JOIN t_engine e ON c.id = e.car_id WHERE c.id = #{id}</select>方式二:嵌套查询(配合延迟加载)
原理:先查 Car,再根据car_id单独查 Engine。
<resultMapid="CarEngineMapLazy"type="car"><idproperty="id"column="id"/><resultproperty="carNum"column="car_num"/><resultproperty="brand"column="brand"/><!-- select:指向另一个 Mapper 的查询方法,column:传给子查询的列名 --><associationproperty="engine"javaType="Engine"select="com.xie.mapper.EngineMapper.selectByCarId"column="id"fetchType="lazy"/><!-- lazy:延迟加载 --></resultMap><selectid="selectCarLazy"resultMap="CarEngineMapLazy">SELECT id, car_num, brand FROM t_car WHERE id = #{id}</select>2.2 一对多映射(collection)
场景:一辆车(Car)有多个配件(Part)。
实体类
publicclassCar{privateLongid;privateStringcarNum;privateStringbrand;privateList<Part>parts;// 一对多关联}publicclassPart{privateLongid;privateStringname;privateDoubleprice;}方式一:嵌套结果(JOIN 查询,推荐)
<resultMapid="CarPartsMap"type="car"><idproperty="id"column="id"/><resultproperty="carNum"column="car_num"/><resultproperty="brand"column="brand"/><!-- 一对多:ofType 指定集合中元素的类型 --><collectionproperty="parts"ofType="Part"><idproperty="id"column="part_id"/><resultproperty="name"column="part_name"/><resultproperty="price"column="part_price"/></collection></resultMap><selectid="selectCarWithParts"resultMap="CarPartsMap">SELECT c.id, c.car_num, c.brand, p.id AS part_id, p.name AS part_name, p.price AS part_price FROM t_car c LEFT JOIN t_part p ON c.id = p.car_id WHERE c.id = #{id}</select>注意:
collection必须使用ofType指定集合泛型类型,而不是javaType(javaType默认就是List)。
方式二:嵌套查询(配合延迟加载)
<resultMapid="CarPartsMapLazy"type="car"><idproperty="id"column="id"/><resultproperty="carNum"column="car_num"/><resultproperty="brand"column="brand"/><collectionproperty="parts"ofType="Part"select="com.xie.mapper.PartMapper.selectByCarId"column="id"fetchType="lazy"/></resultMap>三、延迟加载(Lazy Loading)
3.1 什么是延迟加载?
延迟加载是指:当 MyBatis 查询主对象时,不会立即查询关联对象,只有当我们调用关联对象的getter方法(如car.getEngine())时,MyBatis 才会触发关联查询。
3.2 配置方式
全局配置(mybatis-config.xml)
<settings><!-- 开启延迟加载(默认 false) --><settingname="lazyLoadingEnabled"value="true"/><!-- 激进加载(默认 true):如果为 true,只要主对象被访问,所有关联对象都会被加载。 设为 false 则只会在显式调用 getter 时加载,推荐关闭 --><settingname="aggressiveLazyLoading"value="false"/><!-- 触发延迟加载的方法列表,用逗号分隔 --><settingname="lazyLoadTriggerMethods"value="equals,clone,hashCode,toString"/></settings>局部配置(在association/collection中覆盖)
通过fetchType属性覆盖全局配置:
fetchType="eager":立即加载(不使用延迟)。fetchType="lazy":延迟加载(优先级高于全局配置)。
<associationproperty="engine"select="...selectByCarId"column="id"fetchType="lazy"/><!-- 该关联使用延迟加载 -->3.3 核心原理(面试重点)
- MyBatis 为返回的
Car对象生成一个代理对象(通过 CGLIB 或 Javassist)。 - 当你调用
car.getEngine()时,代理对象会拦截该调用。 - 它判断
engine属性是否为null且未加载,如果是,则执行预先配置好的selectSQL,将查询结果赋值给该属性。 - 返回真实的结果。
四、N+1 查询问题与性能陷阱
4.1 什么是 N+1 问题?
当使用嵌套查询时,如果主查询返回了 N 条记录,那么为了加载每条记录的关联对象,MyBatis 会额外执行 N 次子查询,总共执行1 + N次 SQL。
示例:
List<Car>cars=carMapper.selectAll();// 查询 10 辆车 (1 次 SQL)for(Carcar:cars){// 访问 parts 时,会触发 10 次额外的 SQL (N 次 SQL)System.out.println(car.getParts().size());}即便开启了延迟加载,N+1次查询依然会发生,只不过从“启动时立即发生”推迟到了“遍历访问时发生”。
4.2 解决方案:使用嵌套结果(JOIN)
这是解决 N+1 问题的唯一根本方法。
通过一次JOIN查询,将所有数据(主表 + 关联表)一次性查出来,MyBatis 通过resultMap自动去重并组装成对象树。
<selectid="selectAllWithParts"resultMap="CarPartsMap">SELECT c.*, p.* FROM t_car c LEFT JOIN t_part p ON c.id = p.car_id</select>此时只有1 次 SQL,性能最高。
4.3 策略选择
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 数据量小 / 必须立即展示 | 嵌套结果(JOIN) | 性能最好,一次查出所有数据 |
| 主表数据量大 / 关联字段不常用 | 嵌套查询 + 延迟加载 | 节省内存和带宽,按需加载 |
| 同时加载多层关联 | 谨慎使用嵌套查询 | 容易导致 N+1 爆炸,优先使用 JOIN |
五、高级映射与延迟加载最佳实践
5.1 分步查询与延迟加载的实战组合
利用延迟加载,我们可以将复杂的多表关联查询拆分为多个独立的单表查询,提高代码复用性。
Mapper 接口:
publicinterfaceCarMapper{CarselectById(Longid);// 只查 Car}publicinterfaceEngineMapper{EngineselectByCarId(LongcarId);// 只查 Engine}XML 组合:
<resultMapid="CarMap"type="car"><idcolumn="id"property="id"/><!-- 此处相当于在 CarMapper 中调用了 EngineMapper 的方法 --><associationproperty="engine"select="com.xie.mapper.EngineMapper.selectByCarId"column="id"fetchType="lazy"/></resultMap>这种写法使得 SQL 逻辑清晰,且selectByCarId方法可以在其他地方复用。
5.2 警惕aggressiveLazyLoading
在旧版 MyBatis 中,如果aggressiveLazyLoading=true,只要调用了主对象的任何getter方法(即使不是关联属性的 getter),都会触发所有延迟加载关联的查询。
建议:在 MyBatis 3.x 及以上,保持aggressiveLazyLoading=false(虽然默认已改为 false,但最好显式配置),仅在访问具体关联对象时触发查询。
5.3 在 Spring 环境中处理 Session 问题
延迟加载需要SqlSession保持开启状态。如果在 Spring MVC 的 Controller 中直接返回实体类,而视图层渲染时才触发延迟加载,此时 Session 可能已关闭,会抛出LazyInitializationException。
解决方案:
- 使用 Spring 的
OpenSessionInViewFilter(OSIV)模式,将 Session 生命周期延长到请求结束。 - 在 Service 层使用
@Transactional,确保事务提交前完成所有关联对象的加载(触发加载)。
六、常见问题排查
| 异常信息 | 原因 | 解决方案 |
|---|---|---|
LazyInitializationException | Session 已关闭后尝试加载懒加载数据 | 配置 OSIV,或确保在 Session 开启期间访问关联数据 |
明明配置了lazy=true,但还是立即执行了查询 | aggressiveLazyLoading=true或调用了hashCode/equals等方法 | 设置aggressiveLazyLoading=false |
| 一对多关联中数据重复 | JOIN 查询时未配置<id>标签 | 务必在resultMap中配置<id>,MyBatis 依靠它去重 |
| 查询性能极差 | 使用嵌套查询且命中 N+1 场景 | 改用嵌套结果(JOIN)一次性查询 |
七、总结
| 知识点 | 核心要点 |
|---|---|
一对一(association) | 使用javaType,处理主表与附属表的关系 |
一对多(collection) | 使用ofType,处理主表与子表集合的关系 |
| 嵌套结果(Nested Results) | 使用JOIN,一次 SQL,性能最优,推荐优先使用 |
| 嵌套查询(Nested Queries) | 拆分 SQL,代码解耦,但存在 N+1 风险 |
| 延迟加载 | 通过lazyLoadingEnabled开启,配合fetchType精细化控制 |
| N+1 问题 | 嵌套查询下的性能陷阱,必须用 JOIN 重构 |
一句话总结
在 MyBatis 中,优先使用
resultMap+JOIN进行关联映射(嵌套结果)以获取最佳性能;当关联数据庞大且访问频率低时,借助select属性实现分步查询,并开启延迟加载(嵌套查询)来优化内存与响应时间。
掌握高级映射与延迟加载的原理,不仅能让你写出高性能的数据库访问代码,更能让你在系统性能调优时游刃有余。