前面七章有一個共同的前提,而我一直到 ch04 才第一次點破它:
那些分析全部建立在「SQL 真的長你以為的那樣」之上。
在 ORM 專案裡,這個前提經常不成立。你寫的是 order.getItems(),資料庫收到的是 200 條 SELECT;你以為的分頁是 LIMIT 20,Hibernate 實際上把八萬列全撈進 JVM 再切。這些都不會出現在任何一份資料庫端的報表裡——
上一章那個「CPU 打滿但慢查詢日誌是空的」案例,最後就是停在這裡。
所以最後一章要回到 Java 這邊,處理三件事:
show-sql」在正式環境不夠用。@ManyToOne 預設 EAGER、Spring Boot 的 OSIV 預設開啟、MySQL JDBC 的 rewriteBatchedStatements 預設關閉。JOIN FETCH 配分頁:那句 HHH000104 警告背後,是一次記憶體分頁與潛在的 OOM。最後一節會把這一整軌的三條主線收攏起來。如果你只想記住這八章的三句話,那三句話在最後一張助記卡上。
前七章的每一個工具,都要求你手上先有一句 SQL。在 ORM 專案裡,拿到那句 SQL 本身就是第一道關卡。
spring:
jpa:
properties:
hibernate.format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE # 沒有這行就看不到參數值? 的 SQL 去 EXPLAIN,等於分析了一句線上根本沒跑過的查詢。另外不要用 spring.jpa.show-sql=true:它直接寫 System.out、不經過 logger、沒有時間戳、也無法分級關閉。
日誌只能告訴你 SQL 長什麼樣。真正需要的三個數字——一個請求發了幾句、各自多久、從哪一行程式發出——要靠攔截層:
datasource-proxy 或 p6spy:包住 DataSource,逐句記錄執行時間,並且可以按請求計數。hibernate.generate_statistics=true,可以取得一個 session 內的查詢次數、快取命中、實體載入數。// 整合測試裡直接把 N+1 擋下來——這是最划算的一道防線
@Test
void orderList_should_not_be_n_plus_1() {
QueryCountHolder.clear();
mockMvc.perform(get("/api/orders?page=0"));
assertThat(QueryCountHolder.getGrandTotal().getSelect()).isLessThanOrEqualTo(3);
}N+1 的定義很簡單:本來一句可以解決的事,變成 1 句主查詢 + N 句關聯查詢。但它的來源有三種,而且解法不完全一樣。
@Entity
public class Order {
@ManyToOne // ← 沒寫 fetch,預設是 EAGER!
private Member member;
@ManyToOne
private Merchant merchant;
}@ManyToOne 與 @OneToOne 的預設是 FetchType.EAGER;只有 @OneToMany/@ManyToMany 預設 LAZY。這個預設值害人無數:查 20 筆訂單、每筆帶兩個 ToOne 關聯,什麼都沒寫就是 40 句額外查詢。fetch = FetchType.LAZY,需要的時候再明確載入。List<Order> orders = repo.findByStatus(1); // 1 句
for (Order o : orders) {
dto.setMemberName(o.getMember().getName()); // 每一圈觸發一次 SELECT
}這是最常見的一種,而且在 DTO 轉換層特別容易發生——因為那段程式碼看起來只是在搬欄位,完全沒有「查詢」的樣子。ch07 那個 820 萬次的案例正是這種。
for (Order o : orders) {
total += o.getItems().size(); // 每一圈初始化一個集合 = 一句 SELECT
}更糟的是巢狀:訂單 → 品項 → 商品,就是 20 → 200 → 2000。
queryExecutionCount 與回傳筆數呈正比。@Query("SELECT o FROM Order o JOIN FETCH o.member JOIN FETCH o.merchant WHERE o.status = :s")
List<Order> findWithRefs(int s);
// 或用 EntityGraph,不必改 JPQL
@EntityGraph(attributePaths = {"member", "merchant"})
List<Order> findByStatus(int status);MultipleBagFetchException,或產生列數相乘的災難。@Entity
public class Order {
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 50) // 或全域:hibernate.default_batch_fetch_size: 50
private List<OrderItem> items;
}Hibernate 會把 N 次單筆查詢合併成 WHERE order_id IN (?, ?, ... ):200 句變成 4 句。
hibernate.default_batch_fetch_size 之後全域生效,即使某個沒被注意到的路徑觸發了 lazy 載入,也只會是 IN 查詢而不是 N 次往返。這是投入產出比最高的一個設定,而且大部分專案沒開。@Query("""
SELECT new com.shop.dto.OrderRow(o.id, o.amount, m.name, mc.name)
FROM Order o JOIN o.member m JOIN o.merchant mc
WHERE o.status = :s
""")
List<OrderRow> findRows(int s);SELECT * 三個代價)。| 場景 | 選它 | 理由 |
|---|---|---|
| 列表頁(只讀、只顯示) | DTO 投影 | 一句解決、欄位最少、不進 session |
| 要修改實體 | EntityGraph / JOIN FETCH | 需要受管理的實體才能 dirty checking |
| 集合關聯、或路徑不確定 | @BatchSize | 全域生效,把 N 次變成 N/size 次 |
| 要分頁又要集合 | 兩段式查詢(見下節) | JOIN FETCH 配分頁會在記憶體裡分頁 |
把 JOIN FETCH 和分頁放在一起,Hibernate 會照做,但會在日誌裡留下一行警告:
HHH000104: firstResult/maxResults specified with collection fetch;
applying in memoryLIMIT 20 會切到品項而不是訂單——分頁結果會是錯的。LIMIT:把符合條件的資料全部撈進 JVM,在記憶體裡去重、組裝,然後才切出那 20 筆。// 第一段:只查主表的 id,分頁在資料庫端完成(可以用 ch04 的 keyset)
@Query("SELECT o.id FROM Order o WHERE o.status = :s ORDER BY o.createdAt DESC")
List<Long> findIds(int s, Pageable page);
// 第二段:用 id 一次把集合抓齊,沒有分頁就不會觸發記憶體分頁
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findWithItems(List<Long> ids);或者更簡單:第一段照常分頁,集合關聯交給 @BatchSize——效果幾乎一樣,而且不必寫兩個方法。
ch04 提過:Page 會多發一句 COUNT(*)。在 ORM 專案裡它還有一個額外的代價——那句 count 會把 JOIN FETCH 一起帶進去(Hibernate 通常會移除 fetch,但複雜查詢下不一定),變成一句昂貴又無用的統計。不需要總頁數時一律用 Slice。
Open Session In View(spring.jpa.open-in-view)預設是 true。Spring Boot 啟動時甚至會印一行 WARN 提醒你,但幾乎沒有人去讀它。
它讓 Hibernate 的 session 一直開到 HTTP 回應寫完為止。好處很直接:controller 或序列化階段存取 lazy 關聯時不會拋 LazyInitializationException——它讓開發變順,代價藏在後面。
沒有 OSIV 的話,在 controller 碰 lazy 關聯會直接拋例外——問題在開發階段就暴露了。有了 OSIV,它不會拋例外,只會安靜地多發幾百句 SQL。ch07 那個案例能活到正式環境,OSIV 是共犯。
spring:
jpa:
open-in-view: falseLazyInitializationException。寫入端有兩個預設值會讓「明明設定了批次」的努力白費。
spring:
jpa:
properties:
hibernate.jdbc.batch_size: 500
hibernate.order_inserts: true # 把同型別的 INSERT 排在一起才批得起來
hibernate.order_updates: truerewriteBatchedStatements=false。在這個預設值下,JDBC batch 只是「少幾次 API 呼叫」,送到網路上的仍然是 500 條獨立的 INSERT。INSERT INTO t VALUES (...), (...), (...):jdbc:mysql://host:3306/db?rewriteBatchedStatements=true差距通常是五到十倍,而它只是連線字串上的一個參數。@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // MySQL 自增
private Long id;推導一次為什麼:Hibernate 必須在 persist() 當下就拿到主鍵值(實體要進 persistence context 就得有 id),而自增值只有在 INSERT 真的執行之後才拿得到。於是它只能一句一句送,批次完全失效。
TABLE 策略有自己的競爭問題)。JdbcTemplate.batchUpdate() 或原生的多值 INSERT。這不是妥協,是選對工具(見下一節)。Hibernate 在 flush 時會逐一比對 session 裡每個實體的每個欄位與載入時的快照。一次載入五萬個實體,即使只改了一個,flush 也要比對五萬次。
@Transactional(readOnly = true) // 讓 Hibernate 跳過 dirty checking
public List<OrderRow> list() { ... }唯讀查詢一律加 readOnly = true:省下快照與比對成本,而且它同時是「這個方法不會寫資料」的宣告(Spring 也可以據此路由到唯讀複本)。DTO 投影則根本不進 persistence context,連快照都不會建。
這是 ch07 那場故障的另一半——同一件事,從應用端看。
先量最基本的數字:一個請求發了幾句 SQL。
// datasource-proxy 的計數結果
GET /api/orders?page=0 → SELECT: 213, total time: 640ms213 句。拆解來源:
@ManyToOne Member 與 @ManyToOne Merchant 都沒寫 fetch,預設 EAGER → 40 句。order.getItems() → 20 句。item.getProduct()(平均 8 個品項)→ 約 152 句。hibernate.default_batch_fetch_size: 50。213 句 → 約 8 句,一行設定、不改任何程式碼。這是止血的最佳投報比。@ManyToOne 改成 fetch = LAZY,列表查詢改用 @EntityGraph 明確指定要載入什麼。把 ORM 用在它不擅長的地方,是這一章大部分問題的共同起點。知道邊界在哪,比熟練任何技巧都有用。
| 場景 | 問題 | 換成 |
|---|---|---|
| 報表與複雜聚合 | JPQL 表達力不足,寫得出來也難以優化 | 原生 SQL、jOOQ、MyBatis |
| 大量寫入/匯入 | IDENTITY 停用批次、dirty checking 成本 | JdbcTemplate.batchUpdate + rewriteBatchedStatements |
| 只讀列表頁 | 載入整個實體圖太貴 | DTO 投影(仍可用 JPA) |
| 需要 hint/特殊語法 | JPQL 不支援(ch02 的 optimizer hint) | native query,但注意型別(ch01 的失敗場景) |
| 批次更新/刪除 | 逐一載入再刪=N+1 的寫入版 | 一句 UPDATE/DELETE,但記得清 persistence context |
@Modifying 的批次 UPDATE 之後,session 裡的實體還是舊值——要加 clearAutomatically = true,或明確 flush/clear。@Transactional 裡會共用同一條連線與同一個交易(前提是用同一個 DataSource),這點是安全的。要不要用 ORM 處理某段邏輯,問這個問題就好:
這一軌從 EXPLAIN 一路走到 ORM。表面上是八個不同的主題,但它們一直在講同樣的三件事。
優化器看的是估算,你該看的是實測。
rows/filtered 是估算,EXPLAIN ANALYZE 才是實測。每一個加速手段,都在別的地方付錢。沒有例外,只有「你知不知道帳單寄到哪裡」。
照「症狀 → 計畫 → 實測 → 根因」的順序走,不要一上來就調旋鈕。
NOT IN 遇到 NULL 安靜地回傳空集合;ch04 的 GROUP BY 在 8.0 之後不再隱式排序;ch06 的 collation 變更會讓比較結果改變。| 來源 | 怎麼發生 | 第一順位解法 | 備註 |
|---|---|---|---|
| @ManyToOne 預設 EAGER | 沒寫 fetch,JPA 規範預設就是 EAGER | 一律明確寫 fetch = LAZY | 只有 ToMany 預設 LAZY,這個預設值害人無數 |
| 迴圈裡碰 lazy 關聯 | DTO 轉換時逐筆 getMember() | EntityGraph 明確載入,或 DTO 投影 | 最常見;程式碼看起來只是在搬欄位 |
| 集合逐一初始化 | 迴圈裡 getItems(),巢狀時 20→200→2000 | @BatchSize / default_batch_fetch_size | 一行全域設定就能救,投報比最高 |
| 設定 | 預設 | 代價 | 建議 |
|---|---|---|---|
| @ManyToOne fetch | EAGER | 每筆主資料多發 1 句/關聯 | 一律改 LAZY,需要時明確載入 |
| spring.jpa.open-in-view | true | 連線佔用 = 整個請求時間;還會把 N+1 藏起來 | 關掉;新專案一開始就關 |
| rewriteBatchedStatements | false | JDBC batch 仍送出 N 條獨立 INSERT | JDBC URL 加上,通常快 5~10 倍 |
| hibernate.default_batch_fetch_size | 未設定 | lazy 載入一律逐筆往返 | 設 50;大部分專案沒開 |
| @Transactional 的 readOnly | false | 唯讀查詢也付 dirty checking 與快照成本 | 唯讀方法一律加 readOnly = true |
| 場景 | 需要什麼 | 用什麼 | 注意 |
|---|---|---|---|
| 載入聚合根、改欄位、存回 | 物件 | JPA 實體 | ORM 的主場 |
| 只讀列表頁 | 資料 | DTO 投影 | 一句 SQL、欄位最少、不進 persistence context |
| 報表與複雜聚合 | 資料 | 原生 SQL / jOOQ / MyBatis | JPQL 表達力不足 |
| 大量寫入/匯入 | 資料 | JdbcTemplate.batchUpdate | IDENTITY 主鍵會停用 JPA 批次 |
| 批次 UPDATE/DELETE | 資料 | @Modifying 的一句 SQL | 記得 clearAutomatically,session 裡還是舊值 |
點擊卡片翻面查看答案,共 13 張。