Hibernate 多對一關聯映射實戰 (N-1):註解配置、雙向關聯、復合主鍵外鍵、級聯操作、雙向一致性維護
多對一 (@ManyToOne) 是關聯映射最基礎也是最常用的模式。本文以「人員-地址」為例,系統講解單向/雙向映射、復合主鍵作為外鍵的 @JoinColumns 配置、mappedBy 雙向一致性維護、級聯類型選擇、FetchType 效能調優、資料庫腳本與完整測試代碼。
一、領域模型設計
- People (1 方):復合主鍵 (
first + last),一個人可有多個地址。
- Address (N 方):單一主鍵
addressId (自增),屬於一個 People。
- 關聯:People 1 → N Address (雙向),Address 為關聯維護端 (擁有外鍵)。
二、資料庫腳本 (MySQL)
CREATE DATABASE hibernate CHARACTER SET utf8mb4;
USE hibernate;
CREATE TABLE people_inf (
first VARCHAR(50) NOT NULL,
last VARCHAR(50) NOT NULL,
age INT,
PRIMARY KEY (first, last)
) ENGINE=InnoDB;
CREATE TABLE address_inf (
address_id INT AUTO_INCREMENT PRIMARY KEY,
message VARCHAR(255),
people_first VARCHAR(50) NOT NULL,
people_last VARCHAR(50) NOT NULL,
FOREIGN KEY (people_first, people_last) REFERENCES people_inf(first, last)
) ENGINE=InnoDB;
三、實體類註解映射
1. People (一端,被引用端)
@Entity
@Table(name = "people_inf")
public class People implements Serializable {
@Id
@Column(name = "first", length = 50)
private String first;
@Id
@Column(name = "last", length = 50)
private String last;
private int age;
// 雙向一對多:mappedBy 指向 Address 中的屬性名 "people"
@OneToMany(mappedBy = "people", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Set<Address> addresses = new HashSet<>();
// Getter/Setter/equals/hashCode (基於復合主鍵) 省略
// 便利方法:維護雙向一致性
public void addAddress(Address address) {
addresses.add(address);
address.setPeople(this); // 關鍵:設置反向引用
}
public void removeAddress(Address address) {
addresses.remove(address);
address.setPeople(null);
}
}
2. Address (多端,關聯維護端)
@Entity
@Table(name = "address_inf")
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "address_id")
private Integer addressId;
private String message;
// 多對一:@JoinColumns 對應復合外鍵
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns({
@JoinColumn(name = "people_first", referencedColumnName = "first", nullable = false),
@JoinColumn(name = "people_last", referencedColumnName = "last", nullable = false)
})
private People people;
// Getter/Setter 省略
}
四、關鍵註解深度解析
| 註解 | 位置 | 核心作用 |
@ManyToOne | Address.people | 定義多對一關聯,預設 FetchType.LAZY (延遲載入) |
@JoinColumns | Address.people | 映射復合外鍵,每個 @JoinColumn 對應一組 (外鍵列, 參考主鍵列) |
@OneToMany | People.addresses | 定義一對多反向關聯 |
mappedBy="people" | People.addresses | **關鍵**:聲明關聯由 Address.people 維護,People 端不生成外鍵、不維護關聯表 |
cascade=CascadeType.ALL | People.addresses | 持久化/合併/刪除/刷新/脫管 級聯到地址集合 |
orphanRemoval=true | People.addresses (可選) | 從集合移除 Address 時自動刪除該 Address (防止孤兒) |
五、Hibernate 配置與測試代碼
hibernate.cfg.xml (精簡版)
<property name="connection.driver_class">com.mysql.jdbc.Driver</property>
<property name="connection.url">jdbc:mysql://localhost:3306/hibernate?useSSL=false&serverTimezone=UTC</property>
<property name="connection.username">root</property>
<property name="connection.password">123456</property>
<property name="dialect">org.hibernate.dialect.MySQL5InnoDBDialect</property>
<property name="show_sql">true</property>
<property name="hbm2ddl.auto">update</property>
<mapping class="com.example.People"/>
<mapping class="com.example.Address"/>
測試案例
public class ManyToOneTest {
private static SessionFactory sf;
@BeforeAll
static void init() {
sf = new Configuration().configure().buildSessionFactory();
}
@Test
void testSaveCascade() {
Session s = sf.openSession(); Transaction tx = s.beginTransaction();
People p = new People();
p.setFirst("林"); p.setLast("彪"); p.setAge(22);
Address a1 = new Address(); a1.setMessage("廣州");
Address a2 = new Address(); a2.setMessage("茂名");
p.addAddress(a1); // 維護雙向引用
p.addAddress(a2);
s.save(p); // 級聯保存 a1, a2 (CascadeType.ALL)
tx.commit(); s.close();
}
@Test
void testBidirectionalConsistency() {
Session s = sf.openSession(); Transaction tx = s.beginTransaction();
People p = s.get(People.class, new PeopleId("林", "彪")); // 復合主鍵查詢
// 懶加載觸發
System.out.println("地址數: " + p.getAddresses().size()); // SELECT * FROM address WHERE people_first='林' AND people_last='彪'
// 新增地址 (雙向一致性)
Address a3 = new Address(); a3.setMessage("深圳");
p.addAddress(a3); // 設置反向引用
s.save(a3); // 顯式保存新地址 (或依賴 cascade)
tx.commit(); s.close();
}
@Test
void testOrphanRemoval() {
// 需在 People.addresses 加 @OneToMany(orphanRemoval=true)
Session s = sf.openSession(); Transaction tx = s.beginTransaction();
People p = s.get(People.class, new PeopleId("林", "彪"));
Address toRemove = p.getAddresses().iterator().next();
p.removeAddress(toRemove); // 從集合移除
// 提交時自動 DELETE 被移除的 Address (orphanRemoval)
tx.commit(); s.close();
}
@AfterAll
static void destroy() { sf.close(); }
}
六、效能調優建議
- FetchType.LAZY (預設):避免載入 People 時連帶查詢所有 Address。需用時再查。
- @BatchSize(size=10) 在 People.addresses 上:集合延遲載入時批量抓取,減少 N+1 查詢。
- @Fetch(FetchMode.SUBSELECT):子查詢一次載入所有集合,適合已知會遍歷集合的場景。
- 避免在 toString()/equals()/hashCode() 中觸發關聯:導致意外載入,建議僅基於主鍵實現 equals/hashCode。
七、常見錯誤與排查
| 現象 | 原因 | 修正 |
| 外鍵欄位為 NULL | 未設置 Address.people 或 People.addAddress 未調用 | 保存前確保雙向引用一致:p.addAddress(a) 或 a.setPeople(p) |
| Duplicate entry 錯誤 | 復合主鍵 PeopleId 未正確實現 equals/hashCode | 基於 first+last 實現,或用 @EmbeddedId 封裝 |
| 懶加載失敗 (no Session) | Session 關閉後訪問 LAZY 集合 | Open Session In View Filter / DTO 組裝 / fetch join / 初始化後再關閉 |
| 級聯刪除誤刪 | CascadeType.REMOVE + 雙向關聯導致連鎖刪除 | 謹慎使用 REMOVE/ALL,考慮 orphanRemoval 替代 |
八、進階:@EmbeddedId 封裝復合主鍵 (推薦)
@Embeddable
public class PeopleId implements Serializable {
private String first;
private String last;
// equals/hashCode 基於 first+last
}
@Entity
public class People {
@EmbeddedId
private PeopleId id; // 替代兩個 @Id
// ...
}
// 查詢
People p = session.get(People.class, new PeopleId("林", "彪"));