Fetching#
本章探討 Hibernate 的資料擷取策略,說明如何在 entity graph 與 DTO projection 之間做出正確選擇,以及各種 fetching 策略對效能的影響。過度擷取(too-much-fetching)是最常見的 JPA 效能問題之一。
每個 transaction 應該只擷取當前業務邏輯所需的資料量。擷取過多資料會增加回應時間並浪費資源。
DTO Projection#
Hibernate 可以將查詢結果以 projection(DTO)或 entity graph 兩種形式擷取:
- DTO projection 類似 JDBC,將
ResultSet轉換為 DTO 物件,只選取需要的欄位 - Entity graph 則利用 ORM 的自動持久化機制,載入完整的 entity 物件
Projection 適合用於唯讀資料的呈現(如表格、捲動列表),而 entity query 適合用於需要修改資料的場景(如表單編輯)。選擇 entity query 而非 projection 的常見問題包括:
- 載入了 UI 不需要的資料,浪費資料庫、網路和記憶體資源
- Entity query 搭配 child collection 時難以分頁
- Dirty checking 和 optimistic locking 只在需要修改資料時才有意義

Figure 14.1: Post comment score ranking system tables
DTO Projection 與分頁#
- 透過
setFirstResult和setMaxResults實現 offset pagination,Hibernate 會產生資料庫特定的分頁語法(如 PostgreSQL 的LIMIT/OFFSET) - 分頁查詢必須搭配
ORDER BY子句,且排序欄位需有 unique constraint,以確保結果順序確定性 - Keyset pagination 效能優於 offset pagination,但 Hibernate 5.1 不直接支援,需使用 native query
Native Query DTO Projection#
- 使用 JPA 時,可透過
@NamedNativeQuery搭配@SqlResultSetMapping將 native SQL 結果映射為 DTO - 使用 Hibernate 原生 API 時,可透過
ResultTransformer或AliasToBeanResultTransformer將ResultSet轉換為 DTO - Native query 在需要 Recursive CTE、Window Function 等進階 SQL 功能時特別有用
當資料集很大時,將處理邏輯移至資料庫端(如使用 Recursive CTE)比在應用程式端排序與過濾更有效率。

