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 與分頁#

  • 透過 setFirstResultsetMaxResults 實現 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 時,可透過 ResultTransformerAliasToBeanResultTransformerResultSet 轉換為 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:

  1. First-level cache(Persistence Context)– 保證 application-level repeatable reads
  2. Second-level cache(若啟用)
  3. 資料庫(執行 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@OneToOneeager@OneToMany@ManyToManylazy
  • 透過 fetch 屬性可覆寫預設策略,結合 implicit 和 explicit 策略形成 default entity graph

Lazy association 可在查詢時透過 join fetch 改為 eager 載入,但 eager association 無法在查詢時改為 lazy。因此 FetchType.LAZYFetchType.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@ManyToMany association 使用 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 實作(如 PersistentBagPersistentListPersistentSet

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 開啟期間,使用 fetch JPQL 指令、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 載入(透過隱含的 @Basic annotation)
  • 可將個別 attribute 設為 @Basic(fetch = FetchType.LAZY),使其在首次存取時才載入
  • 需要 bytecode enhancement 支援:透過 Maven/Gradle plugin 啟用 enableLazyInitialization
  • 適用場景:儲存大型資料的欄位(如 BLOBCLOBVARBINARY
  • 注意: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,所以 posts list 會包含指向同一物件的重複 reference
  • 使用 JPQL distinct 關鍵字可去除重複,但 DISTINCT 也會被傳遞到 SQL 層級,導致不必要的 HashAggregate 排序步驟
  • Hibernate 5.2.2 引入 DISTINCT_PASS_THROUGH query 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 應設定為能容納所有被執行的查詢,以避免查詢重新編譯帶來的效能損失。