从实践到思考:Spring Boot + MyBatis关系查询小分享

一、概述

本文聚焦于 Spring Boot + MyBatis 技术栈下的大型单体应用开发,系统梳理"一对一"、"一对多"、"多对多"表关系在实际业务中的查询与映射策略。内容涵盖查询方式选择、性能瓶颈分析、常见误区以及实践优化建议。

如果你尚未熟悉 MyBatis 的基础用法,建议先阅读 MyBatis 官方文档


在前后端分离架构中,一次完整的用户请求,其核心目标是从后端接口中获取结构化的业务数据。该过程通常经历以下链路:

前端请求 → Controller → Service → DAO → SQL 执行 → 数据库响应 → 结构化组装 → 接口返回

在这条链路中,性能瓶颈往往集中在 I/O 密集型操作,主要包括:

  • 数据库访问频次过高(频繁执行 SQL);
  • 网络往返成本较大(HTTP/SQL 多次交互);

因此在实际开发中,我们应优先关注两个关键方向:

  1. 减少查询次数:避免"循环查询"(即 N+1 查询问题);
  2. 提高单次查询效率:优化 SQL 本身的结构与索引使用;

二、一对一

2.1、表结构

以"学生"和"学生证"的常见业务场景为例:每个学生对应唯一一张学生证,构成典型的一对一关系。

表结构如下:

sql 复制代码
-- 学生
create table student (
    studentid int primary key auto_increment comment '学生ID,主键,自增',
    name varchar(100) not null comment '学生姓名',
    age int comment '学生年龄'
) comment='学生表';
​
-- 学生证
create table studentcard (
    cardid int primary key auto_increment comment '学生证ID,主键,自增',
    studentid int unique comment '关联学生ID,唯一,表示一对一关系',
    issuedate date comment '学生证发放日期'
) comment='学生证表,一对一关联学生';

2.2、DO

通过表结构我们来构造DO,如下:

vbnet 复制代码
// StudentDO.java - 实体类,仅映射 student 表
public class Student {
    private Integer studentid;
    private String name;
    private Integer age;
}
​
// StudentCardDO.java - 实体类,仅映射 studentcard 表
public class StudentCard {
    private Integer cardid;
    private Integer studentid;
    private Date issuedate;
}

2.3、解决方案

基于上述一对一表结构,常见的查询实现方式主要包括:

  • MyBatis 嵌套子查询(association + select);
  • 循环查询(for + 单查);
  • 分开查询 + 后端聚合;
  • 联合查询 + association 结构化映射;

Mybatis嵌套子查询循环查询主表记录有些相似,通常是指后端代码层面的多次查询操作,区别更多在于调用方式和粒度

由于很多情况下,在实际过程中,我发现很多新同学会采用这种方式,所以在本文中这一将他们拆分开说明

建议先跳转至 2.4 小节查看实际业务场景示例,再结合实际需求回来看下面几种方案,会更容易理解。

2.3.1、mybatis嵌套子查询

mybatis 根据主查询结果中某一列的值(如主键 ID),自动调用另一个子查询语句,将子查询结果注入到主对象的字段中。每一行主数据会触发一次子查询。

优点:

  • 映射方式直观,MyBatis 自动封装关联字段;
  • 配置简洁,适用于数据量小、结构稳定的场景。

缺点:

  • 每条主表记录都会触发一次子查询,容易形成 N+1 问题;
  • 不适用于大批量分页查询或高并发场景;

代码演示:

ini 复制代码
<resultMap id="StudentVOMap" type="StudentVO">
    <id property="studentid" column="studentid"/>
    <result property="name" column="name"/>
    <result property="age" column="age"/>
    <association property="studentCard" javaType="StudentCard" column="studentid"
                 select="selectStudentCardByStudentId"/>
</resultMap>
​
<select id="selectStudentCardByStudentId" resultType="StudentCard">
    SELECT cardid, studentid, issuedate FROM studentcard WHERE studentid = #{studentid}
</select>

2.3.2、循环查询主表记录

"循环查询"是指针对每一条主表记录,逐条查询其对应的从表数据 。这种方式既可以发生在后端 Java 层,也可以发生在前端 JS 层,核心问题是都存在 多次查询触发(N 次) 的性能隐患。

  • 后端循环查询: 后端先查询主表数据,再通过 for 循环对每条记录触发子表 SQL 查询,然后组装结构;
  • 前端循环查询: 前端先调用主表接口,再对每条记录触发一个子表 API 请求(HTTP 请求),通常用于异步懒加载。

优点

  • 灵活、直观,便于插入业务逻辑(如权限控制、数据脱敏等);
  • 控制粒度细,便于按需加载;
  • 实现简单,适用于小数据集、低并发场景。

缺点

  • 后端:存在 N+1 SQL 查询问题,效率差,不适合分页或大数据量
  • 前端:请求次数多,接口压力大;网络延迟影响体验;逻辑复杂

代码演示:

后端:

ini 复制代码
List<Student> students = studentMapper.selectAll();
for (Student s : students) {
    List<String> phones = phoneMapper.selectByStudentId(s.getStudentid());
    s.setPhoneNumbers(phones); // 手动封装到 VO 中
}

前端:

