前言
在用 Spring Boot + JPA/Hibernate 開發時,我們常常會反覆查詢同一批「幾乎不會變」的資料,例如國家清單、商品分類、系統設定。這時候一個很自然的想法是:能不能把它們快取起來,不要每次都打資料庫?
答案其實是,可以。Hibernate 其實內建了兩層快取機制:一級快取與二級快取。一級快取是預設就開啟、我們平常不太會意識到它存在;而二級快取則需要額外設定,而且如今討論的人相對少。這篇文章會帶你搞懂:
- 二級快取是什麼?它跟一級快取差在哪?
- 怎麼實際使用二級快取(帶完整範例)
- 什麼時候該用、什麼時候不該用?為什麼現在很少聽到它?
二級快取
二級快取是什麼?與一級快取之間的差異?
要理解二級快取,得先從一級快取講起。
一級快取(First-Level Cache)
一級快取的作用範圍是 Hibernate Session(在 JPA 中就是 EntityManager 的 Persistence Context),生命週期通常等同於「一個交易(transaction)」。
它的行為是:在同一個 Session 中,用同一個 id 查詢同一個 entity,第二次之後不會再打資料庫,而是直接從 Session 內的快取拿。
@Transactional
public void demo() {
// 第一次查詢:發出 SELECT,打到資料庫
User u1 = entityManager.find(User.class, 1L);
// 第二次查詢:同 Session、同 id 不會發 SQL,直接回傳快取
User u2 = entityManager.find(User.class, 1L);
System.out.println(u1 == u2); // true,兩者是同一個實例
}
一級快取有兩個關鍵特性:
- 預設開啟,而且無法關閉,它是 Hibernate 保證「同一交易內物件一致性」的基礎。
- 範圍很小,Session 一結束(交易結束),快取就跟著消失。換個 Session、換個請求,一切歸零,還是得重新查資料庫。
補充:如何繞過一級快取、強制重新查詢?
有時候我們會有「不要拿快取、直接重新打資料庫」的需求(例如懷疑資料已被別人改動)。以下是四種常見做法:
方法一:entityManager.refresh(entity) — 針對單一 entity 重新發 SELECT,並用資料庫的最新值覆蓋它。
User user = entityManager.find(User.class, 1L);
// 別人可能修改了資料
entityManager.refresh(user); // 重新發 SELECT,覆蓋當前狀態
方法二:entityManager.clear() — 把整個 Persistence Context 清空,之後任何查詢都會重新打資料庫。
User u1 = entityManager.find(User.class, 1L);
entityManager.clear(); // 清空整個 Persistence Context
User u2 = entityManager.find(User.class, 1L); // 重新發 SELECT
方法三:entityManager.detach(entity) — 只從 Persistence Context 移除單一 entity,範圍比 clear() 精準。
User u1 = entityManager.find(User.class, 1L);
entityManager.detach(u1); // 只移除這一筆
User u2 = entityManager.find(User.class, 1L); // 重新發 SELECT
方法四:開一個新的 Session — 一級快取隨 Session 而生,換到新的 Session(新交易)自然就是全新的空快取,一切從資料庫重新查起。
二級快取(Second-Level Cache)
二級快取的作用範圍是 SessionFactory(整個應用程式共用),生命週期跨越多個 Session、多個交易,甚至多個請求。
也就是說,A 使用者的請求把某筆 User 讀進二級快取後,B 使用者的請求(不同 Session)再查同一筆資料時,也能直接命中快取、不打資料庫。這正是一級快取做不到的事。
但它不是免費的:
- 預設是關閉的,必須額外引入快取供應商(EhCache、Caffeine、Infinispan、Hazelcast…)並手動設定才能啟用。
- 需要在 entity 上標註
@Cacheable並指定 concurrency 策略,明確告訴 Hibernate「這個 entity 才要快取」。
兩者差異整理
| 比較項目 | 一級快取 | 二級快取 |
|---|---|---|
| 作用範圍 | Session(單一交易) | SessionFactory(整個應用共用) |
| 生命週期 | 交易結束即消失 | 跨交易、跨請求存在 |
| 是否預設開啟 | 是,且無法關閉 | 否,需額外設定 |
| 是否跨 Session 共享 | 否 | 是 |
| 需要供應商 | 不需要(內建) | 需要(EhCache、Caffeine…) |
| 典型用途 | 保證單一交易內物件一致性 | 快取「讀多寫少」的共用資料 |
一句話總結:一級快取管的是「同一個交易內不要重複查」,二級快取管的是「不同交易之間也不要重複查」。
如何使用二級快取 (帶範例)
以下用 EhCache(透過 JCache 標準介接)示範完整流程。假設專案已經有 Spring Boot + JPA。
步驟一:加入相依套件
Hibernate 官方建議透過 JCache(JSR-107)標準介接快取供應商,這樣之後要換供應商比較容易。以 Maven 為例:
<!-- Hibernate 的 JCache 橋接 -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<!-- 實際的快取供應商:EhCache 3 -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<classifier>jakarta</classifier>
</dependency>
步驟二:開啟二級快取設定
在 application.properties 中啟用:
# 開啟二級快取
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
# 使用 JCache 作為 region factory
spring.jpa.properties.hibernate.cache.region.factory_class=jcache
# (可選)開啟查詢快取,快取查詢結果的 id 清單
spring.jpa.properties.hibernate.cache.use_query_cache=true
# (建議)觀察快取命中狀況的統計資訊
spring.jpa.properties.hibernate.generate_statistics=true
步驟三:在 Entity 上標註 @Cacheable
只有標註的 entity 才會被放進二級快取。這裡的重點是 @Cache 的 concurrency 策略:
import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cacheable // JPA 標準註解,標記此 entity 可被快取
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate 指定並發策略
public class Product {
@Id
private Long id;
private String name;
private BigDecimal price;
// getters / setters ...
}
concurrency 策略的選擇很關鍵,常見有四種:
| 策略 | 適用情境 |
|---|---|
READ_ONLY | 唯讀(例如國家代碼),效能最好 |
NONSTRICT_READ_WRITE | 偶爾更新、可容忍短暫的不一致 |
READ_WRITE | 需要更新,透過軟鎖(soft lock)維持一致性,最常用 |
TRANSACTIONAL | 需搭配 JTA 交易管理,用於完整交易隔離的場景 |
步驟四:驗證是否命中
寫一段程式跨 Session 查詢,並觀察 SQL log:
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional(readOnly = true)
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
}
分別在兩個不同的請求(不同交易)呼叫 getProduct(1L):
- 第一次:console 印出
select ... from product where id = ?,資料被放進二級快取。 - 第二次:不會再印出 SQL,直接從二級快取取得。
如果開了 generate_statistics,也可以透過 SessionFactory 的 Statistics 觀察 SecondLevelCacheHitCount(命中次數)來確認快取真的生效。
小提醒:二級快取存的是 entity 的「拆解狀態」(id 對應各欄位的值),而不是整個 Java 物件。每次命中時 Hibernate 會重新組裝成新的 entity 實例,所以跨 Session 拿到的不是同一個物件,這點跟一級快取不同。
什麼時候該用?什麼時候不該用?為甚麼很少聽到它
適合使用的情境
二級快取的理想是 「讀多寫少、且允許共用」的資料:
- 參考型資料(reference data):國家、幣別、商品分類、權限清單這類幾乎不變的資料。
- 高頻讀取、低頻更新:讀取遠多於寫入,快取命中率高,效益才明顯。
- 單一系統獨佔資料庫:只有這個服務在寫這張表,快取才不容易失準。
不適合使用的情境
- 頻繁更新的資料:每次寫入都要讓快取失效(invalidate),維護成本可能比省下的查詢還高,甚至更慢。
- 多個服務/多個節點共寫同一個資料庫:其他服務直接改了資料庫,Hibernate 的二級快取並不知情,就會拿到過期的髒資料。這是二級快取最惡名昭彰的坑。
- 叢集部署(多台機器):每個節點各有一份本地快取,彼此不同步,得再引入分散式快取(Infinispan、Hazelcast)做同步,複雜度直線上升。
為什麼現在很少聽到它?
其實不是它沒用,而是時代與架構變了,它的定位被其他方案取代了:
-
微服務 + 分散式架構成為主流。 二級快取假設「資料庫由我獨佔、我能掌握所有寫入」,但微服務世界裡資料常被多個服務共享或透過事件變更,一個instance不會自動evict其他instance上面的Hibernate Cache,一致性問題讓人卻步。
-
大家改用「應用層快取」,而且更喜歡顯式控制。 現在更常見的是 Spring 的
@Cacheable快取抽象搭配 Redis。它跟 ORM 解耦、可跨服務共享、失效策略(TTL、主動 evict)能自己掌控,語意也更清楚——你很清楚「哪段邏輯的結果被快取了」,而不是藏在 ORM 底層。雖然變成依賴外部服務控制,但是這樣的做法變成主流。
// 現在更常見的做法:Spring Cache 抽象 + Redis,明確、可控
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
-
隱式快取難以除錯。 二級快取藏在 Hibernate 內部,出現髒資料或效能問題時,往往很難追查是不是快取在搞鬼;相比之下,顯式的快取層更透明、更好維運。
-
「先量測再優化」的觀念普及。 很多效能問題其實來自 N+1 查詢、缺索引,這些用
JOIN FETCH、加索引就能解決,不一定要動用二級快取這種重機制。
小結
這篇我們把 Hibernate 的二級快取從頭理了一遍:
- ✅ 一級快取 作用於單一 Session/交易,預設開啟、無法關閉,保證同交易內物件一致。
- ✅ 二級快取 作用於整個 SessionFactory,跨交易、跨請求共享,但需要額外供應商與設定。
- ✅ 使用四步驟:加相依 → 開設定 → 標
@Cacheable並選 concurrency 策略 → 驗證命中。 - ✅ 它適合「讀多寫少、單一服務獨佔」的參考型資料;碰到高頻更新、多服務共寫、叢集部署就要小心髒資料。
- ✅ 之所以少被提起,是因為微服務與分散式架構讓它的前提難以成立,大家轉而使用 Redis + Spring Cache 抽象 這種更顯式、更可控的方案。
二級快取的精髓在於:它是一個「假設你所有資料的變更都會經過 Hibernate」的快取。 一旦這個假設成立,它幾乎零侵入就能省下大量查詢;一旦假設不成立,它帶來的髒資料風險會超過它的好處。理解這個前提,你就會知道它為什麼曾經流行,又為什麼慢慢淡出主流。
當然,分散式架構下也不是所有資料都適合放進 Redis。快取工具只是手段,真正需要設計的是資料一致性模型、快取失效策略(Cache Invalidation)、資料生命週期以及更新流程。而不是單純因為系統採用微服務,就一律以 Redis 取代 Hibernate 二級快取。
是否適合使用二級快取,最終仍取決於業務對資料一致性的要求。 如果你的系統能接受短時間的舊資料,且有適當的快取失效策略(例如 TTL 或事件通知),那麼二級快取帶來的效益可能會大於它的風險。 反之,若每次讀取都必須取得最新資料,就應該謹慎評估甚至避免使用。