コンテンツへスキップ
戻る

Hibernate の二次キャッシュとは?

公開日:  at  08:00 午前

はじめに

Spring Boot + JPA/Hibernate で開発していると、「ほとんど変わらない」同じデータ群を何度も繰り返しクエリすることがよくあります。たとえば国リスト、商品カテゴリ、システム設定などです。ここで自然に浮かぶのが、「毎回データベースを叩かず、キャッシュしておけないか?」という発想です。

答えは、できます。実は Hibernate には二層のキャッシュ機構が組み込まれています。一次キャッシュ二次キャッシュです。一次キャッシュはデフォルトで有効になっていて、普段その存在を意識することはほとんどありません。一方で二次キャッシュは追加の設定が必要で、しかも最近は語られることが比較的少なくなっています。この記事では次のことを理解していきます。

  1. 二次キャッシュとは何か?一次キャッシュとの違いは?
  2. 二次キャッシュを実際にどう使うのか(完全なサンプル付き)
  3. どんなときに使うべきで、どんなときに使うべきでないか?そしてなぜ今はあまり耳にしないのか?

二次キャッシュ

二次キャッシュとは?一次キャッシュとの違いは?

二次キャッシュを理解するには、まず一次キャッシュから話す必要があります。

一次キャッシュ(First-Level Cache)

一次キャッシュのスコープは Hibernate Session(JPA でいう EntityManager の Persistence Context)です。ライフサイクルは通常「一つのトランザクション」と同じです。

その挙動はこうです。同じ Session の中では、同じ id で同じ entity をクエリしても、2 回目以降はデータベースを叩かず、Session 内のキャッシュから直接返します。

@Transactional
public void demo() {
    // 1 回目のクエリ:SELECT を発行し、データベースを叩く
    User u1 = entityManager.find(User.class, 1L);

    // 2 回目のクエリ:同じ Session・同じ id なので SQL は発行されず、キャッシュから返る
    User u2 = entityManager.find(User.class, 1L);

    System.out.println(u1 == u2); // true — まったく同じインスタンス
}

一次キャッシュには 2 つの重要な特徴があります。

補足:一次キャッシュを回避して強制的に再クエリするには?

ときには「キャッシュを使わず、直接データベースを叩き直したい」という要求があります(たとえば他の誰かにデータを変更された疑いがあるとき)。以下は 4 つの一般的な方法です。

方法 1:entityManager.refresh(entity) — 単一の entity について SELECT を発行し直し、データベースの最新値で上書きします。

User user = entityManager.find(User.class, 1L);
// 他の誰かがデータを変更したかもしれない
entityManager.refresh(user); // SELECT を発行し直し、現在の状態を上書き

方法 2:entityManager.clear() — Persistence Context 全体を空にするので、以降のクエリはすべてデータベースを叩き直します。

User u1 = entityManager.find(User.class, 1L);
entityManager.clear(); // Persistence Context 全体をクリア
User u2 = entityManager.find(User.class, 1L); // SELECT を発行し直す

方法 3:entityManager.detach(entity) — Persistence Context から単一の entity だけを取り除きます。clear() よりスコープが正確です。

User u1 = entityManager.find(User.class, 1L);
entityManager.detach(u1); // この 1 件だけを取り除く
User u2 = entityManager.find(User.class, 1L); // SELECT を発行し直す

方法 4:新しい Session を開く — 一次キャッシュは Session とともに生まれて消えます。新しい Session(新しいトランザクション)に切り替えれば、自然と真新しい空のキャッシュになり、すべてデータベースから改めてクエリされます。

二次キャッシュ(Second-Level Cache)

二次キャッシュのスコープは SessionFactory(アプリケーション全体で共有)です。ライフサイクルは複数の Session、複数のトランザクション、さらには複数のリクエストにまたがります。

つまり、ユーザー A のリクエストがある User を二次キャッシュに読み込んだあと、ユーザー B のリクエスト(別の Session)が同じレコードをクエリしても、データベースを叩かずにそのままキャッシュにヒットできるのです。これはまさに一次キャッシュにはできないことです。

ただし、タダではありません。

両者の違いのまとめ