ini 复制代码
const students = await api.getStudents();
​
for (const student of students) {
    student.phoneNumbers = await api.getPhonesByStudentId(student.studentid); // 逐条触发接口
}

2.3.3、分开查询 + 聚合数据

"分开查询"是指主表和子表数据分别执行一次批量查询,再由前端或后端完成数据组装。

分为两种实现方式:

  1. 后端聚合:后端一次性查询主表 + 子表所有数据,按主键进行聚合,返回结构化结果给前端。
  2. 前端聚合:前端调用两个接口,分别请求主表和子表数据,在页面中进行数据合并(可用于异步加载)。

注意:

  • 在分开查询的实现中,分页和过滤条件必须全部放在第一步的主表查询里完成,保证分页的准确性和数据的完整性。
  • 第二步关联查询只是基于第一步结果进行补充数据,不再参与分页或过滤。
  • 若把分页和过滤放在第二步,会导致分页不准确、数据重复或遗漏。
  • 两步查询避免了 JOIN 导致的重复行,提升查询性能和分页稳定性。
  • 聚合数据时应保证分组完整,避免因分组逻辑错误引发数据错乱。
  • 后端或前端聚合均可,根据项目复杂度和业务需求灵活选择。

优点

  • 一次性查询主从表,无 N+1 问题,性能优良。
  • 支持分页查询,避免联合查询带来的分页错乱。
  • 支持权限控制、字段脱敏、动态组装等复杂业务处理。

缺点

  • 后端实现稍复杂,需要显式聚合子表数据。
  • 前端聚合方式依赖客户端逻辑,开发复杂度提升。

代码演示:

后端:

less 复制代码
// 1. 分页查询学生主表数据(假设已带上分页条件)
List<Student> students = studentMapper.selectPage(...);
​
// 2. 提取所有学生ID,用于批量查询电话号码(避免 N+1 查询)
List<PhoneNumber> phones = phoneMapper.selectByStudentIds(
    students.stream()
            .map(Student::getStudentid) // 获取每个学生的ID
            .toList()                   // 转为 ID 列表
);
​
// 3. 将电话号码按学生ID分组,并将每个学生的号码列表作为 value 存入 Map
Map<Integer, List<String>> phoneMap = phones.stream()
    .collect(Collectors.groupingBy(
        PhoneNumber::getStudentid,     // 按 studentid 分组
        Collectors.mapping(            // 提取 phonenumber 字段
            PhoneNumber::getPhonenumber,
            Collectors.toList()        // 聚合为 List<String>
        )
    ));
​
// 4. 将学生数据与电话列表组装成 VO(视图对象),准备返回前端
List<StudentVO> result = students.stream().map(s -> {
    StudentVO vo = new StudentVO();
    BeanUtils.copyProperties(s, vo); // 复制 student 字段到 VO
    vo.setPhoneNumbers(              // 设置电话号码字段
        phoneMap.getOrDefault(
            s.getStudentid(),        // 根据 studentId 取出电话列表
            List.of()                // 若无记录则返回空列表
        )
    );
    return vo;
}).collect(Collectors.toList()); // 最终返回 List<StudentVO>

前端:

ini 复制代码
// 前端示例(如 Vue/React)
const studentList = await api.getStudents(); // 获取主表数据
const phoneMap = await api.getPhonesByStudentIds(studentList.map(s => s.id)); // 获取子表数据
​
// 组装数据
const result = studentList.map(s => ({
  ...s,
  phoneNumbers: phoneMap[s.id] || [],
}));

2.3.3、联合查询 + association

主表与从表通过 JOIN 语句一次性查询,MyBatis 根据字段映射关系,将"扁平化"的结果集自动转换为嵌套对象结构(如一对一使用 <association>,一对多使用 <collection>)。

优点

  • 查询效率高,避免 N+1 查询;
  • 支持一条 SQL 查询所有数据;
  • 配合 resultMap 自动聚合,结构清晰;
  • 代码简洁,可直接返回前端使用。

缺点

  • 一对多场景会造成数据重复膨胀,分页结果错乱(如一个学生多个电话,则分页时一名学生会占多行);
  • 在多对多关系中,映射和数据处理会变得更复杂;
  • 对于动态子表数据(如权限脱敏、按角色展示等),不如 Java 分组组装灵活;
  • 不支持懒加载:一旦主表查询触发,所有联表字段都会被加载。

代码演示

ini 复制代码
<resultMap id="StudentVO" type="StudentVO">
  <id property="studentid" column="studentid" />
  <result property="name" column="name" />
  <result property="age" column="age" />
  <association property="studentCard" javaType="StudentCardVO">
    <id property="cardid" column="cardid" />
    <result property="issuedate" column="issuedate" />
  </association>
</resultMap>
​
<select id="selectStudentListWithCard" resultMap="StudentVO">
  select s.studentid, s.name, s.age, sc.cardid, sc.issuedate
  from student s
  left join studentcard sc on s.studentid = sc.studentid
</select>

2.4、场景

在了解了上面几种常用的一对一查询方式后,我们现在看一个在实际开发过程中常常会使用到的场景:

查询学生列表,展示学生与学生证信息:某业务页面需要展示学生列表,要求每条记录展示如下信息:

