前两篇已经完成单表实体映射,并使用 Spring Data JPA 操作 HealthCheckHistoryEntity。实际业务中的表很少永远彼此独立,例如多个受监控服务可能属于同一个监控分组,这会形成表之间的外键关系。
本章增加一组独立的监控元数据表,用它学习 @ManyToOne、@OneToMany、关系维护端、加载策略与级联。已有心跳历史表继续保存服务名称,不在学习关联关系时同时重写已经工作的 Repository。
1. 关系模型
需求是一个监控分组可以包含多个受监控服务,而一个服务当前只属于一个分组:
1MonitoringGroup 12-> MonitoredService N34backend5-> spring6-> postgresql
关系数据库使用子表中的外键表达这层关系:
| 表 | 主键 | 关键字段 | 关系 |
|---|---|---|---|
monitoring_group | id | name | 被多个服务引用 |
monitored_service | id | name、group_id | group_id 指向分组 |
group_id 存在于 monitored_service,所以每一行服务都能明确找到所属分组。外键保证它不能指向不存在的分组。
这两张表保存的是「监控对象怎样组织」的元数据,与 health_status、health_check_history 保存的「检查结果」不同。真实项目以后可以继续把检查记录关联到服务主键,但本章先控制变化范围。
2. 创建表结构
Flyway 已经执行 V1 和 V2,因此新增第三个迁移:
01create table monitoring_group (02id bigint generated always as identity primary key,03name varchar(50) not null unique04);0506create table monitored_service (07id bigint generated always as identity primary key,08name varchar(50) not null unique,09group_id bigint not null,10constraint monitored_service_group_fkey11foreign key (group_id)12references monitoring_group (id)13on delete restrict14);1516create index idx_monitored_service_group17on monitored_service (group_id);
约束名称 monitored_service_group_fkey 让数据库错误和迁移脚本更容易定位。ON DELETE RESTRICT 表示一个分组仍被服务引用时拒绝删除,避免服务突然失去归属。
PostgreSQL 不会自动为外键列创建索引。按分组查询服务或检查删除行为时需要使用 group_id,因此显式创建索引。
迁移只创建数据库结构,不让 Hibernate 自动修改表。前文的配置继续保留:
1spring:2jpa:3hibernate:4ddl-auto: validate
应用启动时先由 Flyway 执行缺失迁移,再由 Hibernate 验证实体映射与最终表结构是否一致。
3. 多对一
先映射持有外键的服务实体:
01package cn.usehook.spring.monitoring;0203import jakarta.persistence.Column;04import jakarta.persistence.Entity;05import jakarta.persistence.FetchType;06import jakarta.persistence.GeneratedValue;07import jakarta.persistence.GenerationType;08import jakarta.persistence.Id;09import jakarta.persistence.JoinColumn;10import jakarta.persistence.ManyToOne;11import jakarta.persistence.Table;1213@Entity14@Table(name = "monitored_service")15public class MonitoredServiceEntity {1617@Id18@GeneratedValue(strategy = GenerationType.IDENTITY)19private Long id;2021@Column(nullable = false, length = 50, unique = true)22private String name;2324@ManyToOne(fetch = FetchType.LAZY, optional = false)25@JoinColumn(name = "group_id", nullable = false)26private MonitoringGroupEntity group;2728protected MonitoredServiceEntity() {}2930MonitoredServiceEntity(String name, MonitoringGroupEntity group) {31this.name = name;32this.group = group;33}3435public Long getId() {36return id;37}3839public String getName() {40return name;41}4243public MonitoringGroupEntity getGroup() {44return group;45}46}
@ManyToOne 表示多个服务对象可以引用同一个分组对象。@JoinColumn 指定当前实体表中的外键列,因此 MonitoredServiceEntity.group 是这段双向关系的维护端。
optional = false 表达 Java 关系不能为空,nullable = false 描述映射期望;真正阻止空值落库的仍然是迁移中的 NOT NULL。三个位置表达相同业务规则,但分别服务于对象模型、映射检查和数据库完整性。
JPA 对 @ManyToOne 的规范默认加载方式是 EAGER。这里显式改成 LAZY,让查询服务时不必总是立即读取分组;需要分组数据的查询会在下一篇使用 fetch join 或投影明确取得。
4. 一对多
分组实体使用集合表示反向关系:
01package cn.usehook.spring.monitoring;0203import jakarta.persistence.CascadeType;04import jakarta.persistence.Column;05import jakarta.persistence.Entity;06import jakarta.persistence.GeneratedValue;07import jakarta.persistence.GenerationType;08import jakarta.persistence.Id;09import jakarta.persistence.OneToMany;10import jakarta.persistence.Table;11import java.util.ArrayList;12import java.util.List;1314@Entity15@Table(name = "monitoring_group")16public class MonitoringGroupEntity {1718@Id19@GeneratedValue(strategy = GenerationType.IDENTITY)20private Long id;2122@Column(nullable = false, length = 50, unique = true)23private String name;2425@OneToMany(mappedBy = "group", cascade = CascadeType.PERSIST)26private List<MonitoredServiceEntity> services = new ArrayList<>();2728protected MonitoringGroupEntity() {}2930public MonitoringGroupEntity(String name) {31this.name = name;32}3334public void addService(String serviceName) {35services.add(new MonitoredServiceEntity(serviceName, this));36}3738public Long getId() {39return id;40}4142public String getName() {43return name;44}4546public List<MonitoredServiceEntity> getServices() {47return List.copyOf(services);48}49}
mappedBy = "group" 指向服务实体中的 group 字段,表示外键由对方维护。这一侧没有第二个 @JoinColumn,否则 Hibernate 可能把同一个业务关系理解为两套独立映射。
addService() 同时把子对象加入集合,并让子对象引用当前分组。双向关系在 Java 内存中不会自动同步,只修改其中一侧会让当前对象图出现矛盾,因此用方法集中维护两端。
getServices() 返回列表副本,调用方不能绕过 addService() 直接修改内部集合。Hibernate 仍然通过字段访问管理原集合。
5. 级联与生命周期
cascade = CascadeType.PERSIST 表示持久化新分组时,也持久化集合中的新服务:
1MonitoringGroupEntity group = new MonitoringGroupEntity("backend");2group.addService("spring");3group.addService("postgresql");45entityManager.persist(group);
级联不会改变数据库外键方向,也不等于所有操作都应该传播。本章只允许新对象一起保存,没有启用 CascadeType.REMOVE,因为删除一个分组时是否应该删除全部服务是独立的数据生命周期决策。
数据库当前使用 ON DELETE RESTRICT,对象映射也没有级联删除,两层规则保持一致。如果业务以后决定「删除分组时连同服务一起删除」,需要同时评估 JPA 级联、orphanRemoval、数据库外键动作和历史数据保留要求,不能只改一个注解。
@OneToMany 默认延迟加载。读取一个分组时,services 集合通常只是尚未初始化的代理集合;第一次在打开的持久化上下文中访问它,Hibernate 才执行服务查询。
不要为了避免延迟加载异常,把所有关系统一改成 EAGER。这会让简单查询也加载整张对象图,并可能产生大量不可控 SQL。更可靠的做法是根据具体用例,在 Repository 查询中明确需要的数据。
6. 验证关系
沿用 Testcontainers 和事务测试,验证级联保存、外键和延迟集合:
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.TestcontainersConfiguration;04import jakarta.persistence.EntityManager;05import org.junit.jupiter.api.Test;06import org.springframework.beans.factory.annotation.Autowired;07import org.springframework.boot.test.context.SpringBootTest;08import org.springframework.context.annotation.Import;09import org.springframework.transaction.annotation.Transactional;1011import static org.assertj.core.api.Assertions.assertThat;1213@SpringBootTest14@Import(TestcontainersConfiguration.class)15@Transactional16class MonitoringRelationshipTests {1718private final EntityManager entityManager;1920MonitoringRelationshipTests(@Autowired EntityManager entityManager) {21this.entityManager = entityManager;22}2324@Test25void persistsGroupAndServices() {26MonitoringGroupEntity group = new MonitoringGroupEntity("backend");27group.addService("spring");28group.addService("postgresql");2930entityManager.persist(group);31entityManager.flush();32entityManager.clear();3334MonitoringGroupEntity saved = entityManager.find(35MonitoringGroupEntity.class,36group.getId()37);3839assertThat(saved.getServices())40.extracting(MonitoredServiceEntity::getName)41.containsExactlyInAnyOrder("spring", "postgresql");42}43}
flush() 确保插入语句和外键约束在断言前执行,clear() 则保证后续对象来自重新查询。测试方法仍处于事务中,因此访问延迟集合时持久化上下文没有关闭。
这个测试证明映射能够工作,但还没有证明查询次数合理。逐个读取多个服务的分组可能形成 N+1 查询,集合 fetch join 又不能随意和分页混用。下一篇通过 JPQL、DTO 投影和实体图明确每个用例需要的数据,并观察生成的 SQL。