Java 多表关联(一对一/一对多/多对多)理解指南

MyBatis-Plus 多表关联:没有"三种写法",只有两种组装姿势

一对一、一对多、多对多,很多人是当三段代码背的。背完就乱:为什么一对多查两次、多对多又只 join 一次?其实关系类型和查询方式是两个完全独立的维度,真正需要记住的只有两条规律。这篇把它彻底捋顺。

一、先纠正三个流传甚广的错误认知

这三个说法你大概率听过,甚至笔记里就是这么记的:

  • ❌ "一对多 = 查两次表"
  • ❌ "多对多 = join 一次"
  • ❌ "一对一 / 一对多 / 多对多是三种不同的代码写法"

全是错的。 真相是:

  1. 关系类型(1:1 / 1:N / M:N)和查询方式(分步查 / join 查)是两个正交的维度。 任何一种关系,都可以分步查,也都可以 join 查。它们之间是自由组合的,不存在绑定关系。
  2. 代码写法上只分两派:VO 里嵌套单个对象(一对一、多对一),或者 VO 里嵌套 List(一对多、多对多)。组装代码的骨架一模一样。
  3. MyBatis-Plus 不会自动查关联表(这一点和 JPA/Hibernate 完全不同,MP 没有 @OneToMany 这种东西)。所有关联数据的获取,本质都是一句话:分别查询 + 在 Service 层组装成 VO。

做过前端的话可以这样理解:数据库里的表就像你在 Redux / Pinia 里 normalize 过的 state,实体之间只存 id 引用;而页面要的嵌套 JSON,是你在 selector(或 BFF 层)里把扁平数据 join 出来的。MyBatis-Plus 的世界里,这个 selector 就是 Service 层,而且必须你亲手写。

二、表关系是怎么来的:看约束,不看索引

三种关系在数据库层面到底是怎么"长"出来的?一句话:

关系 外键在哪 关键约束
一对多 / 多对一 "多"的那张表,一条外键 外键可重复(普通索引即可)
一对一 放任一方(一般放"从属"表) 外键列加唯一约束
多对多 哪边都放不下 单开一张中间表,放两条外键

几个要点:

  • 多对一和一对多是同一种关系,只是观察方向相反。 站在客户看是"我有 N 笔订单",站在订单看是"我属于某个客户"。外键只有一条,永远在"多"的那一边。
  • 严格地说,决定关系的是"外键列是否允许重复"这个约束,而不是索引本身。索引是查询加速手段;只不过唯一约束会自动创建唯一索引,所以直观上记成"普通索引 → 一对多,唯一约束 → 一对一"也没毛病。
  • 一对一的外键放哪边都行,实务上放在"从属/扩展"那张表(比如用户档案表),并加唯一约束。

三、一对多:客户与订单(最标准的范式)

3.1 建表

CREATE TABLE customer (
    id    BIGINT PRIMARY KEY AUTO_INCREMENT,
    name  VARCHAR(50) NOT NULL,
    phone VARCHAR(20)
);

CREATE TABLE orders (                       -- 别叫 order,那是 SQL 保留字,踩坑高发区
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no    VARCHAR(32) NOT NULL,
    amount      DECIMAL(10,2) NOT NULL,
    customer_id BIGINT NOT NULL,
    INDEX idx_customer_id (customer_id)     -- 普通索引:外键可重复 → 一对多
);

站在王老板看:我有两笔订单 → 一对多;站在 DD003 看:我属于李总 → 多对一。同一条外键,两个方向。

3.2 Entity:只映射表,不装对方

@Data
@TableName("customer")
public class Customer {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private String phone;
}

@Data
@TableName("orders")          // 类名叫 Order 没关系,@TableName 指定真实表名
public class Order {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String orderNo;
    private BigDecimal amount;
    private Long customerId;  // 只存 id,不存 Customer 对象
}

Entity 只映射表结构,不装关联对象——这就是"normalize",嵌套结构是 VO 的事。

3.3 查询:客户详情页,带出名下所有订单

前端要的 JSON 长这样:

{
  "id": 1,
  "name": "王老板",
  "phone": "138****0001",
  "orders": [
    { "id": 1001, "orderNo": "DD001", "amount": 99.00 },
    { "id": 1002, "orderNo": "DD002", "amount": 199.00 }
  ]
}

VO 里嵌套 List——这是一对多在代码里唯一的标志:

@Data
public class CustomerVO {
    private Long id;
    private String name;
    private String phone;
    private List<OrderVO> orders;   // 一对多的标志:VO 里放 List
}

Service 三步走:查"一" → 查"多" → 组装:

public CustomerVO getCustomerWithOrders(Long customerId) {
    // ① 查"一":客户
    Customer customer = customerMapper.selectById(customerId);

    // ② 按外键查"多":该客户所有订单
    List<Order> orders = orderMapper.selectList(
        new LambdaQueryWrapper<Order>().eq(Order::getCustomerId, customerId));

    // ③ 组装:塞进 VO
    CustomerVO vo = new CustomerVO();
    BeanUtils.copyProperties(customer, vo);
    vo.setOrders(orders.stream().map(o -> {
        OrderVO ov = new OrderVO();
        BeanUtils.copyProperties(o, ov);
        return ov;
    }).collect(Collectors.toList()));
    return vo;
}

3.4 写入:多对一的红利

@Data
public class OrderCreateDTO {
    @NotBlank private String orderNo;
    @NotNull  private BigDecimal amount;
    @NotNull  private Long customerId;   // 前端传客户 id 即可
}

public void createOrder(OrderCreateDTO dto) {
    Order order = new Order();
    BeanUtils.copyProperties(dto, order);
    orderMapper.insert(order);   // 外键就在订单表,一条 insert 完事,不需要事务
}

外键在"多"方,写入时一条 insert 就搞定——这是 1:N 关系最舒服的地方。

四、一对一:用户与档案(补上笔记里缺的那段)

一对一是最容易被"平铺"两个字糊弄过去的。看完整例子。

4.1 建表:唯一约束是关键

CREATE TABLE user (
    id       BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL
);

CREATE TABLE user_profile (
    id      BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    id_card VARCHAR(18),
    address VARCHAR(200),
    CONSTRAINT uk_user_id UNIQUE (user_id)   -- 唯一约束:一个用户只能有一份档案 → 一对一
);

把 UNIQUE 去掉,这张表立刻退化成一对多。一对一和一对多在表结构上的差别,就这一条约束。

4.2 查询:VO 里嵌套的是对象,不是 List

@Data
public class UserVO {
    private Long id;
    private String username;
    private UserProfileVO profile;   // 一对一的标志:VO 里放单个对象
}
public UserVO getUserWithProfile(Long userId) {
    User user = userMapper.selectById(userId);
    UserProfile profile = profileMapper.selectOne(
        new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, userId));

    UserVO vo = new UserVO();
    BeanUtils.copyProperties(user, vo);
    UserProfileVO pv = new UserProfileVO();
    BeanUtils.copyProperties(profile, pv);
    vo.setProfile(pv);
    return vo;
}

对比上一节你会发现:除了一处 setOrders(List) 换成了 setProfile(对象),整个代码骨架和一对多完全一样。 这就是"只有两种组装姿势"的含义。

五、多对多:学生与课程

5.1 三张表

学生和课程谁也放不下这条外键,于是开中间表:

CREATE TABLE student (
    id   BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL
);

CREATE TABLE course (
    id   BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL
);

CREATE TABLE student_course (              -- 中间表:两条外键
    id         BIGINT PRIMARY KEY AUTO_INCREMENT,
    student_id BIGINT NOT NULL,
    course_id  BIGINT NOT NULL,
    UNIQUE KEY uk_s_c (student_id, course_id)   -- 防重复选课
);

5.2 查法一:分步查(查三次)

需求:查出小明(id=1)和他选的所有课程。

// 第一步:查小明
Student student = studentMapper.selectById(1L);

// 第二步:查中间表,拿到课程 id 列表
List<Long> courseIds = studentCourseMapper.selectList(
        new LambdaQueryWrapper<StudentCourse>().eq(StudentCourse::getStudentId, 1L))
    .stream().map(StudentCourse::getCourseId).collect(Collectors.toList());

// 第三步:拿 id 批量查课程
List<Course> courses = courseMapper.selectBatchIds(courseIds);

// 组装
StudentWithCoursesVO vo = new StudentWithCoursesVO();
vo.setStudentId(student.getId());
vo.setName(student.getName());
vo.setCourses(courses.stream().map(c -> {
    CourseVO cv = new CourseVO();
    BeanUtils.copyProperties(c, cv);
    return cv;
}).collect(Collectors.toList()));

就是一对多的"查两次"变成了"查三次"——中间多了一张表而已,思路没有任何变化。

5.3 查法二:join 查(查一次)

SELECT s.id AS student_id, s.name AS student_name,
       c.id AS course_id,   c.name AS course_name
FROM student s
JOIN student_course sc ON sc.student_id = s.id
JOIN course c        ON c.id = sc.course_id
WHERE s.id = 1;

结果集(注意小明重复出现了):

student_id student_name course_id course_name
1 小明 10 数学
1 小明 20 英语

Java 组装——取第一行的人,遍历所有行的课:

List<StudentCourseRow> rows = studentMapper.selectStudentWithCourses(1L);  // 自定义 join 查询