Figure 14.2: Fetching all records vs Recursive CTE
Query Fetch Size#
- 使用 JPA 時,JDBC
ResultSet會被完整走訪並具體化,fetch size 僅影響擷取完整結果集所需的資料庫 round-trip 次數 - PostgreSQL 和 MySQL 預設一次取回所有資料,預設 fetch size 通常足夠
- Oracle 預設 fetch size 僅 10 筆,若分頁大小為 50,則需要 5 次 round-trip
- 可透過 query hint
org.hibernate.fetchSize或全域設定hibernate.jdbc.fetch_size調整
設定全域 fetch size 需謹慎,因為它會影響所有 SQL 查詢。務必透過測量來驗證設定是否合理。
Fetching Entities#
DTO projection 適合唯讀資料,但多數企業應用也需要修改資料。當 entity 被載入後,Persistence Context 會自動管理它,偵測變更並產生對應的 SQL 語句。
Direct Fetching#
最簡單的 entity 載入方式是呼叫 EntityManager.find() 方法。Hibernate 會依序從以下位置尋找 entity:
- First-level cache(Persistence Context)– 保證 application-level repeatable reads
- Second-level cache(若啟用)
- 資料庫(執行 SQL 查詢)
Proxy Reference#
EntityManager.getReference()回傳一個 Proxy,延遲 SQL 執行直到 entity 實際被存取- 適用場景:設定子 entity 的父關聯時,可用
getReference()取得父 entity 的 Proxy,避免不必要的 SELECT 查詢 - Hibernate 原生 API 提供
session.byId().getReference()和session.load()兩種方式
Natural Identifier#
- Hibernate 支援透過 natural identifier(business key)載入 entity,使用
@NaturalId標註 - 透過
session.bySimpleNaturalId(Post.class).load(slug)載入 - Hibernate 5.5 以上版本可在單一查詢中完成;舊版本需要兩次查詢
- 也提供
getReference()方法回傳 Proxy - 搭配 second-level cache(
@NaturalIdCache)可進一步避免查詢
Query Fetching#
- Direct fetching 只能載入單一 entity,若需根據複雜條件載入多筆 entity,需使用 JPQL、HQL 或 Criteria API
- Criteria API 搭配 Metamodel API(如
Post_.title)可實現 type-safe 的動態查詢建構
Fetching Associations#
Association 可分為 object reference(@ManyToOne、@OneToOne)和 collection(@OneToMany、@ManyToMany),各自可設定為 eager 或 lazy 載入。
- 預設行為:
@ManyToOne和@OneToOne為 eager;@OneToMany和@ManyToMany為 lazy - 透過
fetch屬性可覆寫預設策略,結合 implicit 和 explicit 策略形成 default entity graph
Lazy association 可在查詢時透過
join fetch改為 eager 載入,但 eager association 無法在查詢時改為 lazy。因此FetchType.LAZY比FetchType.EAGER更靈活。
FetchType.EAGER#
- 當
@ManyToOne使用預設的FetchType.EAGER時,direct fetching 會產生 JOIN 查詢,而 JPQL 查詢則會產生額外的 secondary SELECT - 對 collection(
@OneToMany、@ManyToMany)使用FetchType.EAGER會導致 Cartesian Product 問題:若一個 Post 有 20 個 comment 和 10 個 tag,單一查詢會回傳 200 筆記錄 - 若不使用
join fetch,Hibernate 會為每個 collection 發出額外查詢
應避免對
@OneToMany和@ManyToManyassociation 使用FetchType.EAGER。Eager fetching 的 association 越多,entity 載入就越慢,因為會涉及大量的 table join 或次要查詢。
FetchType.LAZY#
FetchType.LAZY是高效能應用的更好選擇,fetching 策略應由業務需求決定,在 per-query 基礎上建構 entity graph- Lazy association 在首次被存取時才透過 secondary SELECT 初始化
- 可透過 EntityGraph 或 JPQL
join fetch在需要時 eagerly 載入 lazy association - 對於
@OneToMany和@ManyToMany,Hibernate 使用自己的 collection Proxy 實作(如PersistentBag、PersistentList、PersistentSet)
N+1 Query Problem#
N+1 query problem 是最常見的效能問題:
- 執行一個查詢取得 N 筆 entity 後,若逐一存取每個 entity 的 lazy association,將額外觸發 N 次查詢
- 雖然常與
FetchType.LAZY關聯,但FetchType.EAGER搭配 JPQL 查詢時同樣會產生 N+1 問題 - 解決方法:使用
join fetch在同一查詢中載入關聯的 entity
偵測 N+1 問題:使用 datasource-proxy 框架搭配 SQLStatementCountValidator,在整合測試中驗證 SQL 語句數量是否符合預期。
自動產生的 SQL 語句必須透過整合測試驗證其數量。
datasource-proxy的 statement count validator 可驗證 SELECT、INSERT、UPDATE、DELETE 語句是否正確批次化。
LazyInitializationException#
- 當 Persistence Context 關閉後嘗試存取未初始化的 lazy Proxy 或 collection 時拋出
- 正確做法:在 Persistence Context 開啟期間,使用
fetchJPQL 指令、EntityGraph 或Hibernate.initialize()預先載入需要的 association - 錯誤做法:將 association 改為
FetchType.EAGER(影響所有使用該 entity 的查詢)
Fetching 策略是 query-time 的決策,每個查詢應只擷取當前業務場景需要的資料。
FetchType.EAGER是 mapping-time 的決策,不應在業務邏輯之外決定 association 的載入方式。
Open Session in View Anti-Pattern#
Open Session in View (OSIV) 將 Persistence Context 維持至整個 HTTP request 生命週期,讓 view layer 可以觸發 lazy loading。其問題包括:
- Service layer 結束 transaction 後,UI rendering 階段的 SQL 在 auto-commit 模式下執行,增加資料庫 I/O 負擔
- 破壞了 separation of concerns,SQL 語句可能在 service 和 UI 兩個層級產生
- UI layer 存取 lazy association 仍可能觸發 N+1 query problem
- 資料庫連線在 UI rendering 期間被佔用,降低 connection pool throughput
Open Session in View 是解決一個不該存在的問題。若 UI 只需唯讀資料,應使用 DTO projection,既不會遇到
LazyInitializationException,也能保持 separation of concerns。

