Java 多表关联(一对一/一对多/多对多)理解指南
MyBatis-Plus 多表关联:没有"三种写法",只有两种组装姿势
一对一、一对多、多对多,很多人是当三段代码背的。背完就乱:为什么一对多查两次、多对多又只 join 一次?其实关系类型和查询方式是两个完全独立的维度,真正需要记住的只有两条规律。这篇把它彻底捋顺。
一、先纠正三个流传甚广的错误认知
这三个说法你大概率听过,甚至笔记里就是这么记的:
- ❌ "一对多 = 查两次表"
- ❌ "多对多 = join 一次"
- ❌ "一对一 / 一对多 / 多对多是三种不同的代码写法"
全是错的。 真相是:
- 关系类型(1:1 / 1:N / M:N)和查询方式(分步查 / join 查)是两个正交的维度。 任何一种关系,都可以分步查,也都可以 join 查。它们之间是自由组合的,不存在绑定关系。
- 代码写法上只分两派:VO 里嵌套单个对象(一对一、多对一),或者 VO 里嵌套 List(一对多、多对多)。组装代码的骨架一模一样。
- 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 │
└──────────────────────────────────────────────────────────────┘
最后四句话,背下来就够:
- 关系看约束:外键永远放"多"方;唯一约束是一对一;中间表是多对多。
- 写法看 VO:嵌套对象一派,嵌套 List 一派,只有这两种组装姿势。
- 查法看场景:单个主对象分步查;批量主对象批量分步查(
in+groupingBy,防 N+1);浅层可 join;深层嵌套乖乖分步。 - 写入看 SQL 条数:一条 SQL 能搞定的不用事务;先删后插等多步写入必须
@Transactional。

浙公网安备 33010602011771号