StudentWithCoursesVO vo = new StudentWithCoursesVO();
vo.setStudentId(rows.get(0).getStudentId());   // 主对象信息每一行都一样,取第一行
vo.setName(rows.get(0).getStudentName());
vo.setCourses(rows.stream().map(r -> {
    CourseVO cv = new CourseVO();
    cv.setCourseId(r.getCourseId());
    cv.setName(r.getCourseName());
    return cv;
}).collect(Collectors.toList()));

看出来了吗?这段组装代码和"一对多 join 查"的组装代码一模一样。 都是:join 出来主对象信息在每行重复 → 取第一行当主对象 → 遍历所有行拼子 List。关系是多对多还是一对多,组装代码根本不关心——它只关心 VO 里放的是对象还是 List。

5.4 写入:操作中间表,先删后插,必须加事务

给小明(id=1)重新设置课程为 [10, 20]:

@Transactional   // ← 笔记里最容易漏的一行!
public void setStudentCourses(Long studentId, List<Long> courseIds) {
    // 1. 先删掉已有的所有选课记录
    studentCourseMapper.delete(
        new LambdaQueryWrapper<StudentCourse>().eq(StudentCourse::getStudentId, studentId));
    // 2. 再逐条插入新的选课记录
    for (Long courseId : courseIds) {
        StudentCourse sc = new StudentCourse();
        sc.setStudentId(studentId);
        sc.setCourseId(courseId);
        studentCourseMapper.insert(sc);
    }
}

⚠️ 为什么这里必须 @Transactional,而前面下订单不用? 判断标准就一条:这个业务操作是不是一条 SQL 能完成的。 下订单是一条 insert,天然原子;先删后插是 N+1 条 SQL,删完崩了、插没执行,数据就残缺了,必须事务兜底。

六、分步查 vs join 查,到底怎么选

场景 推荐写法 原因
查单个主对象 + 它的子列表 分步查 简单清晰,好维护
查一批主对象 + 各自的子列表 批量分步查 + groupingBy 防 N+1
子列表字段少、关联层级浅 join 一次 少一次网络往返
嵌套多层(用户 → 订单 → 订单项) 分步查 多层 join 会笛卡尔积式膨胀

重点说说 N+1,这是分步查唯一的坑:

错误示范——查 20 个客户,循环里逐个查订单,1 + 20 = 21 条 SQL(相当于前端在列表渲染的循环里逐项发请求,老前端看到这儿血压已经上来了):

for (Customer c : customers) {
    orderMapper.selectList(...eq(Order::getCustomerId, c.getId()));  // 每循环一次打一次数据库
}

正确姿势——一把 in 全查出来,内存里分组(就是 lodash 的 groupBy):

// ① 查一批主对象
List<Customer> customers = customerMapper.selectBatchIds(customerIds);

// ② 一次 in 查出所有相关订单
List<Order> allOrders = orderMapper.selectList(
    new LambdaQueryWrapper<Order>().in(Order::getCustomerId, customerIds));

// ③ 内存里 groupBy,2 条 SQL 搞定
Map<Long, List<Order>> ordersByCustomer = allOrders.stream()
    .collect(Collectors.groupingBy(Order::getCustomerId));

// ④ 组装
List<CustomerVO> vos = customers.stream().map(c -> {
    CustomerVO vo = new CustomerVO();
    BeanUtils.copyProperties(c, vo);
    vo.setOrders(ordersByCustomer.getOrDefault(c.getId(), List.of())
        .stream().map(o -> {
            OrderVO ov = new OrderVO();
            BeanUtils.copyProperties(o, ov);
            return ov;
        }).collect(Collectors.toList()));
    return vo;
}).collect(Collectors.toList());

七、一页总结

四段代码并排看,找到你的肌肉记忆:

┌──────────────────────────────────────────────────────────────┐
│ 一对一·分步查:查主表 → 查从表 → set 单个对象                  │
├──────────────────────────────────────────────────────────────┤
│ 一对多·分步查:查"一" → 按外键查"多" → set List               │
│ 一对多·join查:一条 SQL → 取第一行的"一",遍历所有行拼 List    │
├──────────────────────────────────────────────────────────────┤
│ 多对多·分步查:查主表 → 查中间表 ids → 查副表 → set List       │
│ 多对多·join查:一条 SQL join 三表 → 同上,拼 List              │
└──────────────────────────────────────────────────────────────┘

最后四句话,背下来就够:

  1. 关系看约束:外键永远放"多"方;唯一约束是一对一;中间表是多对多。
  2. 写法看 VO:嵌套对象一派,嵌套 List 一派,只有这两种组装姿势。
  3. 查法看场景:单个主对象分步查;批量主对象批量分步查(in + groupingBy,防 N+1);浅层可 join;深层嵌套乖乖分步。
  4. 写入看 SQL 条数:一条 SQL 能搞定的不用事务;先删后插等多步写入必须 @Transactional。
posted @ 2026-09-26 21:46  -鹿-  阅读(9)  评论(0)    收藏  举报