前言
一對一關聯在領域模型中常見 (用戶-護照、訂單-支付詳情)。Hibernate 提供主鍵關聯 (Primary Key Association)與外鍵關聯 (Foreign Key Association)兩種映射方式,各有優劣。本文通過雙向導航、級聯持久化、延遲加載策略、SQL 對比,全方位解析最佳實踐。
一、領域模型
// 方式一:主鍵關聯 (共享主鍵)
@Entity public class TUser {
@Id @GeneratedValue Long id;
String name; int age;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
TPassport passport;
}
@Entity public class TPassport {
@Id Long id; // 無 @GeneratedValue
String serial; int expiry;
@OneToOne @MapsId @JoinColumn(name = "id")
TUser user; // 反向導航,共享主鍵
}
// 方式二:外鍵關聯 (獨立主鍵 + 唯一約束)
@Entity public class TUser {
@Id @GeneratedValue Long id;
String name; int age;
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "passport_id", unique = true)
TPassport passport;
}
@Entity public class TPassport {
@Id @GeneratedValue Long id;
String serial; int expiry;
@OneToOne(mappedBy = "passport")
TUser user;
}
二、XML 映射對比 (傳統專案參考)
2.1 主鍵關聯 (共享主鍵)
<class name="TUser" table="USER4">
<id name="id"><generator class="native"/></id>
<property name="name"/> <property name="age"/>
<one-to-one name="passport" class="TPassport" cascade="all" constrained="true"/>
</class>
<class name="TPassport" table="PASSPORT4">
<id name="id">
<generator class="foreign"><param name="property">user</param></generator>
</id>
<property name="serial"/> <property name="expiry"/>
<one-to-one name="user" class="TUser" constrained="true"/>
</class>
constrained="true":宣告外鍵約束,Hibernate 知曉 PASSPORT4.id 參照 USER4.id
foreign generator:PASSPORT 主鍵由 USER 主鍵生成,強一致性
- 雙向導航:
user.getPassport()、passport.getUser() 均可
2.2 外鍵關聯 (唯一外鍵)
<class name="TUser" table="USER5">
<id name="id"><generator class="native"/></id>
<property name="name"/> <property name="age"/>
<many-to-one name="passport" class="TPassport" column="passport_id" unique="true" cascade="all"/>
</class>
<class name="TPassport" table="PASSPORT5">
<id name="id"><generator class="native"/></id>
<property name="serial"/> <property name="expiry"/>
<one-to-one name="user" class="TUser" property-ref="passport"/>
</class>
- 本質是 Many-to-One + unique="true",Hibernate 視為一對一特例
- 雙方主鍵獨立生成,
property-ref 指定反向導航屬性
- 級聯需在關聯擁有方 (User) 設定
cascade="all"
三、SQL 行為對比
| 操作 | 主鍵關聯 | 外鍵關聯 |
| 保存雙向對象 | 1 INSERT (User) + 1 INSERT (Passport, 共享 ID) | 1 INSERT (User) + 1 INSERT (Passport) + 1 UPDATE (User 設 passport_id) |
| 查詢 User 帶 Passport | LEFT OUTER JOIN (可配 outer-join="true") | LEFT OUTER JOIN (預設) |
| 刪除 User (cascade=all) | DELETE Passport + DELETE User (順序自動) | DELETE Passport + DELETE User |
| 主鍵生成策略 | Passport 依賴 User | 獨立生成 |
四、延遲加載與 outer-join 策略
<one-to-one name="passport" class="TPassport" outer-join="true" />
<!-- outer-join="true" : 左外連立即抓取 (預設 false: proxy 延遲) -->
- outer-join="true":查 User 時自動 LEFT JOIN Passport,單 SQL 完成,適合「必須同時用到關聯」場景。
- outer-join="false" (預設):返回 User 代理,首次調用
getPassport() 觸發二次查詢 (N+1 風險)。
- 註解式:
@OneToOne(fetch = FetchType.EAGER) vs LAZY (預設)。
五、級聯操作最佳實踐
// 主鍵關聯:雙向級聯
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
TPassport passport;
// 外鍵關聯:僅關聯擁有方級聯
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "passport_id", unique = true)
TPassport passport;
orphanRemoval = true:斷開關聯時自動刪除孤兒對象 (如 user.setPassport(null) 觸發 DELETE Passport)
- 主鍵關聯雙向級聯安全 (共享生命週期);外鍵關聯僅擁有方級聯,避免循環級聯導致重複操作。
六、常見坑與避坑指南
| 坑點 | 後果 | 解決 |
| 雙向關聯未維護反向引用 | 關聯對象無法導航、級聯失效 | 在 setter 中同步:user.setPassport(p); p.setUser(user); |
| 外鍵關聯未加 unique=true | 退化為多對一,資料不一致 | 必須 @JoinColumn(unique=true) 或 DB 唯一索引 |
| 主鍵關聯使用 IDENTITY 策略 | Passport 無法共享 ID,插入失敗 | 用 SEQUENCE/TABLE 或 assigned,配合 foreign generator |
| N+1 查詢 | 列表頁逐行觸發關聯查詢 | HQL join fetch、EntityGraph、Batch Fetching |
七、總結選型建議
- 生命週期完全綁定、業務上不可分割 (用戶-身份證、訂單-支付) → 主鍵關聯,共享主鍵、強一致、單表查詢快。
- 生命週期相對獨立、可能單獨存在/刪除 (用戶-偏好設定、商品-擴展屬性) → 外鍵關聯,獨立主鍵、鬆耦合。
- 高併發讀多寫少、關聯必用 → 加
outer-join="true" / FetchType.EAGER 或 @BatchSize。
- 嚴禁:單向一對一不設級聯、不設外鍵約束、主鍵關聯用 IDENTITY。