Figure 14.3: Open Session in View lifecycle
Temporary Session#
hibernate.enable_lazy_load_no_trans允許在 Persistence Context 關閉後仍能 lazy load association- 每次 lazy loading 都會開啟一個新的臨時
Session,意味著新的資料庫連線和 transaction - 如同 OSIV,這只是治標不治本的 anti-pattern
Associations 與分頁#
- 混合使用 collection
join fetch和 pagination(setMaxResults)時,Hibernate 無法在 SQL 層級分頁 - Hibernate 會載入完整結果集後在記憶體中分頁,並發出警告:
firstResult/maxResults specified with collection fetch; applying in memory! - Entity query 的結果集大小由 child rows 決定,SQL-level pagination 會截斷 collection 資料
Entity query 搭配 collection
join fetch的分頁效率不佳,會導致資料庫擷取完整結果集。對於唯讀視圖,DTO projection 的 SQL-level pagination 更為高效。
Attribute Lazy Fetching#
- 預設情況下,所有 entity attribute 都以
FetchType.EAGER載入(透過隱含的@Basicannotation) - 可將個別 attribute 設為
@Basic(fetch = FetchType.LAZY),使其在首次存取時才載入 - 需要 bytecode enhancement 支援:透過 Maven/Gradle plugin 啟用
enableLazyInitialization - 適用場景:儲存大型資料的欄位(如
BLOB、CLOB、VARBINARY) - 注意:bytecode enhancement 會轉換所有 entity attribute 的 getter,不僅限於標記為 lazy 的 attribute

Figure 14.4: The attachment database table
Fetching Subentities#
另一種避免載入大型欄位的方法是將同一張資料表映射為多個 subentity:
- 定義一個
@MappedSuperclass(如BaseAttachment)包含共用屬性 - Summary subentity(如
AttachmentSummary)只繼承基本屬性,不包含大型欄位 - 完整 entity(如
Attachment)繼承基本屬性並加上大型欄位(如content) - 兩者映射到同一張資料表,但查詢 subentity 時不會載入大型欄位
Subentity 與 DTO projection 在讀取資料時效果類似,但 subentity 可以追蹤狀態變更並同步到資料庫,這是 DTO projection 做不到的。

Figure 14.5: Attachment and AttachmentSummary entities
Entity Reference Deduplication#
使用 join fetch 載入 @OneToMany collection 時,SQL 結果集的大小由 child rows 決定,parent entity 資料會重複出現:
- Persistence Context 保證 application-level repeatable reads,所以
postslist 會包含指向同一物件的重複 reference - 使用 JPQL
distinct關鍵字可去除重複,但DISTINCT也會被傳遞到 SQL 層級,導致不必要的 HashAggregate 排序步驟 - Hibernate 5.2.2 引入
DISTINCT_PASS_THROUGHquery hint,設為false時:- 在 Hibernate 層級進行 entity 去重
- 不將
DISTINCT傳遞到 SQL 語句 - 避免資料庫端不必要的排序操作,提升查詢效能
Query Plan Cache#
- Hibernate 對 entity query(JPQL/HQL)和 native query 都提供 QueryPlanCache,將查詢字串解析為 Abstract Syntax Tree 並快取
- 透過
hibernate.query.plan_cache_max_size控制快取大小(預設 2048) - 對 native query 還有 ParameterMetadata cache,透過
hibernate.query.plan_parameter_metadata_max_size設定(預設 128) - 效能影響顯著:entity query 在 plan cache size 為 100 時平均 5.2 微秒,cache size 為 1 時則需 315.5 微秒(約 60 倍差距)

Figure 14.6: Entity query plan cache performance gain
- Native query 的 plan cache 效能增益較小(36.29 vs 16.09 微秒)

Figure 14.7: Native SQL query plan cache performance gain
對於 entity query(JPQL 和 Criteria API),
hibernate.query.plan_cache_max_size應設定為能容納所有被執行的查詢,以避免查詢重新編譯帶來的效能損失。