📌 PDF :大白话说Java面试题 --- 05_Mybatis篇
第9题:MyBatis 是否支持一对一、一对多的关联查询?如何实现?
📚 回答:
- 核心考点 : MyBatis 关联查询是 ORM 框架面试的核心考点。面试官不会满足于"用
association和collection标签"这种表面回答,而是深入考察 联合查询(JOIN)与嵌套查询(分步查询)的选型差异 、N+1 问题的产生原因与规避方案 、延迟加载(Lazy Loading)的 CGLIB 代理实现原理 、以及 列名冲突(columnPrefix)和结果集去重(discriminator) 等工程实践细节。面试官真正想判断的是:你是否能在复杂业务场景下正确设计关联查询方案,并规避性能陷阱。
1. 一对一关联查询------两种实现方式深度对比
-
1.1 方式一:联合查询(嵌套结果 / Nested Results) 联合查询通过 SQL 的
JOIN一次性从多张表获取数据,在resultMap中通过association标签将结果映射到嵌套对象。这是性能最优的方案,因为只发一次 SQL。xml<!-- UserMapper.xml --> <resultMap id="userWithIdCardMap" type="User"> <id property="id" column="user_id"/> <result property="name" column="user_name"/> <!-- association 映射一对一对象 --> <association property="idCard" javaType="IdCard"> <id property="id" column="id_card_id"/> <result property="number" column="id_card_number"/> <result property="issueDate" column="id_card_issue_date"/> </association> </resultMap> <select id="selectUserWithIdCard" resultMap="userWithIdCardMap"> SELECT u.id AS user_id, u.name AS user_name, ic.id AS id_card_id, ic.number AS id_card_number, ic.issue_date AS id_card_issue_date FROM users u LEFT JOIN id_cards ic ON u.id = ic.user_id WHERE u.id = #{id} </select>关键点:
javaType指定关联对象的 Java 类型(必填);- 必须使用
column别名区分主表和关联表的同名列(如user_idvsid_card_id); LEFT JOIN确保即使关联表无数据,主表记录也能返回(关联对象为 null)。
-
1.2 方式二:嵌套查询(分步查询 / Nested Select) 嵌套查询先查主表,再根据主表结果中的外键值,发起第二次 SQL 查询关联对象。
xml<resultMap id="userWithIdCardStepMap" type="User"> <id property="id" column="id"/> <result property="name" column="name"/> <!-- select: 指定嵌套查询的 statement id --> <!-- column: 将当前查询结果的哪个列传递给嵌套查询作为参数 --> <association property="idCard" javaType="IdCard" select="com.example.mapper.IdCardMapper.selectByUserId" column="id" fetchType="lazy"/> </resultMap> <select id="selectUserById" resultMap="userWithIdCardStepMap"> SELECT id, name FROM users WHERE id = #{id} </select>xml<!-- IdCardMapper.xml --> <select id="selectByUserId" resultType="IdCard"> SELECT * FROM id_cards WHERE user_id = #{userId} </select>执行流程:
- 执行
selectUserById,查询到User的基本信息; - 发现
association的select属性,将column="id"的值(即 user.id)作为参数,调用IdCardMapper.selectByUserId; - 将查询到的
IdCard对象设置到User.idCard属性上。
适用场景:关联对象不总是需要(如详情页才展示身份证信息),配合延迟加载使用。citation:0
- 执行
2. 一对多关联查询------collection 的两种映射方式
-
2.1 方式一:联合查询(嵌套结果) 与一对一类似,通过
JOIN一次性查询,使用collection标签映射集合属性。xml<resultMap id="userWithOrdersMap" type="User"> <id property="id" column="user_id"/> <result property="name" column="user_name"/> <!-- collection 映射一对多集合 --> <collection property="orders" ofType="Order"> <id property="id" column="order_id"/> <result property="orderNumber" column="order_number"/> <result property="amount" column="order_amount"/> <result property="createTime" column="order_create_time"/> </collection> </resultMap> <select id="selectUserWithOrders" resultMap="userWithOrdersMap"> SELECT u.id AS user_id, u.name AS user_name, o.id AS order_id, o.order_number AS order_number, o.amount AS order_amount, o.create_time AS order_create_time FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.id = #{id} </select>关键注意点------结果集去重 :
当使用
JOIN查询一对多关系时,结果集中主表数据会重复出现(1 个用户有 3 个订单,结果集有 3 行,user_id 和 user_name 重复 3 次)。MyBatis 通过<id>标签识别主对象唯一标识,自动合并同一主对象的collection数据。如果忘记在 resultMap 中配置主对象的
<id>标签 ,MyBatis 无法识别哪些行属于同一个User,会导致集合数据重复或错乱。 -
2.2 方式二:嵌套查询(分步查询) 与一对一嵌套查询类似,通过
select属性指定子查询。xml<resultMap id="userWithOrdersStepMap" type="User"> <id property="id" column="id"/> <result property="name" column="name"/> <collection property="orders" ofType="Order" select="com.example.mapper.OrderMapper.selectByUserId" column="id" fetchType="lazy"/> </resultMap> <select id="selectUserById" resultMap="userWithOrdersStepMap"> SELECT id, name FROM users WHERE id = #{id} </select>xml<!-- OrderMapper.xml --> <select id="selectByUserId" resultType="Order"> SELECT * FROM orders WHERE user_id = #{userId} </select>
3. N+1 问题------嵌套查询的性能陷阱
-
3.1 什么是 N+1 问题? 当使用嵌套查询(
select属性)查询列表时,会引发严重的性能问题:xml<!-- 查询所有用户(假设返回 N 条) --> <select id="selectAllUsers" resultMap="userWithOrdersStepMap"> SELECT id, name FROM users </select>执行过程:
- 执行主查询
SELECT id, name FROM users,返回 N 个用户 → 1 次查询; - 对每个用户,触发
collection的嵌套查询SELECT * FROM orders WHERE user_id = ?→ N 次查询; - 总查询次数:N + 1 次。
当 N = 1000 时,会发起 1001 次数据库交互,性能灾难。citation:1
- 执行主查询
-
3.2 N+1 问题的解决方案对比
方案 实现方式 原理 适用场景 联合查询 改用 JOIN+resultMap嵌套结果一次 SQL 获取所有数据 关联数据必须同时展示 延迟加载 fetchType="lazy"+lazyLoadingEnabled=true按需触发嵌套查询 关联数据不总是需要 批量查询 MyBatis 的 foreach或自定义 IN 查询将 N 次单条查询合并为 1 次批量查询 需要关联数据,但不想用 JOIN 最佳实践 :如果列表查询中关联数据必须展示,坚决使用联合查询(JOIN),不要用嵌套查询。
4. 延迟加载(Lazy Loading)------CGLIB 代理实现原理
-
4.1 延迟加载的配置与效果 延迟加载允许关联对象在真正被访问时才触发查询,而不是在查询主对象时立即加载。
全局配置:
xml<settings> <!-- 开启延迟加载总开关 --> <setting name="lazyLoadingEnabled" value="true"/> <!-- MyBatis 3.4.1 前需配置,之后默认 false --> <setting name="aggressiveLazyLoading" value="false"/> </settings>局部配置(覆盖全局):
xml<association property="idCard" select="..." column="..." fetchType="lazy"/> <!-- fetchType="eager" 立即加载,fetchType="lazy" 延迟加载 --> -
4.2 延迟加载的三种模式 MyBatis 根据配置分为三种加载行为:citation:4
模式 配置 触发时机 说明 直接加载 lazyLoadingEnabled=false主查询后立即执行关联查询 默认行为,无延迟 侵入式延迟 lazyLoadingEnabled=true,aggressiveLazyLoading=true访问主对象任意属性时触发 3.4.1 前默认,已废弃 深度延迟 lazyLoadingEnabled=true,aggressiveLazyLoading=false仅访问关联对象属性时触发 3.4.1 后推荐 -
4.3 CGLIB 代理实现原理 MyBatis 延迟加载的核心是 CGLIB 动态代理:citation:3
- 当开启延迟加载后,MyBatis 为关联对象创建 CGLIB 代理对象(而非真实对象)设置到主对象中;
- 代理对象的属性初始为 null 或空集合;
- 当调用
user.getIdCard().getNumber()时,CGLIB 拦截器invoke()方法发现idCard是代理对象且未加载; - 拦截器执行事先保存好的嵌套查询 SQL,将真实数据查询上来;
- 调用
user.setIdCard(realIdCard)替换代理对象,然后继续执行getNumber()。
java// 伪代码示意 public Object invoke(Object proxy, Method method, Object[] args) { if (isLazyLoaderProperty(method)) { // 触发延迟加载 Object target = loadTargetObject(); // 执行嵌套 SQL setRealObject(target); // 替换代理对象 return method.invoke(target, args); // 调用真实对象方法 } return method.invoke(realObject, args); }注意 :延迟加载只对
association和collection的嵌套查询有效,联合查询(JOIN)不存在延迟加载概念,因为数据已经在一次查询中返回。citation:5
5. 高级映射技巧
-
5.1 列名冲突与 columnPrefix 当主表和关联表有同名列时,除了手动起别名,还可以使用
columnPrefix自动添加前缀:xml<resultMap id="userWithIdCardMap" type="User"> <id property="id" column="id"/> <result property="name" column="name"/> <!-- columnPrefix 会自动为所有子标签的 column 添加前缀 --> <association property="idCard" javaType="IdCard" columnPrefix="card_"> <id property="id" column="id"/> <!-- 实际映射 card_id --> <result property="number" column="number"/> <!-- 实际映射 card_number --> </association> </resultMap> <select id="selectUserWithIdCard" resultMap="userWithIdCardMap"> SELECT u.id, u.name, ic.id AS card_id, ic.number AS card_number FROM users u LEFT JOIN id_cards ic ON u.id = ic.user_id WHERE u.id = #{id} </select> -
5.2 多对多映射 多对多本质是一对多的嵌套。例如:用户-角色-权限模型。
xml<resultMap id="userWithRolesMap" type="User"> <id property="id" column="user_id"/> <result property="name" column="user_name"/> <collection property="roles" ofType="Role"> <id property="id" column="role_id"/> <result property="roleName" column="role_name"/> <!-- 在 collection 中再嵌套 collection --> <collection property="permissions" ofType="Permission"> <id property="id" column="perm_id"/> <result property="permName" column="perm_name"/> </collection> </collection> </resultMap> -
5.3 discriminator 鉴别器 当需要根据某列的值映射到不同的子类时,使用
discriminator:xml<resultMap id="vehicleResult" type="Vehicle"> <id property="id" column="id"/> <result property="vin" column="vin"/> <discriminator javaType="int" column="vehicle_type"> <case value="1" resultType="Car"> <result property="doorCount" column="door_count"/> </case> <case value="2" resultType="Truck"> <result property="boxSize" column="box_size"/> </case> </discriminator> </resultMap>
6. 四种关联查询方案深度对比
| 对比维度 | 联合查询 (JOIN) | 嵌套查询 (分步) + 立即加载 | 嵌套查询 + 延迟加载 | 嵌套查询 + 批量 |
|---|---|---|---|---|
| SQL 次数 | 1 次 | N+1 次 | 1 ~ N+1 次(按需) | 2 次 |
| 网络开销 | 最小 | 极大 | 中等 | 较小 |
| 内存占用 | 可能大(结果集冗余) | 小 | 小 | 中等 |
| 代码复杂度 | 低 | 低 | 低 | 高 |
| 适用场景 | 列表页、必须展示关联数据 | ❌ 生产环境禁用 | 详情页、关联数据不总是需要 | 特殊优化场景 |
| N+1 风险 | 无 | 有 | 无(按需触发) | 无 |
7. 生产环境避坑指南
-
7.1 严禁在列表查询中使用嵌套查询 列表查询返回 N 条记录时,嵌套查询会引发 N+1 问题。必须使用联合查询(JOIN),或改用延迟加载 + 分页。
-
7.2 联合查询必须配置主对象
<id>标签 一对多联合查询时,resultMap中主对象的<id>标签是合并集合数据的依据。遗漏会导致数据错乱。 -
7.3 注意
aggressiveLazyLoading的版本差异 MyBatis 3.4.1 之前,aggressiveLazyLoading默认值为true(侵入式延迟),访问主对象任意属性都会触发关联查询。3.4.1 之后默认false(深度延迟),只有访问关联对象属性时才触发。citation:4 -
7.4 延迟加载与序列化的冲突 CGLIB 代理对象在序列化(如转为 JSON 返回前端)时,如果
lazyLoadingEnabled=true且aggressiveLazyLoading=false, Jackson 等框架在反射获取属性时会触发延迟加载,可能导致意外查询。建议在 Service 层手动触发加载或使用 DTO 转换。 -
7.5 嵌套查询的缓存问题 嵌套查询的 SQL 会被独立缓存(如果开启二级缓存),但关联对象的缓存失效策略需要单独考虑。联合查询的结果作为一个整体缓存,一致性更容易维护。
-
7.6 大数据量 JOIN 的性能优化 联合查询虽然避免了 N+1,但
JOIN大量数据时可能导致结果集膨胀(1 万用户 × 平均 10 订单 = 10 万行)。优化方案:- 分页查询主表,再批量查询关联表;
- 使用
collection的select+fetchType="lazy",配合分页只加载当前页数据。
8. 面试官追问与高分回答模板
-
追问 1:"MyBatis 如何实现一对一和一对多关联查询?"
低分回答 :"用
association和collection标签。"(没有区分实现方式)高分回答:
"MyBatis 支持一对一和一对多关联查询,每种关系都有两种实现方式:
- 联合查询(嵌套结果) :通过 SQL 的
JOIN一次性获取所有数据,在resultMap中用association(一对一)或collection(一对多)映射。优点是只发一次 SQL,性能最好;缺点是需要处理列名冲突和结果集去重。 - 嵌套查询(分步查询) :先查主表,再通过
select属性指定子查询,将主表的外键列值传递给子查询。优点是 SQL 简单、支持延迟加载;缺点是列表查询时会引发 N+1 问题 (查询 N 条主记录会触发 N 次子查询)。
生产环境中,列表查询必须用联合查询,详情页且关联数据不总是需要时,可用嵌套查询 + 延迟加载。"
- 联合查询(嵌套结果) :通过 SQL 的
-
追问 2:"什么是 N+1 问题?MyBatis 中怎么避免?"
低分回答:"N+1 就是查询了很多次,用 JOIN 避免。"(没有讲清原理)
高分回答:
"N+1 问题出现在使用嵌套查询(
select属性)查询列表时:- 先执行 1 次主查询,返回 N 条记录;
- 然后对这 N 条记录,每条都触发一次关联查询(
association或collection的select); - 总查询次数 = N + 1 次。
当 N 很大时(如 1000),会发起 1001 次数据库交互,性能极差。
避免方案:
- 联合查询(JOIN) :改用
resultMap的嵌套结果映射,一次 SQL 获取所有数据,生产环境列表查询的首选; - 延迟加载 :设置
fetchType="lazy",让关联对象按需加载,避免列表查询时批量触发; - 批量查询 :将 N 次单条查询合并为 1 次 IN 查询(需自定义实现)。
核心原则:列表查询绝不用嵌套查询的立即加载。"
-
追问 3:"MyBatis 的延迟加载原理是什么?"
低分回答:"用代理对象,用到的时候才查。"(太浅)
高分回答:
"MyBatis 延迟加载基于 CGLIB 动态代理 实现,核心流程如下:
- 开启延迟加载后,MyBatis 为关联对象创建 CGLIB 代理对象(而非真实对象),设置到主对象中;
- 代理对象的属性初始为 null 或空集合,不触发任何 SQL;
- 当代码调用
user.getIdCard().getNumber()时,CGLIB 拦截器invoke()方法检测到idCard是未加载的代理对象; - 拦截器执行事先保存好的嵌套查询 SQL,将真实数据查询上来,调用
user.setIdCard(realIdCard)替换代理对象; - 然后继续执行
getNumber(),返回真实数据。
延迟加载只对association和collection的嵌套查询有效,联合查询(JOIN)不存在延迟加载,因为数据已经一次性返回了。
配置要点:lazyLoadingEnabled=true开启总开关,aggressiveLazyLoading=false(3.4.1 后默认)实现深度延迟,只有真正访问关联对象属性时才触发查询。"
-
追问 4:"联合查询和嵌套查询怎么选?"
高分回答:
"选型取决于业务场景和性能要求:
- 列表查询(返回多条记录):必须用联合查询(JOIN)。嵌套查询会引发 N+1 问题,数据量稍大就性能灾难。
- 详情查询(单条记录):如果关联数据必须展示(如订单详情必须显示用户信息),用联合查询;如果关联数据不总是需要(如用户详情页默认不显示订单列表,点击才展开),用嵌套查询 + 延迟加载。
- 大数据量 JOIN :如果一对多的数据量极大(如 1 万用户 × 100 订单),JOIN 会导致结果集膨胀到百万行,此时应考虑分页 + 延迟加载,或主表分页后批量查询关联表。
总结:列表用 JOIN,详情按需选,大数据量避免结果集膨胀。"
-
追问 5:"resultMap 中忘记配置主对象的
<id>标签,一对多查询会有什么后果?"高分回答:
"后果是 集合数据重复或错乱 。MyBatis 使用
<id>标签的值作为主对象的唯一标识,来判断哪些行属于同一个主对象。在一对多联合查询中,结果集有多行(如 1 个用户 3 个订单,结果集 3 行),MyBatis 通过user_id(<id>标签对应的列)识别这 3 行属于同一个User,然后合并orders集合。如果遗漏
<id>,MyBatis 无法识别行之间的关联关系,可能将每一行都当作一个新的User对象处理,导致返回 N 个User对象(每个只有 1 个订单),而不是 1 个User对象(包含 N 个订单)。这是生产环境中非常隐蔽的 Bug,排查时需要检查
resultMap是否正确定义了主对象的<id>。" -
追问 6:"如果让你设计一个用户-订单-订单明细的三层关联查询,你会怎么做?"
高分回答:
"三层关联查询的设计要分层考虑:
- 数据模型层 :
User包含List<Order>,Order包含List<OrderDetail>,OrderDetail包含Product。 - 查询方案选择 :
- 如果是一次性展示完整数据(如导出报表),使用 多层 JOIN +
resultMap嵌套collection(User→Order→OrderDetail),注意给每层的列加前缀避免冲突(如user_、order_、detail_); - 如果是用户详情页(默认只展示用户基本信息,点击才加载订单),使用 嵌套查询 + 延迟加载 :
User的orders配置fetchType="lazy",Order的details也配置fetchType="lazy"。
- 如果是一次性展示完整数据(如导出报表),使用 多层 JOIN +
- 性能优化 :
- 列表查询绝不用嵌套查询,避免 N+1;
- 大数据量时,三层 JOIN 的结果集会指数级膨胀,应分页查询主表(User),再用批量 IN 查询关联表;
- 开启二级缓存缓存热点数据。
- 列名冲突处理 :使用
columnPrefix或手动别名,确保每层数据的列名唯一。"
- 数据模型层 :
9. 方案选型速查表
| 业务场景 | 推荐方案 | 核心理由 |
|---|---|---|
| 列表页展示关联数据 | 联合查询(JOIN) | 一次 SQL,避免 N+1 |
| 详情页关联数据必显 | 联合查询(JOIN) | 一次查询,简单直接 |
| 详情页关联数据按需显示 | 嵌套查询 + 延迟加载 | 减少不必要的数据库交互 |
| 大数据量一对多列表 | 主表分页 + 批量 IN 查询关联表 | 避免结果集膨胀和内存溢出 |
| 树形结构(多级嵌套) | 嵌套查询 + 延迟加载 + 递归 | 按需加载,避免一次性加载整棵树 |
| 报表导出(全量数据) | 联合查询 + 流式读取 | 一次 SQL,JDBC 流式处理大结果集 |
💡 面试官想要的满分总结:
MyBatis 的关联查询不是简单的标签使用,而是涉及 SQL 执行策略、对象映射机制、性能优化 的综合设计问题。
一对一用
association,一对多用collection,但更重要的是理解 联合查询(JOIN)和嵌套查询(分步)的本质差异 :联合查询一次 SQL 获取所有数据,性能最优但需处理列名冲突和结果集去重;嵌套查询 SQL 简单但会引发 N+1 问题,生产环境列表查询中绝对禁用。延迟加载通过 CGLIB 代理 实现按需查询,适合详情页中关联数据不总是需要的场景。配置时要注意
aggressiveLazyLoading的版本差异(3.4.1 前后默认值不同),以及代理对象与 JSON 序列化的冲突。工程实践中的核心原则:列表查询用 JOIN,详情按需用延迟加载,大数据量避免结果集膨胀,永远记得配置主对象的
<id>标签。真正的专家不仅知道怎么用标签,更知道在什么场景下不用某些标签。
觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