姓名 年龄 学生证编号 发放日期
张三 20 10001 2023-09-01
李四 21 null null

思考:

对于这种常见的方式,其实上面四种查询方式都可以解决,但是也存在很大差异,具体如下:

Mybatis嵌套查询 / 循环查询主表记录:对主表查询返回的每一条学生记录,分别触发一次子表(学生证)查询;会产生 N + 1 次数据库访问(1 次主查询 + N 次子查询)

分开查询 + 聚合数据:查询主表和子表各一次,然后再按学生ID聚合数据,仅 2 次数据库访问,避免 N + 1 问题;

联合查询 + association:使用 LEFT JOIN 一次性查询两张表的数据,结构清晰、SQL 一次完成所有查询;

经过上面的思考我们心中已经有了答案,真正在使用时,我们会采用分开查询联合查询 的方式。

2.5、总结

处理一对一关联关系时,通常有两种主要方案:

  • 一种是通过 MyBatis 联合查询配合 association 标签,利用单条 SQL 直接返回完整的关联对象结构;
  • 一种是分开查询主表和关联表数据,再在后端(或前端)进行批量组装。

前者优势在于查询效率高,避免多次数据库访问,且 MyBatis 自动完成对象映射,代码简洁,适合频繁且完整展示关联数据的场景;但当关联数据量大或关联字段不是每次都需要时,联合查询可能导致不必要的数据加载和性能开销。

后者通过职责分离,灵活实现"懒加载"或按需加载,便于业务模块解耦和缓存策略的应用,适合关联字段偶尔展示或在详情页加载的业务需求;但需要开发者额外实现批量查询与数据组装逻辑,且如果没有批量优化,容易引发 N+1 查询问题。

综合来看,最佳实践是优先采用MyBatis 联合查询映射,保证性能和代码简洁;对于业务复杂或关联数据不固定展示的场景,则可选用分开查询加批量聚合的方案,同时必须确保批量查询以避免性能瓶颈,最终应根据具体业务访问频率、数据量和维护成本做合理权衡。

三、一对多

3.1、表结构

再聊了一对一的解决方案后我们再来看一下一对多,

我们这边通过生活中,我们常见的学生手机号的场景来进行讲解:

每一个学生可以有多个手机号码,一个手机号码只属于一个学生,他们之间是一对多关系

表结构如下:

sql 复制代码
-- 学生
create table student (
    studentid int primary key auto_increment comment '学生ID,主键,自增',
    name varchar(100) not null comment '学生姓名',
    age int comment '学生年龄'
) comment='学生表';
​
-- 电话号码
create table phonenumber (
    phoneid int primary key auto_increment comment '电话号码ID,主键,自增',
    studentid int comment '关联学生ID,表示一个学生多个电话号码',
    phonenumber varchar(20) comment '电话号码'
) comment='电话号码表,一对多关联学生';

3.2、DO

通过表结构我们来构造DO,如下:

vbnet 复制代码
// StudentDO.java
public class StudentDO {
    private Integer studentid;
    private String name;
    private Integer age;
}
​
// PhoneNumberDO.java
public class PhoneNumberDO {
    private Integer phoneid;
    private Integer studentid;
    private String phonenumber;
}

3.3、解决方案

在了解了基本的表结构后,我们先来看几种表关系为一对多的查询方式:

  • mybais嵌套查询
  • 联合查询 + collection
  • 循环查询主表记录
  • 分开查询 + 聚合数据
  • SQL 层聚合 + GROUP_CONCAT
  • 冗余字段 + 子查询

建议先跳转至 3.4 小节查看实际业务场景一,再结合实际需求回来看下面几种方案,会更容易理解。

3.3.1、mybais嵌套查询

嵌套查询是指在 MyBatis 中先查询主表(例如学生表),然后针对每条主表记录,再执行一个子查询来获取关联数据(例如电话号码)。MyBatis 通过配置 <select> 标签嵌套调用,或者使用 collectionassociation 标签自动映射实现。

优点:

  • 代码结构清晰,逻辑简单直观,易于理解和维护。
  • 灵活,方便对每条数据单独处理,例如权限校验和数据脱敏。
  • 开发快速,适合数据量较小或接口简单的场景。

缺点:

  • 性能瓶颈明显,主表返回 N 条数据就会触发 N 次子查询,数据库和网络开销大。
  • 不适合大数据量或高并发场景,容易引起性能问题。
  • 维护时,复杂查询场景会增加调试难度。

代码演示:

xml 复制代码
<!-- 学生实体结果映射 -->
<resultMap id="StudentResultMap" type="Student">
  <id property="studentid" column="studentid"/>
  <result property="name" column="name"/>
  <result property="age" column="age"/>
  <!-- 嵌套查询获取电话号码列表 -->
  <collection property="phoneNumbers" ofType="PhoneNumber">
      <result property="phonenumber" column="phonenumber" />
  </collection>
</resultMap>
​
<!-- 查询学生列表 -->
<select id="selectAllStudents" resultMap="StudentResultMap">
  select studentid, name, age from student
</select>
​
<!-- 根据学生ID查询电话号码 -->
<select id="selectPhonesByStudentId" resultType="String" parameterType="int">
  select phonenumber from phone where studentid = #{studentid}
