《Spring Boot 入门》13.1 关联映射

图书、作者、分类之间天然存在关联。本节用图书服务演示 @OneToOne、@OneToMany、@ManyToOne、@ManyToMany 四种映射,讲清 mappedBy 与关系维护方、@JoinColumn 指定外键、级联与 orphanRemoval 的取舍,并用 show-sql 直观演示 N+1 问题。

本节目标:把图书服务从「一张表」扩展到「书—作者—分类」的关联模型,掌握四种关联注解、关系维护方、级联与抓取策略,并能识别 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 = REMOVEorphanRemoval = true
删除父实体子被删除子被删除
从集合移除子子仍在数据库子被删除

一条重要的安全提醒:@ManyToOne 上一般不要加 cascade = REMOVE 或 ALL。删除一本书不应该把它的作者也删掉,那样会连带毁掉该作者的其它书。级联删除通常只用在「子实体完全依附于父实体」的场景,比如订单与订单项。

13.1.7 fetch 策略与 N+1 问题

抓取策略决定「加载一个实体时,它的关联什么时候被查出来」。默认值因注解而异:

注解默认 fetch
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

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 条——接口耗时随数据量线性膨胀。两类解法:

  1. 改成 LAZY(推荐把 @ManyToOne 显式写成 fetch = FetchType.LAZY),需要时用 join fetch 一次性取回。
  2. 用 @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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计