【大白话说Java面试题 第139题】【05_Mybatis篇】第9题:MyBatis 是否支持一对一、一对多的关联查询?如何实现?

📌 PDF :大白话说Java面试题 --- 05_Mybatis篇

第9题:MyBatis 是否支持一对一、一对多的关联查询?如何实现?

📚 回答:

  • 核心考点 : MyBatis 关联查询是 ORM 框架面试的核心考点。面试官不会满足于"用 associationcollection 标签"这种表面回答,而是深入考察 联合查询(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_id vs id_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>

    执行流程

    1. 执行 selectUserById,查询到 User 的基本信息;
    2. 发现 associationselect 属性,将 column="id" 的值(即 user.id)作为参数,调用 IdCardMapper.selectByUserId
    3. 将查询到的 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>

    执行过程

    1. 执行主查询 SELECT id, name FROM users,返回 N 个用户 → 1 次查询
    2. 对每个用户,触发 collection 的嵌套查询 SELECT * FROM orders WHERE user_id = ?N 次查询
    3. 总查询次数: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

    1. 当开启延迟加载后,MyBatis 为关联对象创建 CGLIB 代理对象(而非真实对象)设置到主对象中;
    2. 代理对象的属性初始为 null 或空集合;
    3. 当调用 user.getIdCard().getNumber() 时,CGLIB 拦截器 invoke() 方法发现 idCard 是代理对象且未加载;
    4. 拦截器执行事先保存好的嵌套查询 SQL,将真实数据查询上来;
    5. 调用 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);
    }

    注意 :延迟加载只对 associationcollection 的嵌套查询有效,联合查询(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=trueaggressiveLazyLoading=false, Jackson 等框架在反射获取属性时会触发延迟加载,可能导致意外查询。建议在 Service 层手动触发加载或使用 DTO 转换。

  • 7.5 嵌套查询的缓存问题 嵌套查询的 SQL 会被独立缓存(如果开启二级缓存),但关联对象的缓存失效策略需要单独考虑。联合查询的结果作为一个整体缓存,一致性更容易维护。

  • 7.6 大数据量 JOIN 的性能优化 联合查询虽然避免了 N+1,但 JOIN 大量数据时可能导致结果集膨胀(1 万用户 × 平均 10 订单 = 10 万行)。优化方案:

    1. 分页查询主表,再批量查询关联表;
    2. 使用 collectionselect + fetchType="lazy",配合分页只加载当前页数据。
8. 面试官追问与高分回答模板
  • 追问 1:"MyBatis 如何实现一对一和一对多关联查询?"

    低分回答 :"用 associationcollection 标签。"(没有区分实现方式)

    高分回答

    "MyBatis 支持一对一和一对多关联查询,每种关系都有两种实现方式:

    1. 联合查询(嵌套结果) :通过 SQL 的 JOIN 一次性获取所有数据,在 resultMap 中用 association(一对一)或 collection(一对多)映射。优点是只发一次 SQL,性能最好;缺点是需要处理列名冲突和结果集去重。
    2. 嵌套查询(分步查询) :先查主表,再通过 select 属性指定子查询,将主表的外键列值传递给子查询。优点是 SQL 简单、支持延迟加载;缺点是列表查询时会引发 N+1 问题 (查询 N 条主记录会触发 N 次子查询)。
      生产环境中,列表查询必须用联合查询,详情页且关联数据不总是需要时,可用嵌套查询 + 延迟加载。"
  • 追问 2:"什么是 N+1 问题?MyBatis 中怎么避免?"

    低分回答:"N+1 就是查询了很多次,用 JOIN 避免。"(没有讲清原理)

    高分回答

    "N+1 问题出现在使用嵌套查询(select 属性)查询列表时:

    • 先执行 1 次主查询,返回 N 条记录;
    • 然后对这 N 条记录,每条都触发一次关联查询(associationcollectionselect);
    • 总查询次数 = N + 1 次。
      当 N 很大时(如 1000),会发起 1001 次数据库交互,性能极差。
      避免方案
    1. 联合查询(JOIN) :改用 resultMap 的嵌套结果映射,一次 SQL 获取所有数据,生产环境列表查询的首选;
    2. 延迟加载 :设置 fetchType="lazy",让关联对象按需加载,避免列表查询时批量触发;
    3. 批量查询 :将 N 次单条查询合并为 1 次 IN 查询(需自定义实现)。
      核心原则:列表查询绝不用嵌套查询的立即加载。"
  • 追问 3:"MyBatis 的延迟加载原理是什么?"

    低分回答:"用代理对象,用到的时候才查。"(太浅)

    高分回答

    "MyBatis 延迟加载基于 CGLIB 动态代理 实现,核心流程如下:

    1. 开启延迟加载后,MyBatis 为关联对象创建 CGLIB 代理对象(而非真实对象),设置到主对象中;
    2. 代理对象的属性初始为 null 或空集合,不触发任何 SQL;
    3. 当代码调用 user.getIdCard().getNumber() 时,CGLIB 拦截器 invoke() 方法检测到 idCard 是未加载的代理对象;
    4. 拦截器执行事先保存好的嵌套查询 SQL,将真实数据查询上来,调用 user.setIdCard(realIdCard) 替换代理对象;
    5. 然后继续执行 getNumber(),返回真实数据。
      延迟加载只对 associationcollection 的嵌套查询有效,联合查询(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:"如果让你设计一个用户-订单-订单明细的三层关联查询,你会怎么做?"

    高分回答

    "三层关联查询的设计要分层考虑:

    1. 数据模型层User 包含 List<Order>Order 包含 List<OrderDetail>OrderDetail 包含 Product
    2. 查询方案选择
      • 如果是一次性展示完整数据(如导出报表),使用 多层 JOIN + resultMap 嵌套 collectionUserOrderOrderDetail),注意给每层的列加前缀避免冲突(如 user_order_detail_);
      • 如果是用户详情页(默认只展示用户基本信息,点击才加载订单),使用 嵌套查询 + 延迟加载Userorders 配置 fetchType="lazy"Orderdetails 也配置 fetchType="lazy"
    3. 性能优化
      • 列表查询绝不用嵌套查询,避免 N+1;
      • 大数据量时,三层 JOIN 的结果集会指数级膨胀,应分页查询主表(User),再用批量 IN 查询关联表;
      • 开启二级缓存缓存热点数据。
    4. 列名冲突处理 :使用 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> 标签。真正的专家不仅知道怎么用标签,更知道在什么场景下不用某些标签。


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