</select>

3.3.2、联合查询 + collection

这种方式不使用 MyBatis <collection> 标签的 select 子查询属性,而是通过一条 SQL 联合查询多表数据,将结果通过 <collection> 标签映射成集合属性。这样可以避免传统嵌套查询中的 N+1 查询问题,提升查询性能。

优点:

  • 只发起一条 SQL,避免大量子查询带来的性能问题。
  • 查询效率高,减少数据库和网络开销。

缺点:

  • 分页不一致性(pagination inconsistency) :由于联合查询返回的结果包含主表和多条从表记录,数据库的 LIMIT 分页限制的是行数而非主表记录数。当主表一条记录关联多条从表记录时,分页结果会被切分,导致主表记录被拆分到多个分页中,分页结果不准确。
  • 复杂 SQL 和复杂结果映射,维护难度较高。
  • 可能导致前端接收数据时,需要额外处理重复或拆分的主表信息。

代码演示:

ini 复制代码
<resultMap id="StudentResultMapWithPhones" type="Student">
  <id property="studentid" column="studentid"/>
  <result property="name" column="name"/>
  <result property="age" column="age"/>
  <collection property="phoneNumbers" ofType="String">
    <result column="phonenumber" />
  </collection>
</resultMap>
​
<select id="selectStudentsWithPhones" resultMap="StudentResultMapWithPhones">
  select s.studentid, s.name, s.age, p.phonenumber
  from student s
  left join phone p on s.studentid = p.studentid
  order by s.studentid
  limit #{offset}, #{limit}
</select>

分页不一致性说明:

  • 该 SQL 的 LIMIT 限制的是联合查询结果中的"行"数,而不是主表"学生"记录数。
  • 当一个学生关联多条电话号码时,这些多条记录会被拆分到不同分页中。
  • 导致某些分页中缺少完整学生信息,或者同一学生的信息出现在多个分页。
  • 这种不一致性会给前端数据展示和分页逻辑带来困扰,通常需要额外处理或采用其他分页策略。

3.3.3、循环查询主表记录

和嵌套查询类似,先查出主表列表,然后遍历主表列表,针对每条主表数据执行单独查询来获取对应的子表数据,最后手动把数据拼接。

分为两种实现方式:

  • 后端循环聚合:在后端业务逻辑中用循环逐条调用查询子表接口,自己组装数据。
  • 前端循环聚合:前端代码循环调用子表接口,自己拼装数据结构。

优点:

  • 灵活度高,方便对每条数据做细粒度控制(如权限、脱敏)。
  • 实现简单,直观易懂。

缺点:

  • 性能瓶颈明显,容易出现 N+1 查询问题,查询次数多,数据库负担重。
  • 网络IO增加,接口响应变慢。
  • 不适合大数据量和分页场景。

代码演示:

后端:

scss 复制代码
// 查询所有学生,返回学生列表
List<Student> students = studentMapper.selectAll();
​
// 遍历每个学生,针对每个学生单独查询对应的电话号码列表
for (Student student : students) {
    // 根据学生ID查询该学生所有的电话号码
    List<String> phones = phoneMapper.selectPhonesByStudentId(student.getStudentid());
    // 将查询到的电话号码列表设置到学生对象中
    student.setPhoneNumbers(phones);
}

前端:

csharp 复制代码
// 通过接口获取学生列表
const students = await api.getStudents();
​
// 遍历学生列表,针对每个学生发起请求获取对应的电话号码
for (const student of students) {
  // 调用接口,查询该学生所有的电话号码
  student.phoneNumbers = await api.getPhonesByStudentId(student.studentid);
}

3.3.4、分开查询 + 聚合数据

分开查询的核心思想是将主表数据和关联表数据分开查询,避免多次循环查询带来的性能问题。具体来说,后端或前端先批量查询主表的所有目标数据,然后通过一次批量查询,拿到所有相关的从表数据(比如所有学生对应的电话号码),最后根据主表的主键将从表数据进行分组,再将分组结果和主表数据合并组装成业务对

分为两种实现方式:

  • 后端聚合:后端先分别批量查询主表和关联表数据,然后在 Java 代码中通过主表 ID 进行分组和映射,最终组装成完整的业务对象返回给前端。
  • 前端聚合:前端先请求主表数据列表,随后根据主表的 ID 列表调用批量接口获取关联数据,最后在前端进行数据组装。

优点:

  • 性能好,避免了 N+1 查询问题,数据库交互次数少。
  • 与分页逻辑兼容,便于控制查询范围和数据量。
  • 灵活,可方便地对业务逻辑做扩展(如权限过滤、数据脱敏等)。

缺点:

  • 代码复杂度增加,Java 或前端层需要写额外的组装逻辑。
  • 数据一致性需要注意,组装环节可能存在数据同步风险。

注意:

  • 在分开查询的实现中,分页和过滤条件必须全部放在第一步的主表查询里完成,保证分页的准确性和数据的完整性。
  • 第二步关联查询只是基于第一步结果进行补充数据,不再参与分页或过滤。
  • 若把分页和过滤放在第二步,会导致分页不准确、数据重复或遗漏。
  • 两步查询避免了 JOIN 导致的重复行,提升查询性能和分页稳定性。
  • 聚合数据时应保证分组完整,避免因分组逻辑错误引发数据错乱。
  • 后端或前端聚合均可,根据项目复杂度和业务需求灵活选择。

