跳至內容
返回

Spring Boot 二級快取是什麼?

發佈於:  at  08:00 上午

前言

在用 Spring Boot + JPA/Hibernate 開發時,我們常常會反覆查詢同一批「幾乎不會變」的資料,例如國家清單、商品分類、系統設定。這時候一個很自然的想法是:能不能把它們快取起來,不要每次都打資料庫?

答案其實是,可以。Hibernate 其實內建了兩層快取機制:一級快取二級快取。一級快取是預設就開啟、我們平常不太會意識到它存在;而二級快取則需要額外設定,而且如今討論的人相對少。這篇文章會帶你搞懂:

  1. 二級快取是什麼?它跟一級快取差在哪?
  2. 怎麼實際使用二級快取(帶完整範例)
  3. 什麼時候該用、什麼時候不該用?為什麼現在很少聽到它?

二級快取

二級快取是什麼?與一級快取之間的差異?

要理解二級快取,得先從一級快取講起。

一級快取(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,兩者是同一個實例
}

一級快取有兩個關鍵特性:

補充:如何繞過一級快取、強制重新查詢?

有時候我們會有「不要拿快取、直接重新打資料庫」的需求(例如懷疑資料已被別人改動)。以下是四種常見做法:

方法一: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)再查同一筆資料時,也能直接命中快取、不打資料庫。這正是一級快取做不到的事。

但它不是免費的:

兩者差異整理

比較項目一級快取二級快取
作用範圍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 才會被放進二級快取。這裡的重點是 @Cacheconcurrency 策略

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)

如果開了 generate_statistics,也可以透過 SessionFactoryStatistics 觀察 SecondLevelCacheHitCount(命中次數)來確認快取真的生效。

小提醒:二級快取存的是 entity 的「拆解狀態」(id 對應各欄位的值),而不是整個 Java 物件。每次命中時 Hibernate 會重新組裝成新的 entity 實例,所以跨 Session 拿到的不是同一個物件,這點跟一級快取不同。

什麼時候該用?什麼時候不該用?為甚麼很少聽到它

適合使用的情境

二級快取的理想是 「讀多寫少、且允許共用」的資料

不適合使用的情境

為什麼現在很少聽到它?

其實不是它沒用,而是時代與架構變了,它的定位被其他方案取代了:

  1. 微服務 + 分散式架構成為主流。 二級快取假設「資料庫由我獨佔、我能掌握所有寫入」,但微服務世界裡資料常被多個服務共享或透過事件變更,一個instance不會自動evict其他instance上面的Hibernate Cache,一致性問題讓人卻步。

  2. 大家改用「應用層快取」,而且更喜歡顯式控制。 現在更常見的是 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();
}
  1. 隱式快取難以除錯。 二級快取藏在 Hibernate 內部,出現髒資料或效能問題時,往往很難追查是不是快取在搞鬼;相比之下,顯式的快取層更透明、更好維運。

  2. 「先量測再優化」的觀念普及。 很多效能問題其實來自 N+1 查詢、缺索引,這些用 JOIN FETCH、加索引就能解決,不一定要動用二級快取這種重機制。

小結

這篇我們把 Hibernate 的二級快取從頭理了一遍:

二級快取的精髓在於:它是一個「假設你所有資料的變更都會經過 Hibernate」的快取。 一旦這個假設成立,它幾乎零侵入就能省下大量查詢;一旦假設不成立,它帶來的髒資料風險會超過它的好處。理解這個前提,你就會知道它為什麼曾經流行,又為什麼慢慢淡出主流。

當然,分散式架構下也不是所有資料都適合放進 Redis。快取工具只是手段,真正需要設計的是資料一致性模型、快取失效策略(Cache Invalidation)、資料生命週期以及更新流程。而不是單純因為系統採用微服務,就一律以 Redis 取代 Hibernate 二級快取。

是否適合使用二級快取,最終仍取決於業務對資料一致性的要求。 如果你的系統能接受短時間的舊資料,且有適當的快取失效策略(例如 TTL 或事件通知),那麼二級快取帶來的效益可能會大於它的風險。 反之,若每次讀取都必須取得最新資料,就應該謹慎評估甚至避免使用。


建議修改
在以下平台分享此文章:

上一篇
Buffer Pool 與 MySQL Query Cache
下一篇
React 入門(1):環境建置、JSX 與第一個 Component