MyBatis(四):关联映射、延迟加载与框架对比
MyBatis(四):关联映射、延迟加载与框架对比
导语:这一篇补上 MyBatis 面试的"最后一公里"——结果映射怎么把
ResultSet变成对象(resultMap/autoMappingBehavior/TypeHandler在ResultSetHandler里的落地)、一对一/一对多的两种写法与 N+1 问题、延迟加载的代理原理与三个真实事故级坑(含"Session 关闭后访问"到底抛什么异常),最后回到选型视角:MyBatis 为什么叫半自动 ORM、与 Hibernate 怎么选、与 Spring 是怎么整合的,共 12 题。
一、结果映射与关联查询
1. MyBatis 是如何把 SQL 结果封装成目标对象的?
答: 分"启动期建映射"和"运行期填对象"两步。
(1)启动期:resultType / resultMap → ResultMap 对象
resultType="User" → 自动映射:不写映射规则,按"列名 ↔ 属性名"匹配
resultMap="userMap" → 显式映射:<id>/<result>/<association>/<collection>/<discriminator>
↓
ResultMap {
idResultMappings, // 主键映射
resultMappings, // 普通字段映射
constructorResultMappings, // 构造函数映射
associationMappings, // 嵌套一对一
collectionMappings, // 嵌套一对多
discriminatorResultMappings,
type, autoMapping, ...
}(2)运行期:ResultSetHandler(默认实现 DefaultResultSetHandler)
1. 创建结果对象:ObjectFactory.create(type)
- 无参构造(默认)→ 反射 newInstance
- 配了 <constructor> → 按构造参数映射(适合不可变对象)
2. 逐列应用映射:对每个 ResultMapping 用 TypeHandler.getResult()
→ typeHandler.setValue(obj, columnValue)(反射写入属性)
3. 嵌套映射(重点):
- 嵌套结果(association/collection 无 select):从同一 ResultSet 里取列递归映射
→ 用 <id> 判断"是否同一个对象",合并为一条对象图
- 嵌套查询(有 select):用 column 值作为参数再发一次查询,递归处理
4. 延迟加载:把未加载的嵌套对象包成代理,后续访问时再查(3)自动映射规则(autoMappingBehavior)
| 取值 | 行为 |
|---|---|
NONE | 完全关闭自动映射(只按 resultMap 显式规则) |
PARTIAL(默认) | 自动映射"没有嵌套结果"的字段(嵌套结果内部不自动映射) |
FULL | 嵌套结果也自动映射(风险高,容易误映射) |
四个实现细节:
- 列名匹配忽略大小写,且
user_name → userName依赖mapUnderscoreToCamelCase; - 没匹配上的属性保持默认值(null/0),不会报错——这就是"字段忘了映射,接口静默返回 null"的常见原因;
<id>很关键:它不仅标记主键,还用于嵌套结果的对象去重与合并(没有<id>时,一对多结果可能被错误合并或重复创建);resultType也可以用别名(typeAliases/@Alias),但多表 join 有同名列时必须用resultMap+ 别名,否则后者覆盖前者。
2. MyBatis 能映射枚举(Enum)吗?
答: 能,而且有三种方式,选错会造成"改枚举顺序 → 线上数据错乱"的严重问题。
| 方式 | 存到库里的值 | 配置 |
|---|---|---|
EnumTypeHandler(默认) | 枚举的 name()(如 PAID) | 默认行为,推荐 |
EnumOrdinalTypeHandler | 枚举的 ordinal()(序号 0/1/2…) | 需显式注册 typeHandlers |
自定义 TypeHandler | 自定义(如业务 code) | 继承 BaseTypeHandler<XxxEnum> |
<!-- 全局用 Java 内置枚举的 name() -->
<typeHandlers>
<!-- 也可只对某个枚举指定 -->
<typeHandler handler="org.apache.ibatis.type.EnumTypeHandler" javaType="com.x.OrderStatus"/>
</typeHandlers>
<!-- 或按字段指定 -->
insert into orders(status) values(#{status,typeHandler=com.x.OrderStatusHandler})// 自定义:按业务 code 存库(最稳)
@MappedTypes(OrderStatus.class)
public class OrderStatusHandler extends BaseTypeHandler<OrderStatus> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, OrderStatus p, JdbcType jt) throws SQLException {
ps.setInt(i, p.getCode());
}
@Override
public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
return OrderStatus.of(rs.getInt(columnName));
}
// getNullableResult(ResultSet, int) / (CallableStatement, int) 同理
}三个结论(面试官想让你说出来的):
- 不要用
ordinal()存库——枚举一旦插入新值、顺序变化,历史数据全部错位; name()存库(默认)最简单,但"枚举改名"会导致历史数据读不出来(重构时要注意);- 生产推荐自定义
TypeHandler+ 业务code:与数据库解耦,改名不影响数据(MyBatis-Plus 用@EnumValue+MybatisEnumTypeHandler表达同一思路)。
3. 一对一、一对多关联查询有哪几种方式?
答: 两种方式,本质区别在"发几条 SQL":
(1)嵌套结果(join 一条 SQL)—— 推荐
<resultMap id="blogMap" type="Blog">
<id property="id" column="b_id"/>
<result property="title" column="title"/>
<!-- 一对一 -->
<association property="author" javaType="Author">
<id property="id" column="a_id"/>
<result property="name" column="author_name"/>
</association>
<!-- 一对多 -->
<collection property="comments" ofType="Comment">
<id property="id" column="c_id"/>
<result property="content" column="content"/>
</collection>
</resultMap>
<select id="selectBlog" resultMap="blogMap">
select b.id b_id, b.title,
a.id a_id, a.name author_name,
c.id c_id, c.content
from blog b
left join author a on a.id = b.author_id
left join comment c on c.blog_id = b.id
where b.id = #{id}
</select>- 只发一条 SQL,性能好;必须给每张表的主键起别名并用
<id>标记(否则一对多会重复/漏合并); - 注意 join 后行数膨胀(1 篇博客 × 100 条评论 = 100 行),数据量大时要评估。
(2)嵌套查询(N+1,配延迟加载)
<resultMap id="blogMap" type="Blog">
<id property="id" column="id"/>
<result property="title" column="title"/>
<association property="author" column="author_id"
select="com.x.AuthorMapper.selectById" fetchType="lazy"/>
<collection property="comments" column="id"
select="com.x.CommentMapper.selectByBlogId" fetchType="lazy"/>
</resultMap>
<select id="selectBlog" resultMap="blogMap">select * from blog where id = #{id}</select>- 主查询 1 条 + 每个关联对象 1 条 → 分页列表就会变成 N+1;
- 优点:SQL 复用(关联查询可单独使用)、可延迟加载、不会行膨胀;
column也支持多列传参:column="{blogId=id,status=status}"(配合#{blogId}使用)。
对比表(答题模板):
| 维度 | 嵌套结果(join) | 嵌套查询(N+1) |
|---|---|---|
| SQL 条数 | 1 | 1 + N |
| 性能 | 好(但有行膨胀) | 差(N+1),需延迟加载缓解 |
| SQL 复用 | 差(专用 SQL) | 好(复用现有 Mapper 方法) |
| 延迟加载 | 不支持(一次性查完) | 支持 |
| 适用 | 列表页/详情页(大多数场景) | 关联对象经常用不到、或 SQL 复杂需复用 |
4. 什么是 N+1 问题?怎么解决?
答: N+1 = 1 条主查询 + N 条子查询。例如查 100 条订单,每条再查一次用户 → 101 条 SQL。
识别方法(比"知道概念"更实用): 打开 SQL 日志,若看到同一条子查询 SQL 被连续执行 N 次(只有参数不同),就是 N+1。
| 解决手段 | 做法 | 评价 |
|---|---|---|
| ① 改嵌套结果为 join | 一条 SQL 查完并合并对象图 | 首选,注意行膨胀与 <id> 标记 |
| ② 延迟加载(按需) | fetchType="lazy" + lazyLoadingEnabled=true,只在真正用到时才查 | 缓解而非消除;循环里访问每个对象就退化回 N+1 |
| ③ 批量加载(IN 查询) | 先收集所有外键,再用 IN 一次性查子表,内存分组组装 | 关联对象多、想避免 join 膨胀时最有效(MyBatis 不内置,需手写或插件) |
| ④ 只查需要的列 / 减少数据量 | 列表页不返回大字段与关联集合 | 治本:很多 N+1 来自"返回了不需要的嵌套数据" |
| ⑤ 缓存 | 关联数据是字典类时用二级缓存/应用缓存 | 仅适合低频变更数据 |
| ⑥ 用 DTO + 两次查询 | 主表查一次、子表按主键 IN 查一次,Service 层组装 | 微服务/复杂页面里最常用的工程做法 |
必须提醒的坑: 延迟加载 + JSON 序列化(Jackson 调 getter)会悄悄触发所有延迟属性,日志里就出现 N+1 —— 这也是"明明配了 lazy 还是慢"的常见原因。
5. resultMap 的继承(extends)与鉴别器(discriminator)有什么用?
答:
(1)extends:映射复用
<resultMap id="baseMap" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="createTime" column="create_time"/>
</resultMap>
<!-- 继承 + 追加 -->
<resultMap id="detailMap" type="User" extends="baseMap">
<result property="email" column="email"/>
</resultMap>- 用途:抽取公共字段(可跨 Mapper 引用:
extends="com.x.BaseMapper.baseMap"); - 好处:字段增删只改一处,避免"复制粘贴的 resultMap 各改各的"。
(2)discriminator:按列值选不同映射(多态结果)
<resultMap id="animalMap" type="Animal">
<id property="id" column="id"/>
<discriminator javaType="String" column="type">
<case value="dog" resultType="Dog">
<result property="barkVolume" column="bark_volume"/>
</case>
<case value="cat" resultType="Cat">
<result property="lives" column="lives"/>
</case>
</discriminator>
</resultMap>- 作用是"一行数据映射成不同子类"(相当于结果集层面的多态);
- 实际项目中更常见的做法是用不同查询/不同 DTO,把多态留给 Service 层——
discriminator属于"知道即可,慎用"的能力(调试困难、SQL 耦合子类结构)。
二、延迟加载
6. MyBatis 延迟加载的原理是什么?怎么配置?
答: 延迟加载只对 association(一对一)和 collection(一对多)生效,其他属性(普通列)一定是立即加载的。
原理(代理 + 待执行查询):
1. ResultSetHandler 处理嵌套对象时,若判定为"延迟加载":
- 用 ProxyFactory(默认 Javassist,可切换 CGLIB)为关联对象/集合创建代理
- 把"要执行的查询"(MappedStatement + 参数)封装成 ResultLoader 挂在代理上
- 代理对象的属性此时为空
2. 业务调用 blog.getAuthor().getName():
- 代理拦截 → 发现未加载 → ResultLoader.loadResult()
→ 用保存的 Executor 执行子查询 → 得到 Author → set 到目标对象
→ 再执行原方法调用(此后已加载,不再重复查)
3. 只访问 blog.getTitle()(未触碰关联对象)→ 子查询永远不会执行配置项(三件套):
<settings>
<!-- ① 开启延迟加载(默认 false) -->
<setting name="lazyLoadingEnabled" value="true"/>
<!-- ② 是否"触发一个属性就加载全部属性"
3.4.1 之前默认 true;3.4.1 起默认 false(即按需加载,推荐保持默认) -->
<setting name="aggressiveLazyLoading" value="false"/>
<!-- ③ 哪些方法一旦被调用就触发加载(默认 equals/hashCode/toString/clone) -->
<setting name="lazyLoadTriggerMethods" value="equals,clone,hashCode,toString"/>
</settings>按语句覆盖全局配置:
<association property="author" column="author_id"
select="com.x.AuthorMapper.selectById"
fetchType="lazy"/> <!-- lazy | eager:优先级高于全局 -->一句话:延迟加载 = "先给代理、后补查询"——
lazyLoadingEnabled是总开关,fetchType是单点覆盖,aggressiveLazyLoading=false才是真正的"按需"。
7. 延迟加载有哪些坑?
答: 四个坑,前两个是生产事故级:
(1)Session 已关闭再访问延迟属性 → 抛异常
- MyBatis 抛的是
org.apache.ibatis.executor.ExecutorException: Executor was closed.
(注意别答成LazyInitializationException——那是 Hibernate 的异常,这是本题最容易答错的点) - 原因:延迟加载需要
Executor去查库,而SqlSession/Executor已关闭; - 规避:在事务/Session 内完成访问或转成 DTO,或用
fetchType="eager"、或改成 join 一次性查完。
(2)JSON 序列化 / toString() 触发意外查询
- Jackson/Fastjson 序列化时会遍历所有 getter → 一次性触发所有延迟属性 → 界面上的 N+1;
toString()/equals()/hashCode()默认在lazyLoadTriggerMethods里,日志打印对象也会触发查询;- 规避:返回给前端用 DTO/ VO(在 Service 层组装),或加
@JsonIgnore,或不要开延迟加载。
(3)循环里访问关联对象 → 退化成 N+1
for (Blog b : list) { b.getAuthor().getName(); }→ 每条都发一次子查询;- 只有"只有少数几条数据会用到关联对象"时,延迟加载才真正省事。
(4)与缓存/嵌套查询的相互影响
- 延迟加载的查询也会走
Executor→ 参与一级/二级缓存,并会清一级缓存(写操作时); - 嵌套查询场景下,分页插件与延迟加载要慎配:分页的是主查询,子查询不受分页控制,容易出现"总数对不上/子查询过多"。
实践建议(可以直接当结论背):不要依赖延迟加载来做性能优化——要么在 Service 层明确地按需查 + 组装 DTO,要么用 join 嵌套结果。延迟加载的价值主要在"老代码改造/关联对象极少使用"的场景。
三、框架定位与对比
8. MyBatis 是什么?为什么叫"半自动 ORM"?
答:
- 定义:MyBatis 是持久层框架(Persistence Framework),它封装了 JDBC 的连接管理、参数设置与结果映射,但SQL 仍由开发者手写;
- "半自动"的含义:SQL 与对象映射是"人写 + 框架自动化"的分工——
- 开发者负责:写 SQL、描述映射关系(
resultMap); - 框架负责:参数绑定、执行、结果集 → 对象的自动映射、缓存、事务、插件。
- 开发者负责:写 SQL、描述映射关系(
| 半自动(MyBatis) | 全自动(Hibernate / JPA) | |
|---|---|---|
| 谁写 SQL | 开发者 | 框架生成(HQL/Criteria/方法名派生) |
| 关联对象 | 需要手写关联 SQL(association/collection) | 框架按对象关系自动加载(@OneToMany 等) |
| 控制力 | 高(可精确优化每一条 SQL) | 低(生成 SQL 不可控,调优靠"猜+改映射") |
| 关键机制 | resultMap + SqlSource | 对象关系映射(ORM)+ 脏检查 + 延迟加载 |
一句话:MyBatis 把"SQL 的所有权"留给开发者,只把"机械映射"自动化——这就是"半自动"。
9. MyBatis 相比 JDBC 解决了哪些问题?
答: 把 JDBC 的"样板 + 易错"部分全部包掉:
| JDBC 的痛点 | MyBatis 的解决 |
|---|---|
| 获取/释放连接、异常处理模板代码 | DataSource + SqlSession 生命周期管理(Spring 下自动) |
| SQL 硬编码在 Java 里、难以维护 | SQL 放 XML/注解,与代码分离 |
手动 ps.setXxx() 逐个设参 | #{} + TypeHandler 自动绑定 |
手动遍历 ResultSet 组装对象 | ResultSetHandler + resultMap 自动映射 |
动态条件拼 SQL 易错(空格、逗号、and) | 动态 SQL(if/where/set/trim/foreach) |
| 无缓存、无插件扩展 | 一级/二级缓存 + 四大拦截点插件 |
| 分页、批处理要自己写 | RowBounds/分页插件、BatchExecutor |
代码量对比:一个常规查询在 JDBC 下约 20~30 行样板,MyBatis 下约 3~5 行——减少 80% 左右(这也是它成为国内主流持久层框架的直接原因)。
10. MyBatis 和 Hibernate 的核心区别?
答:
| 维度 | MyBatis | Hibernate / JPA |
|---|---|---|
| 定位 | 半自动:SQL 可控 | 全自动:对象模型驱动 |
| SQL 控制 | 手写 SQL,能精确优化 | 自动生成(HQL/Criteria),难以精确控制 |
| 数据库移植 | 差(SQL 与方言绑定) | 好(dialect 机制) |
| 关联加载 | 手动配 association/collection | 自动(fetch/lazy),但易产生 N+1 与不可控 SQL |
| 缓存 | 一级/二级缓存(能力较弱) | 一级/二级缓存更完善,可接第三方 |
| 学习成本 | 低(会 SQL 就会一半) | 高(缓存、脏检查、状态管理、flush 时机) |
| 调优方式 | 改 SQL / 加索引 | 调映射、调 flush/批量、抓生成的 SQL |
| 适用场景 | 互联网高并发、SQL 需要精调、需求多变 | 企业级系统、领域模型复杂、需求相对稳定 |
结论(答辩式回答):没有绝对优劣,只有匹配——需要"每一条 SQL 都在掌控之中"(分库分表、大表优化、复杂报表)选 MyBatis;需要"业务模型驱动、快速迭代、少写 SQL"选 JPA/Hibernate。国内互联网 95% 以上选 MyBatis(或 MyBatis-Plus)。
11. MyBatis 的优缺点?
答:
优点
- SQL 可控、易优化:可直接写 hint、改写 SQL、配合执行计划调优——这是它在高并发场景胜出的根本原因;
- 入门低、上手快:会 SQL 就基本会用;
- SQL 与代码解耦:XML 集中管理,DBA 也能参与 review;
- 扩展性好:插件、
TypeHandler、ObjectFactory、Cache都是可替换的接口; - 生态成熟:分页(PageHelper)、代码生成(MBG)、增强(MyBatis-Plus)、与 Spring 整合无缝。
缺点
- SQL 工作量大:字段/关联表一多,XML 膨胀,维护成本高(MyBatis-Plus / MyBatis Dynamic SQL 就是来缓解这个的);
- 数据库移植性差:SQL 与方言绑定,换库(Oracle → MySQL)要改 SQL;
- 映射需要人工维护:字段增删要同步改
resultMap/SQL;映射错误不报错(静默返回 null)是常见坑; - 无"对象关系"能力:关联对象要自己配,N+1 要靠人治理;
- 二级缓存偏鸡肋:全局失效、跨节点不一致(见《MyBatis(二)》第 8 题)。
加分答法:"MyBatis 把复杂度从框架转移到了开发者身上"——换来的是对 SQL 的完全控制;所以它更适合"有 DBA/重视 SQL 质量的团队",小团队要接受"SQL 质量参差"的代价。
四、与 Spring 集成
12. MyBatis 与 Spring 是如何整合的?SqlSession 的生命周期怎么管理?
答: 整合由 mybatis-spring(Spring Boot 下由 mybatis-spring-boot-starter 自动装配)完成,共有四个关键角色:
| 组件 | 作用 |
|---|---|
SqlSessionFactoryBean | 把 DataSource、Configuration(含 typeAliases、plugins、mapperLocations)装配成 SqlSessionFactory,交给 Spring 容器 |
MapperScannerConfigurer / @MapperScan | 扫描 Mapper 接口,注册为 MapperFactoryBean(见《MyBatis(三)》第 9 题) |
SqlSessionTemplate | 线程安全的 SqlSession:每次调用通过 SqlSessionInterceptor 动态获取/新建会话,用完自动关闭/归还 |
SpringManagedTransaction | 让 MyBatis 的 Connection 与 Spring 事务的 ConnectionHolder 绑定 |
SqlSession 生命周期(核心中的核心):
Mapper 方法调用
→ SqlSessionTemplate(单例、线程安全)
→ SqlSessionInterceptor.invoke()
→ SqlSessionUtils.getSqlSession(factory, executorType, exceptionTranslator)
├─ ① 当前线程存在 Spring 事务? → 复用"事务绑定"的 SqlSession(同一事务同一连接)
│ (事务结束由 TransactionSynchronizationManager 回调关闭)
└─ ② 没有事务? → 新建 SqlSession → 调用结束后立即 close()
→ 执行 MapperMethod由此可以解释四个高频现象:
- "为什么同一个事务里的多次查询不会各开一个连接?" —— 因为
SqlSessionUtils复用了事务绑定的SqlSession,底层连接来自 Spring 的ConnectionHolder; - "为什么 MyBatis 的操作能被
@Transactional回滚?" —— 因为二者共用同一个Connection与同一套事务管理(SpringManagedTransaction不自己 commit,而是交给 Spring); - "为什么 Mapper 是单例还线程安全?" —— 真正的会话是"每次调用取/建"的,
SqlSessionTemplate本身不持有会话态; - "为什么同一事务内两次相同查询只发一次 SQL?" —— 复用的是同一个
SqlSession的一级缓存(见《MyBatis(二)》第 1 题)。
与 MyBatis-Plus 的关系(顺带回答最常见的"跟进问题"):
- MyBatis-Plus 不是替代 MyBatis,而是在 MyBatis 之上的增强:
BaseMapper通用 CRUD、LambdaQueryWrapper条件构造器、分页插件、代码生成、逻辑删除、自动填充、乐观锁、多租户插件; - 它的
MybatisSqlSessionFactoryBean替换了mybatis-spring的实现以注入 MP 的Configuration/GlobalConfig,因此 MP 与 mybatis-spring 的SqlSessionFactoryBean不能混用; - SQL 的执行主干(
Executor→ 三大Handler)仍然是 MyBatis 的——所以本篇(以及《MyBatis(三)》)讲的原理,在 MP 项目里依然成立,被问到时要说清"MP 是增强,不是另一个 ORM"。
收尾一句话:MyBatis 负责"把 SQL 用好",Spring 负责"把对象管好";整合的关键只有两点——Mapper 接口变成 Spring Bean(
MapperFactoryBean)、SqlSession 跟着 Spring 事务走(SqlSessionTemplate+SpringManagedTransaction)。