代码演示:

后端:

ini 复制代码
// 查询学生列表(分页或筛选)
List<Student> students = studentMapper.selectList(...);
​
// 根据学生ID批量查询电话号码
List<PhoneNumber> phones = phoneMapper.selectByStudentIds(
    students.stream()
            .map(Student::getStudentid)
            .collect(Collectors.toList())
);
​
// 将电话号码按学生ID分组
Map<Integer, List<String>> phoneMap = phones.stream()
    .collect(Collectors.groupingBy(
        PhoneNumber::getStudentid,
        Collectors.mapping(PhoneNumber::getPhonenumber, Collectors.toList())
    ));
​
// 组装最终返回结果
List<StudentVO> result = students.stream().map(s -> {
    StudentVO vo = new StudentVO();
    BeanUtils.copyProperties(s, vo);
    vo.setPhoneNumbers(phoneMap.getOrDefault(s.getStudentid(), Collections.emptyList()));
    return vo;
}).collect(Collectors.toList());

前端:

ini 复制代码
// 先获取学生列表
const studentList = await api.getStudents();
​
// 批量获取电话号码数据,返回格式假设为 { [studentId]: string[] }
const phoneMap = await api.getPhonesByStudentIds(studentList.map(s => s.id));
​
// 在前端组装数据
const result = studentList.map(s => ({
  ...s,
  phoneNumbers: phoneMap[s.id] || [],
}));

3.4.5、SQL 层聚合 + GROUP_CONCAT

通过 SQL 聚合函数(如 GROUP_CONCAT)在数据库层将一对多的从表数据(如电话号码)进行聚合,最终将主表记录与聚合后的结果组成一条记录返回,实现"扁平化结构"。这种方式只查询一次、只返回一行主表记录对应的数据,天然支持分页。

优点:

  • 查询性能高,SQL 层聚合避免 Java 层组装。
  • 分页友好,主表一条记录返回一行,不存在分页错乱问题。
  • 实现简单,无需写复杂的嵌套或映射逻辑。

缺点:

  • 聚合字段为纯字符串,不适合进一步结构化处理;
  • 不适用于子表字段的精准检索、筛选、排序等查询操作,模糊匹配容易误命中且无法使用索引;
  • 一旦聚合内容过长,可能被 MySQL 默认的 group_concat_max_len 限制截断;
  • 聚合字段查询属于"文本匹配",不具备结构语义,不能作为业务查询的核心依据
vbnet 复制代码
<select id="selectStudentListWithPhones" resultType="StudentVO">
  SELECT
    s.studentid,
    s.name,
    s.age,
    GROUP_CONCAT(p.phonenumber ORDER BY p.phoneid SEPARATOR ',') AS phoneNumbers
  FROM student s
  LEFT JOIN phonenumber p ON s.studentid = p.studentid
  GROUP BY s.studentid
  ORDER BY s.studentid
  LIMIT #{offset}, #{pageSize}
</select>

该方案特别适用于只需展示用途 ,比如页面展示学生所有电话号码(逗号拼接形式),而不涉及子表字段的筛选、排序或结构化操作

3.3.5、冗余字段 + 子查询

通过在主表(如 student)中新增一个冗余字段 phone_summary,将一对多的电话号码(通常保存在子表中)聚合为 JSON 数组冗余存储在主表字段中。每当子表数据变更时,由 Service 层同步更新该字段。查询时直接从该字段中获取,无需联表查询。

优点:

  • 显著降低联表开销,分页查询更高效;
  • 列表展示性能优秀,避免复杂映射与组装;
  • 查询逻辑简单,接口更轻量。

缺点:

  • 存在数据冗余,数据一致性依赖于业务代码保障,维护复杂度较高;
  • JSON 字段默认不支持传统 B-tree 索引,不能直接利用常规索引进行高效精确检索;
  • JSON 字段内容搜索能力有限,不适合结构化检索、排序或分组操作,通常只能使用低效的 LIKE 查询;
  • 冗余字段更新需手动控制,且在多线程或分布式环境中需保证同步一致性。

代码演示:

表结构调整:

sql 复制代码
ALTER TABLE student ADD COLUMN phone_summary JSON COMMENT '冗余字段,存储该学生所有电话号码的JSON数组';

同步更新流程(由 Service 层控制):

  1. 电话号码增删改后触发更新逻辑;
  2. 查询该学生所有电话号码;
  3. 序列化为 JSON 字符串;
  4. 更新 student.phone_summary 字段。
sql 复制代码
SELECT s.*
FROM student s
ORDER BY s.studentid 
LIMIT #{offset}, #{pageSize};

由于电话号码数据以 JSON 字段形式冗余存储,不能直接利用常规索引进行高效检索,因此检索电话号码时需结合子查询过滤。

常用做法是使用 EXISTS 子查询:

sql 复制代码
SELECT s.*
FROM student s
WHERE EXISTS (
    SELECT 1 FROM phonenumber p
    WHERE p.studentid = s.studentid 
      AND p.phonenumber = #{phoneNumber}
);