比較項目一次キャッシュ二次キャッシュ
スコープSession(単一トランザクション)SessionFactory(アプリ全体で共有)
ライフサイクルトランザクション終了で消えるトランザクション・リクエストをまたぐ
デフォルトで有効かはい、かつ無効化不可いいえ、追加設定が必要
Session 間で共有かいいえはい
プロバイダーが必要か不要(組み込み)必要(EhCache、Caffeine…)
典型的な用途単一トランザクション内の一貫性「読み多く書き少ない」共有データの保持

一言でいえば、一次キャッシュは「同じトランザクション内で二度クエリしない」ことを、二次キャッシュは「異なるトランザクション間でも二度クエリしない」ことを担っています。

二次キャッシュの使い方(サンプル付き)

以下では EhCache(JCache 標準経由で連携)を使って、一連の流れを示します。プロジェクトにはすでに Spring Boot + JPA があるものとします。

ステップ 1:依存関係を追加する

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>

ステップ 2:二次キャッシュの設定を有効にする

application.properties で有効化します。

# 二次キャッシュを有効化
spring.jpa.properties.hibernate.cache.use_second_level_cache=true

# region factory として JCache を使用
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

ステップ 3:Entity に @Cacheable を付ける

付与された entity だけが二次キャッシュに入ります。ここでのポイントは @Cacheconcurrency 戦略です。

import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cacheable // この entity がキャッシュ可能であることを示す JPA 標準アノテーション
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate で concurrency 戦略を指定
public class Product {

    @Id
    private Long id;

    private String name;

    private BigDecimal price;

    // getters / setters ...
}

concurrency 戦略の選択はきわめて重要です。よく使う 4 つを挙げます。

戦略適した場面
READ_ONLY読み取り専用(例:国コード)。パフォーマンスが最も良い
NONSTRICT_READ_WRITEまれに更新され、短時間の不整合を許容できる
READ_WRITE更新が必要。ソフトロックで一貫性を保つ。最もよく使われる
TRANSACTIONALJTA トランザクション管理が必要。完全なトランザクション隔離の場面

ステップ 4:ヒットしているか検証する

Session をまたいでクエリするコードを書き、SQL ログを観察します。

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional(readOnly = true)
    public Product getProduct(Long id) {
        return productRepository.findById(id).orElseThrow();
    }
}

2 つの異なるリクエスト(異なるトランザクション)から getProduct(1L) を呼び出します。

generate_statistics を有効にしていれば、SessionFactoryStatistics から SecondLevelCacheHitCount(ヒット回数)を観察することで、キャッシュが本当に効いているか確認できます。

補足:二次キャッシュが保持するのは entity の「分解された状態」(id と各フィールド値の対応)であって、Java オブジェクト丸ごとではありません。ヒットのたびに Hibernate は新しい entity インスタンスへ再構築するので、Session をまたいで取得したものは同一のオブジェクトではありません。ここが一次キャッシュとの違いです。

どんなときに使うべきか?使うべきでないか?なぜあまり耳にしないのか?

向いている場面

二次キャッシュが理想的なのは、「読み多く書き少なく、かつ共有してよい」データです。

向いていない場面

なぜ今はあまり耳にしないのか?

役に立たないわけではなく、時代とアーキテクチャが変わり、その役割が別のソリューションに取って代わられたのです。

  1. マイクロサービス + 分散アーキテクチャが主流になった。 二次キャッシュは「データベースは自分が専有し、すべての書き込みを掌握している」ことを前提とします。しかしマイクロサービスの世界では、データは複数のサービスで共有されたりイベント経由で変更されたりします。あるインスタンスが、他のインスタンス上の Hibernate キャッシュを自動的に evict することはなく、一貫性の問題に人々は二の足を踏みます。

  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)、データのライフサイクル、そして更新フローです。システムがマイクロサービスを採用しているというだけで、一律に Hibernate の二次キャッシュを Redis で置き換えるべきではありません。

二次キャッシュが適しているかどうかは、最終的にはビジネスがデータ一貫性にどれだけ厳しいかによって決まります。 システムが短時間の古いデータを許容でき、かつ適切な無効化戦略(たとえば TTL やイベント通知)を備えているなら、二次キャッシュがもたらす効果はリスクを上回るかもしれません。 逆に、読み取りのたびに必ず最新のデータを得なければならないなら、慎重に評価すべきであり、使用を避けることさえ検討すべきです。


修正を提案する
この記事をシェアする:

前の記事
Buffer Pool と MySQL の Query Cache
次の記事
React 入門(1):環境構築、JSX、そして最初のコンポーネント