做后端开发的,应该都碰过这样的场景:订单列表页需要把用户、商品明细一次性展示出来,于是在 Service 层写了一个循环,先查订单,再根据订单里的 userId 挨个去查用户。订单一多,几百条 SQL 瞬间就打到数据库上。第一次写可能觉得没什么,等接口变慢、数据库 CPU 上升,才会回头发现:MyBatis 的高级映射配合延迟加载,天生就是用来解决这种“对象组装”问题的。
这篇文章我会把 resultMap 的细节、association/collection 的用法、延迟加载的原理和关键开关讲透,最后给一套可以直接照着用的 Spring Boot 示例。不管你是在复习面试题,还是正在优化一个被 N+1 查询拖垮的接口,都可以对照着改。
1. 先从设计说起:高级映射要解决什么问题
1.1 多表查询的三条老路,和它们各自的代价
真实的业务表设计里,对象和对象几乎总是“连”着的。用户有角色,订单有会员信息,文章有作者和评论。很多项目刚起步时,表少、数据量小,怎么查都行。可一旦数据量上来,或者接口被前端反复调用,多表查询的设计方式就会直接影响性能和代码维护成本。
我见过最多的三种写法:
第一种,在 Service 层手写 for 循环补数据。先查订单主表,拿到 userId 集合,再循环去查用户表。订单 20 条就是 21 条 SQL,订单 200 条就是 201 条 SQL。代码看着简单直观,但这就是典型的 N+1 查询,数据库连接和 SQL 编译开销会成倍增加,等接口慢到报警时再回头改,往往已经动到很多业务方法了。
第二种,在 select 里把关联表一次性 JOIN 出来,然后把一堆冗余列拿回 Service 层,再用 while(rs.next()) 去重、分组、set 属性。这种写法 SQL 数量确实少了,但结果集到对象图的组装逻辑散落在业务代码里,字段一改,就要跟着改循环和判断。尤其是多对多关系、嵌套集合,手写去重逻辑很容易出错,而且没法复用。
第三种,直接换 ORM,把 MyBatis 当成全自动框架用,希望它自动理解表关系和对象映射。但 MyBatis 的定位是半自动 ORM:SQL 交给你写,结果集怎么变成对象,它不能靠猜,需要你提供映射规则。不理解这一点的人,往往会觉得 MyBatis 麻烦,实际上它只是把决定权交回给了你。
高级映射的意义,就是把“结果集转对象图”的规则从业务代码搬到 XML 的 resultMap 里,让关联字段、嵌套对象、集合元素都变成可配置、可复用、可调试的描述。SQL 查询和对象组装解耦,Service 层只管业务逻辑,不关心数据怎么拼装。
1.2 resultMap 是高级映射的地基
大概从你第一次在 MyBatis 里写一条超过二十行的 select 开始,就会遇到 resultType 不够用的问题。resultType 只能做“列名到同名属性的自动映射”,遇到 User 里有 List
resultMap 能做到的事很简单也很核心:把 SQL 返回的列,按你定义的规则填充到对象图的任意深度。它由 id、result、association、collection、discriminator 这几类子元素组成。id 表示主键,result 表示普通属性,association 表示单个关联对象,collection 表示集合,discriminator 可以按字段值切换映射规则。
这是高级映射的骨架,也是后续所有技巧的出发点。理解 resultMap,才能真正理解延迟加载的生效边界,因为延迟加载发生在嵌套查询的 association/collection 上,而不是普通的字段映射上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高级映射怎么用:resultMap、association 与 collection
2.1 先给 resultMap 立好地基:id、result、column 与 property
先看一个最基础的 resultMap:
xml复制<resultMap id="userMap" type="com.example.User">
<id property="id" column="user_id" />
<result property="name" column="user_name" />
<result property="createTime" column="create_time" />
</resultMap>
property 对应 Java 属性名,column 对应 SQL 查询出来的列名。值得留意的是 id 这个标签。很多初学者觉得 id 在这里可有可无,其实它对 MyBatis 判断“同一行”很关键。MyBatis 会用 resultMap 中的 id 列来标识对象身份,尤其在 JOIN 查询返回多条记录时,id 配置错误会导致同一个关联对象被重复创建,集合元素里出现重复数据。
如果你把数据库字段统一设计成下划线风格,比如 create_time,实体属性用驼峰 createTime,那么可以开启全局配置 mapUnderscoreToCamelCase 来减少手写 result 标签:
yaml复制mybatis:
configuration:
map-underscore-to-camel-case: true
这个开关打开后,普通列能少写很多映射,但嵌套对象里的字段映射还是要手动声明,因为 MyBatis 不会自动去猜 association 内部的子表字段和哪个对象属性对应。
另外,手写 XML 时最好装一个支持 MyBatis 语句提示的插件,不然 select 的 id 和 Mapper 接口方法名对不上、resultMap 写错了,启动时就会报 Invalid bound statement 或 NPE,定位起来比较费时。
2.2 association:一对一关联的两种打开方式
association 处理的是“一个订单对应一个用户”“一个用户对应一个角色”这种一对一关系。它有两条路:嵌套结果和嵌套查询。
嵌套结果的意思是:SQL 直接 JOIN,把用户表的字段一起查出来,然后通过 resultMap 内层再包一层 result 标签来映射:
xml复制<resultMap id="orderUserMap" type="com.example.Order">
<id property="id" column="order_id" />
<result property="orderNo" column="order_no" />
<association property="user" javaType="com.example.User">
<id property="id" column="user_id" />
<result property="name" column="user_name" />
</association>
</resultMap>
对应的 SQL 就是一条 JOIN:
sql复制SELECT o.id AS order_id,
o.order_no AS order_no,
u.id AS user_id,
u.name AS user_name
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.id = #{id}
嵌套结果的好处是只有一条 SQL,不产生额外的子查询,适合详情页这种“这次一定要把用户信息展示出来”的场景。
嵌套查询则是把关联对象交给另一个 select 去查:
xml复制<association property="user"
javaType="com.example.User"
column="user_id"
select="com.example.mapper.UserMapper.selectById" />
这样主查询先执行,MyBatis 拿到 user_id 后再执行 selectById。默认情况下它会在主 SQL 执行后立刻发起子查询;如果配合延迟加载,就能把子查询推迟到你真正访问 user 属性的时候。
这两条路不冲突,可以混用。嵌套结果适合必须一次拿全的场景,嵌套查询适合复用已有查询方法、想控制查询时机的场景。我的建议是:能用一条 JOIN 拿全的,优先用嵌套结果;只有当你明确知道这个关联数据不是每次都需要,才考虑嵌套查询加延迟加载。
2.3 collection:一对多集合的正确姿势
订单和订单明细是最典型的一对多关系。collection 的写法要注意一个关键字:ofType。javaType 描述的是属性的类型,ofType 描述的是集合元素的类型。
xml复制<resultMap id="orderWithItemsMap" type="com.example.Order">
<id property="id" column="order_id" />
<result property="orderNo" column="order_no" />
<collection property="items" ofType="com.example.OrderItem">
<id property="id" column="item_id" />
<result property="productName" column="item_name" />
<result property="price" column="item_price" />
</collection>
</resultMap>
对应 SQL 仍然是一条 JOIN:
sql复制SELECT o.id AS order_id,
o.order_no AS order_no,
i.id AS item_id,
i.product_name AS item_name,
i.price AS item_price
FROM t_order o
LEFT JOIN t_order_item i ON o.id = i.order_id
WHERE o.id = #{id}
用嵌套结果处理一对多时,有两点必须注意。
第一,集合元素的 id 一定要配。JOIN 出来的多行记录会在主表字段上有大量重复值,MyBatis 需要靠集合元素的主键来区分哪些是不同记录。如果不配 id,它会按照“行”来生成集合元素,同一个商品出现两行就会在 List 里出现两个对象。
第二,查询列的顺序要稳定。MyBatis 在构建嵌套结果映射时会根据列名去匹配,如果 select 里改成 SELECT *,一旦表结构变化、列顺序调整,关联字段可能映射错位。最好在 SQL 里显式写列名和别名,别偷懒。
如果需要传多个参数给嵌套查询的 collection,可以在 column 属性里写成花括号形式,例如 column="{orderId=id, userId=user_id}",子查询里就能通过参数名拿到多个值。这在跨多表条件查询时非常有用。
2.4 discriminator:按字段值切换映射的“隐形技能”
discriminator 是很多资料会跳过、但在某些场景特别好用的映射方式。它的作用是根据某列的值,动态选择不同的 resultMap 继续映射。
xml复制<discriminator javaType="int" column="order_type">
<case value="1" resultMap="normalOrderMap" />
<case value="2" resultMap="vipOrderMap" />
</discriminator>
比如订单表里有个 order_type 字段,普通订单和会员订单有完全不同的附加字段,用 discriminator 就能在同一个 select 上按订单类型映射到不同对象结构,避免在 Service 层写 if/else 做二次处理。
不过这条属于“会一个真香”的进阶功能,我的建议是控制使用范围,case 分支别超过三四个,否则 resultMap 的可读性会明显下降,排查问题时会比较痛苦。
3. 延迟加载的原理与配置:不该碰的数据就不碰
3.1 延迟加载到底延迟了什么
延迟加载这个命名很容易让人误解为“SQL 查询延迟了”。准确地说,它延迟的是嵌套查询中关联对象的子查询,而不是主查询。
只有“嵌套查询”这种方式配合延迟加载才有意义。嵌套结果本身只有一条 JOIN SQL,已经全部加载到内存了,谈不上延迟。所以讨论延迟加载时,先确认你用的是 association/collection 的 select 方式。
它解决的实际痛点是:主查询结束后,关联对象不一定马上需要。比如订单列表页,没展开详情前,不关心有哪些商品明细。如果没有延迟加载,MyBatis 执行完主 SQL 就会立刻去执行商品明细查询,列表上百条订单,就会多出一百次没必要的子查询。延迟加载做的事情是:先记住关联查询需要的参数,等你真正调用商品明细的 getter 时才发起 SQL。
这种机制用生活化一点的说法就是:点外卖时平台不会把后厨所有菜都做好一起送过来,它只知道你点了哪些菜,等你真正要吃的时候才做。对于“大概率不会看第二次”的数据,这是一种聪明的惰性。
3.2 两个关键开关:lazyLoadingEnabled 与 aggressiveLazyLoading
MyBatis 延迟加载总开关叫 lazyLoadingEnabled。在 Spring Boot 项目中,可以通过 application.yml 全局设置:
yaml复制mybatis:
configuration:
lazy-loading-enabled: true
aggressive-lazy-loading: false
lazyLoadingEnabled 设置为 true,表示允许使用延迟加载。另一个 aggressiveLazyLoading 则是控制加载粒度:
- aggressiveLazyLoading=false:只访问你调用的那个属性时,才触发对应的子查询。
- aggressiveLazyLoading=true:访问对象任意方法,都会触发所有关联属性的加载。
我强烈建议保持 false。如果设成 true,你只是想打印一下 order.getOrderNo(),结果 user 和 items 的子查询全部执行了,延迟加载就名存实亡。
这里还有一个容易被忽略的优先级问题:局部 fetchType 可以覆盖全局配置。你可以在 association/collection 上显式设置 fetchType="lazy" 或 fetchType="eager"。即使全局 lazyLoadingEnabled 没有打开,只要局部指定了 lazy,这条关联仍会延迟加载;反过来,如果局部写了 eager,全局开启延迟加载也不会影响它。
我的习惯是:全局打开 lazy-loading-enabled,默认都走延迟,但对那些业务上一定会用到的关联字段单独写 fetchType="eager"。这样默认行为偏懒,确有必要时才立刻加载。
3.3 延迟加载不是万能药:N+1 问题的两面性
延迟加载经常和 N+1 查询绑定在一起聊。N+1 指的是 1 条主查询产生了 n 条子查询。很多人误以为延迟加载能解决 N+1,实际上它只是把 N+1 往后推迟了。
如果业务逻辑最终会访问全部关联数据,那延迟加载反而会把 N+1 从“接口开始”推迟到“接口中间”,SQL 总数并没有变少。比如列表页明确要把每个用户的用户名渲染出来,那无论怎么延迟,最终还是要执行 n 条用户查询。这时候推荐用嵌套结果,一次 JOIN 全部拿到。
延迟加载真正适合的场景是“不确定关联字段用不用得上”。例如订单详情接口里,用户基础信息经常展示,但商品评价数据只在特定场景展示,那这个评价数据就可以做成 lazy。大多数请求不会访问到它,省下的 SQL 才是真实收益。
要不要用延迟加载,不是看它功能高不高大上,而是看业务访问模式。下面这个表格是我在实际项目里的判断依据。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 详情页必须展示用户和明细 | 嵌套结果(JOIN) | 一次查询全部拿到,不产生额外 SQL |
| 列表页每个订单都要展示用户名 | 嵌套结果(JOIN) | 数据必用,延迟加载无法减少总查询次数 |
| 关联数据只在少数场景查看 | 嵌套查询 + 延迟加载 | 大多数请求不会触发子查询,省资源 |
| 以对象嵌套形式返回给前端 | 嵌套结果/预先加载 | 避免序列化时逐个触发查询 |
4. 实操过程:在 Spring Boot 里写一个完整的案例
4.1 项目准备与实体设计
我用一个最常用的“用户-订单-订单明细”模型来演示,包括代码和 SQL。先准备依赖,基于 Spring Boot 2.x/3.x 都可以,关键是引入 mybatis-spring-boot-starter。
xml复制<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.2</version>
</dependency>
三个实体类:
java复制public class User {
private Long id;
private String name;
// getter/setter
}
public class OrderItem {
private Long id;
private String productName;
private BigDecimal price;
// getter/setter
}
public class Order {
private Long id;
private String orderNo;
private Long userId;
private User user;
private List<OrderItem> items;
// getter/setter
}
对应三张表:
sql复制CREATE TABLE t_user (
id BIGINT PRIMARY KEY,
name VARCHAR(32)
);
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32),
user_id BIGINT
);
CREATE TABLE t_order_item (
id BIGINT PRIMARY KEY,
order_id BIGINT,
product_name VARCHAR(64),
price DECIMAL(10, 2)
);
设计实体时,Order 里同时放了 User 和 List
4.2 嵌套结果映射:一次 JOIN 返回完整对象图
创建一个 OrderMapper 接口和 OrderMapper.xml。假设我们要查订单详情,并同时拿到用户和商品明细。先在 XML 里定义一个能映射整个对象图的 resultMap:
xml复制<resultMap id="orderWithUserAndItemsMap" type="com.example.Order">
<id property="id" column="order_id" />
<result property="orderNo" column="order_no" />
<result property="userId" column="user_id" />
<association property="user" javaType="com.example.User">
<id property="id" column="user_id" />
<result property="name" column="user_name" />
</association>
<collection property="items" ofType="com.example.OrderItem">
<id property="id" column="item_id" />
<result property="productName" column="item_name" />
<result property="price" column="item_price" />
</collection>
</resultMap>
对应查询方法:
xml复制<select id="selectOrderDetail" resultMap="orderWithUserAndItemsMap">
SELECT o.id AS order_id,
o.order_no AS order_no,
o.user_id AS user_id,
u.id AS user_id,
u.name AS user_name,
i.id AS item_id,
i.product_name AS item_name,
i.price AS item_price
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
LEFT JOIN t_order_item i ON o.id = i.order_id
WHERE o.id = #{id}
</select>
这段 SQL 的重点是每个列都起了别名,让结果集列名和 resultMap 里声明的 column 完全对应。同时注意 u.id 这列,它在 association 里作为 User 的主键标识。如果去掉这一列,MyBatis 遇到同一个用户的多条明细时,仍然能识别出同一用户吗?效率会变差,也容易出错。所以嵌套结果中的 id 映射一定不要省略。
至此,Service 层只需要调一次 mapper 方法,就能拿到一家订单含用户和商品明细的完整对象图,不再有循环查库。
4.3 嵌套查询与局部懒加载:按需查用户、按需查明细
再来看嵌套查询方式。首先确保全局配置已经打开延迟加载:
yaml复制mybatis:
configuration:
lazy-loading-enabled: true
aggressive-lazy-loading: false
然后写一个专门用于演示懒加载的 resultMap。这次 association 和 collection 都不再嵌套列映射,而是通过 select 指向另外两条查询。
xml复制<resultMap id="orderLazyMap" type="com.example.Order">
<id property="id" column="id" />
<result property="orderNo" column="order_no" />
<result property="userId" column="user_id" />
<association property="user"
javaType="com.example.User"
column="user_id"
select="com.example.mapper.UserMapper.selectById"
fetchType="lazy" />
<collection property="items"
ofType="com.example.OrderItem"
column="id"
select="com.example.mapper.OrderItemMapper.selectByOrderId"
fetchType="lazy" />
</resultMap>
<select id="selectOrderLazy" resultMap="orderLazyMap">
SELECT id, order_no, user_id
FROM t_order
WHERE id = #{id}
</select>
UserMapper 里需要一条按主键查用户的方法:
xml复制<select id="selectById" resultType="com.example.User">
SELECT id, name FROM t_user WHERE id = #{id}
</select>
OrderItemMapper 里需要一条按订单查明细的方法:
xml复制<select id="selectByOrderId" resultType="com.example.OrderItem">
SELECT id, product_name, price FROM t_order_item WHERE order_id = #{id}
</select>
这样配置后,执行 selectOrderLazy 时只会查 t_order。当你需要使用用户信息时,再调用 order.getUser().getName(),MyBatis 会用之前保存的 user_id 参数去执行 UserMapper.selectById。商品明细同理。
注意嵌套查询里 column 参数的作用。collection 的 column="id",指的是主表查询结果里的 id 列,它会作为参数传给 selectByOrderId。如果要从主表传多个字段,可以写成 column="{orderId=id, userId=user_id}",子查询方法签名里对应接收两个参数。
4.4 打印 SQL 日志,观察加载时机
验证延迟加载是否生效,最直接的方式是打开 MyBatis 的 SQL 日志。Spring Boot 中可以在 application.yml 里配置:
yaml复制mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
也可以只针对 Mapper 包级别开启 debug 日志:
yaml复制logging:
level:
com.example.mapper: debug
这时写一段测试代码:
java复制Order order = orderMapper.selectOrderLazy(1L);
System.out.println(order.getOrderNo());
// 此刻日志应该只有 selectOrderLazy 这一条 SQL
System.out.println(order.getUser().getName());
// 这句执行后,日志里才会出现 UserMapper.selectById
如果打开 StdOutImpl,你会看到 PreparedStatement 和 Parameters 成对出现。顺序会非常直观:主查询先执行,getUser() 触发后才执行子查询。
这里有一个很容易踩的坑:如果你想在 IDE 里直接打印 order 对象,或者在调试窗口看 order.toString(),而 Order 的 toString() 里包含了 user 或 items,那么延迟加载可能在你“无意识”的情况下被触发,日志里一下子冒出多条 SQL。这不是配置失效,而是 toString/equals/hashCode 这类方法调用了关联对象的 getter。排查时先确认触发点,再谈配置问题。
5. 常见问题与排查技巧实录
5.1 延迟加载不生效,或者加载失败
现象是配置了 lazy-loading-enabled,但日志里子查询立刻执行了。先检查两个地方。
第一,看是不是没有使用嵌套查询。只有 association/collection 的 select 方式才能延迟,嵌套结果里没有延迟的概念,数据已经在 JOIN 里查出来了。
第二,看有没有被局部 fetchType 覆盖。如果业务方法里写了 fetchType="eager",那全局延迟对它无效。想全局统一,就把这些 eager 全部去掉。
还有一种情况是加载失败,比如实体类被定义成 final class,或者没有无参构造。MyBatis 在创建延迟加载对象时,需要运行时生成目标类的“影子对象”,final 类和无参构造缺失都会让它反应不过来,常见报错是无法实例化。解决方法就是让实体对象保持普通 POJO,别加 final,别藏着掖着不写无参构造。
5.2 序列化时触发一堆 SQL 的坑
这是延迟加载最经典的坑。业务方法返回 Order 对象,Spring 用 Jackson 序列化成 JSON 给前端,Jackson 会反射调用 Order 的所有 getter,包括 getUser() 和 getItems()。于是“延迟”的关联对象全部被强制加载,日志里瞬间多出几十条 SQL。
我的建议不是去调序列化器,而是在业务源头控制:需要返回给前端的,统一使用 VO/DTO,只设置真正要展示的字段。这样既不会触发多余的懒加载,也避免了把 ORM 实体直接暴露给前端带来的结构问题。
如果你一定要返回实体,那就在 Service 层事务方法里,先把需要展示的 user 和 items 主动访问一遍,完成加载后再返回。虽然 SQL 变成了几次,但至少你能控制时机,而不是让序列化框架在不可控的位置帮你触发。
5.3 延迟加载与 MyBatis 缓存的配合要点
MyBatis 的一级缓存是 SqlSession 级别的,同一个 SqlSession 内执行过的查询会被缓存。延迟加载在同一次请求中,关联查询的结果也会进入一级缓存,后续再访问同一个关联对象,会先走缓存,这一点通常没问题。
二级缓存的问题要多一些。二级缓存是跨 SqlSession 共享的,它要求被缓存的对象实现 Serializable,延迟加载生成的影子对象在序列化时经常出问题。而且你缓存的是主对象 Order,它的 user 字段可能还是一个没有完全加载的影子对象,序列化时会把它加载出来,或者直接报错。
在实际项目里,我对“复杂对象 + 二级缓存 + 延迟加载”的组合比较保守。如果确实需要二级缓存,优先保证对象可序列化,并且对关联对象都采用 eager 加载,避免缓存和延迟代理互相纠缠。简单的字典数据可以放心缓存,复杂聚合对象就别过度设计缓存了。
5.4 Oracle 时间字段映射的小提醒
Oracle 和 MySQL 的时间类型处理不太一样。Oracle 的 DATE 包含日期和时间,MyBatis 从 Oracle 查出来时,默认可能映射成 java.sql.Timestamp。如果实体属性是 LocalDateTime,需要注意 jdbcType 和 javaType 是否对齐。
xml复制<result property="createTime"
column="create_time"
jdbcType="TIMESTAMP"
javaType="java.time.LocalDateTime" />
也可以在实体字段上用自定义 TypeHandler 处理。这类问题在 Oracle 环境里很常见,特别是表里字段类型是 DATE,Java 里用了 LocalDate,两边差一个时区或者精度,查出来的数据就会和你预期的不一致。排查时先确认驱动版本,再确认 resultMap 里的 jdbcType。
最后分享一个调优习惯:不确定一个接口到底发了多少 SQL 时,不要靠猜。把 StdOutImpl 的日志打开,再对照数据库慢查询日志,一眼就能看出哪些查询是多余的。MyBatis 的高级映射核心就是 resultMap,嵌套结果和嵌套查询两条路线各有适用场景;延迟加载则更像一把有条件的伞,在“可能用到、可能用不到”的数据上才有价值。把这两个点想清楚,比死记配置项更有用。