可能有人会质疑,这种方式依然触发子查询,效率会不会很低?

实际上,EXISTS 子查询在 MySQL 中有多种优化机制:

  • 当子查询条件包含具体手机号时,查询范围显著缩小,能快速定位匹配记录;
  • MySQL 会利用索引(如 phonenumber 字段及联合索引 studentid+phonenumber)进行快速过滤,避免全表扫描;
  • EXISTS 在遇到第一条满足条件记录时即停止扫描,避免冗余扫描;
  • 结合合理的索引设计和子查询条件,EXISTS 的性能通常优于普通的 IN 子查询或联表查询。

注意: 索引设计合理是保证性能的关键,否则即使是 EXISTS 查询也可能性能较差。

3.4、场景

场景一

学生列表展示所有电话号码:业务页面展示学生列表,同时显示每个学生所有电话号码,通常以逗号分隔的方式聚合展示。

姓名 年龄 电话号码
张三 20 13800000000,13900000000
李四 21 13700000000

思考:

该场景主要需求是展示,分页友好,对电话号码的搜索和过滤需求较弱,对于之前的方案在这种场景:

  • mybais嵌套查询循环查询主表记录会带来大量查询,性能不佳,不推荐用于此场景的分页展示。
  • 联合查询 + collection虽然能一次性取出所有数据,但分页不一致性会导致展示异常,不易维护。
  • 冗余字段 + 子查询通过 JSON 聚合存储,读性能最好,且查询逻辑简单,但维护成本较高,需要保证同步机制可靠。
  • SQL 层的聚合 + GROUP_CONCAT:可直接通过 SQL 聚合电话字段,查询简单且性能好,但不支持基于电话号码的检索。
  • 冗余字段 + 子查询方案通过在冗余字段和关联表上构建合理的索引,能够大幅提升检索效率,避免全表扫描,满足大部分基于电话号码的精准或模糊查询需求,同时兼顾读性能和数据维护的平衡。

经过上面的思考我们心中已经有了答案,真正在使用时:

  • 不需要电话号码检索时,优先采用 SQL 层聚合 + GROUP_CONCAT 方案,实现简洁高效,分页友好。
  • 若需支持电话号码检索,则推荐 冗余字段 + 子查询 方案,通过冗余结构或中间表支持索引和查询。

场景二

电话号码搜索和过滤:用户输入电话号码关键字,查询匹配的学生及对应电话号码,结果可能只展示匹配的电话号码,且同一学生可能出现多条记录。

姓名 年龄 电话号码
张三 20 13800000000
张三 20 13900000000
李四 21 13700000000

思考:

该场景的查询入口是电话号码,核心需求是支持电话号码的精准或模糊搜索,结果需要准确返回匹配的电话号码及对应学生信息:

  • 以学生为主表的方案存在根本限制,因为主表分页和过滤是基于学生,不利于电话号码的精准检索;
  • 冗余字段 + JSON存储不支持结构化索引,难以高效搜索;
  • SQL聚合(GROUP_CONCAT)不支持按电话号码过滤,且聚合字段无法作为筛选条件;
  • 联合查询虽能实现过滤,但因为以学生为主表,分页复杂,维护难度大;
  • 分开查询需两次查询且需要业务层复杂处理,性能尚可但不够简洁;

更优方案是将电话号码作为主表进行分页和过滤查询,再通过电话号码关联学生表获取对应学生信息,如下示例:

vbnet 复制代码
SELECT s.name, s.age, p.phonenumber
FROM phone p
JOIN student s ON p.studentid = s.studentid
WHERE p.phonenumber LIKE '%138%'
ORDER BY p.phonenumber
LIMIT 10 OFFSET 0;

该方案充分利用电话号码表的索引,支持高效分页和模糊搜索,同时通过 JOIN 获取学生信息,避免复杂的分页不一致问题。

此外,业务层可直接根据电话号码分页,结果清晰且数据结构简单,易于维护和扩展。

3.5、总结

处理一对多关联关系时,首先要明确业务场景中的数据视角:

  • 主表视角:即以主表为中心,一条主表记录对应多条关联表记录,比如"学生对应多个电话号码"。
  • 关联表视角:以关联表为中心,多条关联表记录对应一条主表记录,比如以电话号码为主,查对应学生信息。

根据视角不同,查询方案和设计思路也会有所差异。

确定主表视角后,选择查询方案时通常考虑以下两种主流做法:

  • 分开查询 + 聚合数据 先批量查询主表数据,再批量查询关联表数据,通过主键在业务层(后端或前端)进行分组聚合,最终组装成完整的业务对象。 优点是性能较好,避免了 N+1 查询问题,且分页友好,缺点是代码复杂度稍高,需要写额外的组装逻辑。
  • 冗余字段 + 子查询 通过在主表增加冗余字段(如 JSON 格式存储关联表数据),实现快速读取,查询效率高,尤其适合展示场景。 缺点是维护复杂度较大,数据一致性依赖业务层的同步机制,且对检索和过滤支持有限。

其他方案(如 MyBatis 嵌套查询、联合查询 + collection、循环查询主表记录、SQL层聚合等)虽然也有应用场景,但普遍存在性能瓶颈、分页复杂度或维护成本较高的问题,因此在实际生产环境中不建议作为首选方案。


