本节目标:把图书服务从「一张表」扩展到「书—作者—分类」的关联模型,掌握四种关联注解、关系维护方、级联与抓取策略,并能识别 N+1 问题。
适用版本:Spring Boot 4.1.x(Java 21)
13.1 关联映射
第 12 章我们把 Book 存进了一张表:书名、ISBN、价格、出版年份都是「属于这本书自己的」列。但一本书还有作者、有分类,这些概念不适合塞进 book 表的列里——同一个作者写很多本书,同一个分类下有很多本书。这类「实体与实体之间的关系」就是本节的主题。目标模型如下:
| 关系 | 例子 | 基数 |
|---|---|---|
| 多对一 / 一对多 | 多本书 → 一个作者;一个作者 → 多本书 | N : 1 |
| 多对一 / 一对多 | 多本书 → 一个分类;一个分类 → 多本书 | N : 1 |
| 一对一 | 一个作者 → 一份作者档案 | 1 : 1 |
| 多对多 | 一本书 ↔ 多个标签;一个标签 ↔ 多本书 | N : M |
13.1.1 先建两个「一」端实体
Author 与 Category 都是简单的独立实体(为节省篇幅,后文代码省略 import):
@Entity
@Table(name = "author")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
// 省略 getter / setter
}
Category 结构几乎一样,只有 name 一个字段。注意包名仍是 jakarta.persistence——Spring Boot 4.x 基于 Jakarta EE 11,javax.persistence 早已不存在。
13.1.2 @ManyToOne:多对一,外键放在「多」的一端
「多本书属于同一个作者」,从 Book 的角度看就是多对一:
@Entity
@Table(name = "book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
}
两个要点:
- 外键列放在「多」的一端,也就是
book表里多出author_id、category_id两列。这是关系型数据库的固有约束——一对多的外键只能指向「一」的那一行的主键。 @JoinColumn(name = "author_id")显式指定外键列名。不写时 JPA 会按默认命名规则生成author_id,但显式声明更利于后续读表结构。
13.1.3 @OneToMany 与 mappedBy:谁才是关系维护方
反过来,从 Author 看「一个作者有多本书」,就是一对多。这里有一个几乎人人都会踩的概念:关系维护方(owning side)。
@Entity
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author")
private List<Book> books = new ArrayList<>();
}
关键在 mappedBy = "author"。它的含义是:「这段关系不归我维护,去 Book 实体的 author 属性那里找维护方」。
| 端 | 注解 | 是否维护方 | 是否写外键 |
|---|---|---|---|
Book.author | @ManyToOne + @JoinColumn | 是 | 会写 book.author_id |
Author.books | @OneToMany(mappedBy = "author") | 否 | 不写任何列 |
为什么要有这个概念?因为只有在维护方设置的关联才会落库。看下面这段代码:
Author author = authorRepository.findById(1L).orElseThrow();
Book book = new Book();
book.setTitle("Effective Java");
// 错误:只在非维护方添加,数据库不会记录关联
author.getBooks().add(book);
bookRepository.save(book);
结果 book.author_id 是 null——因为 Author.books 是 mappedBy 端,它只反映内存里的集合,不负责生成 SQL。正确写法是设置维护方 book.setAuthor(author)。如果确实想让两端都保持同步,就在实体里提供一个辅助方法:addBook(Book book) 内同时执行 this.books.add(book) 与 book.setAuthor(this),调用它时两端一起改,就不会再漏。
13.1.4 @OneToOne:一对一
一个作者对应一份档案,档案里放简介、头像这类不常查的字段:
@Entity
public class AuthorProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
}
@OneToOne 的维护方同样由 @JoinColumn 决定,另一端写 @OneToOne(mappedBy = "author")。要小心一个经典陷阱:在非维护方(mappedBy 那端)把 fetch 设成 LAZY 常常无效——Hibernate 无法在不查外键的情况下判断「关联是否存在」,于是直接发出查询。想要真正的惰性,需要字节码增强(hibernate.enhancer.enableLazyInitialization)配合。
13.1.5 @ManyToMany 与中间表定制
一本书可以有多个标签,一个标签可以属于多本书——多对多。关系型数据库里,多对多用一张中间表实现:
@Entity
public class Book {
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "book_tag",
joinColumns = @JoinColumn(name = "book_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new HashSet<>();
}
@JoinTable 的三个属性:
| 属性 | 含义 |
|---|---|
name | 中间表名,这里是 book_tag |
joinColumns | 中间表中指向「本方」(Book)的外键列 |
inverseJoinColumns | 中间表中指向「对方」(Tag)的外键列 |
Tag 端通常只写 @ManyToMany(mappedBy = "tags"),不重复定义中间表。实践建议:一旦中间表需要加额外字段(比如「打标签的时间」),@ManyToMany 就撑不住了——它只允许中间表有两个外键列。这时应当用一个中间实体替代:
@Entity
@Table(name = "book_tag")
public class BookTag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "book_id")
private Book book;
@ManyToOne
@JoinColumn(name = "tag_id")
private Tag tag;
private Instant taggedAt; // 中间表上的额外字段
}
本质上就是把多对多拆成「两个多对一」,牺牲一点便利换取扩展性。
13.1.6 级联 CascadeType 各取值
级联(cascade)决定「对父实体做的操作,是否传播到子实体」,只在维护方上生效:
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
| 取值 | 语义 |
|---|---|
PERSIST | 保存父时,顺带保存尚未持久化的子 |
MERGE | 合并父时,顺带合并子 |
REMOVE | 删除父时,顺带删除子 |
REFRESH | 刷新父时,顺带刷新子 |
DETACH | 父从持久化上下文移除时,子也移除 |
ALL | 以上全部 |
orphanRemoval = true 是另一个维度:它表示「从集合里移除的子实体,就当孤儿删掉」。author.getBooks().remove(book) 在开启后会触发一条 DELETE。二者区别常被问到:
| 场景 | cascade = REMOVE | orphanRemoval = true |
|---|---|---|
| 删除父实体 | 子被删除 | 子被删除 |
| 从集合移除子 | 子仍在数据库 | 子被删除 |
一条重要的安全提醒:@ManyToOne 上一般不要加 cascade = REMOVE 或 ALL。删除一本书不应该把它的作者也删掉,那样会连带毁掉该作者的其它书。级联删除通常只用在「子实体完全依附于父实体」的场景,比如订单与订单项。
13.1.7 fetch 策略与 N+1 问题
抓取策略决定「加载一个实体时,它的关联什么时候被查出来」。默认值因注解而异:
| 注解 | 默认 fetch |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
EAGER 表示「立刻查」,LAZY 表示「用到才查」。问题在于,EAGER 配上「查列表」就是灾难。假设我们把 Book.author 保留默认的 EAGER,然后执行 bookRepository.findAll(),打开 spring.jpa.show-sql: true 与 format_sql 后,控制台会看到:
-- 第 1 条:查出所有书
select b1_0.id,b1_0.author_id,b1_0.category_id,b1_0.title from book b1_0;
-- 第 2 条:为第 1 本书查作者
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=1;
-- 第 3 条:为第 2 本书查作者
select a1_0.id,a1_0.name from author a1_0 where a1_0.id=2;
-- ……每本书再来一条,一共 N 条
这就是 N+1 问题:1 条查询取回 N 本书,再为每本书各发 1 条查询取关联,总计 N+1 条 SQL。10 本书就是 11 条,1000 本书就是 1001 条——接口耗时随数据量线性膨胀。两类解法:
- 改成
LAZY(推荐把@ManyToOne显式写成fetch = FetchType.LAZY),需要时用join fetch一次性取回。 - 用
@EntityGraph或 JPQL 的join fetch在需要关联的查询里显式抓取,下一节 13.2 会展开。
LAZY 也不是免费的:如果事务已经结束再去访问代理对象,会抛出 LazyInitializationException。原因是 Session 已关闭,代理无法再发 SQL。稳妥做法是在 Service 层的事务内完成关联数据的读取,然后转成 DTO 返回。
13.1.8 toString / equals 的递归坑
双向关联下,两个实体互相引用。如果 toString() 里都打印对方,就会无限递归(Book.toString() 打印 author,Author.toString() 又打印 books),最终栈溢出。equals / hashCode 同理。规避方式有三条:
toString()里只打印基本字段,不打印关联对象;equals/hashCode只用主键,且在实体被持久化(拿到 ID)之后才有意义;- 用 IDE 生成时手动删掉关联字段。
hashCode 建议返回一个固定值,避免实体 ID 从 null 变成非空时 hash 不稳定、破坏 HashSet 的契约:
@Override
public int hashCode() {
return getClass().hashCode();
}
13.1.9 实体关系设计的实践建议
| 建议 | 原因 |
|---|---|
| 优先单向 | 只有 Book.author 单向多对一,往往就够用了;反向集合常常用不上 |
| 避免双向 | 双向要维护两端一致、要处理递归、要小心级联,成本远高于收益 |
大数据量不要用 @ManyToMany | 中间表只两列,无法加字段;集合抓取极易触发 N+1 与笛卡尔积 |
@ManyToOne 显式写 LAZY | 默认 EAGER 是列表接口的性能杀手 |
| 谨慎使用级联删除 | 误用会把「删一个」放大成「删一片」 |
| 关联遍历只在事务内做 | 避免 LazyInitializationException,同时便于转 DTO |
常见坑速查:
| 现象 | 根因 | 解决 |
|---|---|---|
保存后外键为 null | 只在 mappedBy 端设置了关联 | 设置维护方,或写辅助方法 |
| 列表接口慢 | @ManyToOne 默认 EAGER 触发 N+1 | 改 LAZY + join fetch / @EntityGraph |
| 栈溢出 | toString 打印了关联对象 | 只打印基本字段 |
| 删一本书连作者也没了 | @ManyToOne 上加了 REMOVE 级联 | 去掉级联,或改由业务显式删除 |
报 LazyInitializationException | 事务外访问代理对象 | 在事务内转成 DTO |
小结
- 一对多的外键放在「多」的一端,用
@ManyToOne+@JoinColumn声明;「一」端用@OneToMany(mappedBy = ...)。 mappedBy标记的是非维护方,只有维护方设置的关联才会写进数据库。- 多对多用
@JoinTable定制中间表;中间表一旦需要额外字段,就该改用中间实体。 - 级联用
CascadeType控制,orphanRemoval额外负责「移除即删除」;@ManyToOne上慎用删除级联。 - 抓取策略默认值里
@ManyToOne是EAGER,列表查询会引发 N+1;显式改LAZY并配合join fetch。 - 双向关联要小心
toString/equals递归,只打印基本字段、equals只用主键。 - 关系设计优先单向、少用双向、大数据量避开
@ManyToMany。
关联建模完成后,光靠 12.3 的派生查询已经不够用了——跨表条件、聚合、批量更新都需要自己写查询语句。下一节进入 JPQL 与原生 SQL。
阅读导航:上一节:12.3 派生查询方法 · 下一节:13.2 JPQL 与原生 SQL 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。