最终,方案选择应结合具体业务需求,包括:

  • 是否需要支持关联表字段的过滤、排序
  • 数据量大小和并发情况
  • 分页准确性要求
  • 代码维护成本
  • 数据一致性保障能力

这样才能在性能、开发效率和业务需求之间取得最佳平衡。

四、多对多

4.1、表结构

多对多关系是指两个实体之间存在多个相互关联的记录。例如,一个学生可以选修多门课程,一门课程也可以被多个学生选修。为了表示多对多关系,通常采用中间表(关联表)来存储关联关系。

这里以学生课程为例:

sql 复制代码
-- 学生
create table student (
    studentid int primary key auto_increment comment '学生ID,主键,自增',
    name varchar(100) not null comment '学生姓名',
    age int comment '学生年龄'
) comment='学生表';
​
-- 课程
create table course (
    courseid int primary key auto_increment comment '课程ID,主键,自增',
    coursename varchar(100) not null comment '课程名称'
) comment='课程表';
​
-- 学生选课关联表
create table studentcourse (
    studentid int comment '学生ID',
    courseid int comment '课程ID',
    enrollmentdate date comment '选课日期',
    primary key (studentid, courseid)
) comment='学生选课关联表,多对多关系';

4.2、DO设计

基于表结构,我们构造实体对象:

arduino 复制代码
// StudentDO.java
public class Student {
    private Integer studentid;
    private String name;
    private Integer age;
    private List<Course> courses; // 多对多关联课程列表
}
​
// CourseDO.java
public class Course {
    private Integer courseid;
    private String coursename;
}

4.3、解决方案

多对多查询相较于一对一、一对多复杂度更高,主要因为存在中间表关联。常见的查询策略包括:

  • 分步查询 + 后端聚合
  • 联合查询 + 复杂映射
  • 嵌套查询(多层嵌套)
  • 业务侧缓存或预计算

建议先跳转至 4.5 小节查看实际业务场景,再结合实际需求回来看下面几种方案,会更容易理解。

4.3.1、分步查询 + 后端聚合

这是最推荐的方式,尤其是在数据量较大且业务复杂的场景。

实现思路:

  1. 查询主表(学生)分页数据;
  2. 根据学生ID批量查询关联的中间表(student_course);
  3. 根据课程ID批量查询课程表数据;
  4. 在后端根据学生ID将课程列表聚合组装到对应学生对象。

优点:

  • 避免复杂多表联合查询导致的数据膨胀和分页错乱问题;
  • 灵活性强,便于业务层控制权限和数据脱敏;
  • 易于缓存中间表或关联数据,提高查询效率。

缺点:

  • 代码复杂度较高,需要手动聚合数据;
  • 多条 SQL 查询,需保证批量查询以避免 N+1 问题。

注意:

  • 在分开查询的实现中,分页和过滤条件必须全部放在第一步的主表查询里完成,保证分页的准确性和数据的完整性。
  • 第二步关联查询只是基于第一步结果进行补充数据,不再参与分页或过滤。
  • 若把分页和过滤放在第二步,会导致分页不准确、数据重复或遗漏。
  • 两步查询避免了 JOIN 导致的重复行,提升查询性能和分页稳定性。
  • 聚合数据时应保证分组完整,避免因分组逻辑错误引发数据错乱。
  • 后端或前端聚合均可,根据项目复杂度和业务需求灵活选择。

示例代码片段(Java后端聚合示例):

ini 复制代码
// 1. 分页查询学生
List<Student> students = studentMapper.selectPage(...);
​
// 2. 批量查询关联表中的课程ID
List<Integer> studentIds = students.stream().map(Student::getStudentid).toList();
List<StudentCourse> relations = studentCourseMapper.selectByStudentIds(studentIds);
​
// 3. 批量查询课程表
List<Integer> courseIds = relations.stream().map(StudentCourse::getCourseid).distinct().toList();
List<Course> courses = courseMapper.selectByIds(courseIds);
​
// 4. 按学生ID分组课程
Map<Integer, List<Integer>> studentCourseMap = relations.stream()
    .collect(Collectors.groupingBy(
        StudentCourse::getStudentid,
        Collectors.mapping(StudentCourse::getCourseid, Collectors.toList())
    ));
​
Map<Integer, Course> courseMap = courses.stream()
    .collect(Collectors.toMap(Course::getCourseid, Function.identity()));
​
// 5. 组装结果
students.forEach(student -> {
    List<Integer> cIds = studentCourseMap.getOrDefault(student.getStudentid(), Collections.emptyList());
    List<Course> courseList = cIds.stream()
        .map(courseMap::get)
        .filter(Objects::nonNull)
        .toList();
    student.setCourses(courseList);
});

4.3.2、联合查询 + 复杂映射

通过 SQL JOIN 多表联合查询学生、关联表和课程表,获取"扁平化"结果集,再利用 MyBatis 的<collection><association>标签映射成嵌套对象。

示例SQL:

csharp 复制代码
select s.studentid, s.name, s.age,
       c.courseid, c.coursename
from student s
left join student_course sc on s.studentid = sc.studentid
left join course c on sc.courseid = c.courseid
order by s.studentid
limit #{offset}, #{limit}

优点:

  • 一条 SQL 拿全数据,避免多次查询;
  • 简化调用层代码。

缺点:

  • 数据膨胀严重:多条关联课程会重复学生数据,导致结果集行数膨胀;
  • 分页难题:分页会针对结果行,而非学生数,分页不准确,导致数据重复或遗漏;
  • 映射配置复杂,维护成本较高。

4.3.3、嵌套子查询

MyBatis 允许在<collection>中配置select属性,针对每条主表记录执行子查询。

优点:

  • 实现简单,代码逻辑清晰;
  • 适合小数据量或开发初期快速实现。

缺点:

  • 存在严重的 N+1 查询问题,性能不可接受;
  • 不适用于生产环境大数据量。

4.4、实际场景分析

例如:需要分页展示学生列表及其选修的课程信息。

学生姓名 年龄 课程列表
张三 20 语文,数学,英语
李四 21 数学,物理
  • 联合查询简单但分页易错;
  • 嵌套查询性能差,N+1问题明显;
  • 分步查询 + 后端聚合是大多数项目的首选,平衡性能与开发成本。

4.5、关于多对多表结构的思考

4.5.1、为什么多对多关系必须引入第三张表?

在数据库设计中,处理多对多(Many-to-Many)关系时,通常需要引入一个中间表(关联表)来管理两个实体之间的关联。这个设计背后的原因包括:

如果不使用中间表,而试图在"学生"表直接存储课程信息,或者在"课程"表直接存储学生信息,会面临如下问题:

  • 数据冗余和重复:由于一个学生可能选修多门课程,直接存储课程信息就需要在学生表中重复保存多条课程数据,反之亦然,导致数据冗余,维护困难。
  • 扩展性差:关系数量不固定,难以用固定字段表示多条关联关系,也无法动态增加字段,导致设计不灵活。
  • 数据一致性和完整性难保障:多处冗余数据更新时容易产生不一致,数据完整性难以控制。
  • 查询复杂和性能问题:采用逗号拼接、数组字段或非标准格式存储关联,导致SQL查询困难且难以优化,影响性能。

引入中间表,能很好解决以上问题:

  • 清晰的关联关系建模:中间表一行代表一条学生-课程关联,结构清晰;
  • 支持额外属性:可以在中间表上存储选课时间、成绩等业务字段;
  • 方便维护和扩展:插入、删除关联关系直接操作中间表,不影响主表结构;
  • 优化查询和分页:可以根据业务需求灵活选择主表视角和关联查询策略。

4.5.2、多对多关系的本质是什么?

从数据结构角度

多对多关系由三张表组成:

  • 主表A(例如学生)
  • 主表B(例如课程)
  • 中间关联表(例如 studentcourse)

中间表通常包含两张主表的主键和一些业务字段(选课时间等)。

从业务展示角度

任何页面的数据展示本质上都是二维表

| 主表字段1 | 主表字段2 | 关联字段1 | 关联字段2 | ... |

这个二维表中,一定会有一个 "主视角" ,即以哪个表为主来展示数据。

  • 学生列表页:以学生为主,展示学生和其选的课程。
  • 课程详情页:以课程为主,展示课程和报名的学生。
  • 报名列表页:以报名记录(中间表)为主,展示报名详情。

换句话说,多对多场景最终会降维为一对多的视角处理。

4.5.3、为何多对多本质是"一对多"的变形?

多对多其实是两个一对多的结合

  • 学生 → 多个报名(studentcourse) = 一对多
  • 课程 → 多个报名(studentcourse) = 一对多

中间表既是"学生的一对多",又是"课程的一对多"。

实际开发要处理的是哪个"一对多"?

  • 以学生为主:学生和其多个报名记录 → 一对多
  • 以课程为主:课程和其多个报名记录 → 一对多

中间表同时关联两张主表,但业务处理时一定会选定一个主表视角,来做分页和查询。

相关推荐
手握风云-11 分钟前
JavaEE初阶第一期:计算机是如何 “思考” 的(上)
java·java-ee
普通的冒险者28 分钟前
微博项目(总体搭建)
java·开发语言
BAGAE1 小时前
Flutter 与原生技术(Objective-C/Swift,java)的关系
java·开发语言·macos·objective-c·cocoa·智慧城市·hbase
江湖有缘1 小时前
使用obsutil工具在OBS上完成基本的数据存取【玩转华为云】
android·java·华为云
bxlj_jcj1 小时前
Kafka环境搭建全攻略:从Docker到Java实战
java·docker·kafka
国科安芯1 小时前
【AS32系列MCU调试教程】性能优化:Eclipse环境下AS32芯片调试效率提升
java·性能优化·eclipse
Chase_______1 小时前
静态变量详解(static variable)
java·开发语言·jvm
厚衣服_31 小时前
第15篇:数据库中间件高可用架构设计与容灾机制实现
java·数据库·中间件
勇闯IT2 小时前
有多少小于当前数字的数字
java·数据结构·算法
小皮侠2 小时前
【算法篇】逐步理解动态规划模型6(回文串问题)
java·开发语言·算法·动态规划