Spring JPAアプリケーションにおいて、データベースアクセスの最適化はパフォーマンス向上の鍵となります。Hibernate 2次キャッシュ(Second Level Cache)は、SessionFactory(EntityManagerFactory)レベルでエンティティをキャッシュし、複数のセッション間でデータを共有することで、データベースへのアクセス回数を大幅に削減できる強力な仕組みです。本記事では、Spring Boot環境でのHibernate 2次キャッシュの設定方法、キャッシュプロバイダーの選定基準、CacheConcurrencyStrategyの選び方、クエリキャッシュの活用法、そしてキャッシュ無効化のベストプラクティスを体系的に解説します。
実行環境と前提条件
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 | バージョン・要件 |
|---|---|
| Java | 17以上 |
| Spring Boot | 3.4.x |
| Spring Data JPA | 3.4.x(Spring Boot Starterに含まれる) |
| Hibernate | 6.6.x(Spring Data JPAに含まれる) |
| キャッシュプロバイダー | Ehcache 3.x / Caffeine / Redis |
| ビルドツール | Maven または Gradle |
| IDE | VS Code または IntelliJ IDEA |
事前に以下の準備を完了してください。
- JDK 17以上のインストール
- Spring Boot + Spring Data JPAプロジェクトの基本構成
- Maven または Gradle によるプロジェクトビルド環境
1次キャッシュと2次キャッシュの違い
Hibernateには2種類のキャッシュ機構があります。それぞれの特性を理解することで、適切なキャッシュ戦略を選択できます。
flowchart TB
subgraph App["アプリケーション"]
Session1["Session A<br/>(トランザクション1)"]
Session2["Session B<br/>(トランザクション2)"]
Session3["Session C<br/>(トランザクション3)"]
end
subgraph L1["1次キャッシュ"]
L1_1["Session A の<br/>永続化コンテキスト"]
L1_2["Session B の<br/>永続化コンテキスト"]
L1_3["Session C の<br/>永続化コンテキスト"]
end
subgraph L2["2次キャッシュ(SessionFactory共有)"]
EntityCache["エンティティキャッシュ"]
CollectionCache["コレクションキャッシュ"]
QueryCache["クエリキャッシュ"]
end
Database[(データベース)]
Session1 --> L1_1
Session2 --> L1_2
Session3 --> L1_3
L1_1 --> L2
L1_2 --> L2
L1_3 --> L2
L2 --> Database1次キャッシュの特徴
1次キャッシュは永続化コンテキスト(Persistence Context)そのものであり、以下の特徴を持ちます。
| 特性 | 説明 |
|---|---|
| スコープ | Session(EntityManager)単位 |
| ライフサイクル | トランザクション開始から終了まで |
| 共有範囲 | 同一セッション内のみ |
| 有効化 | デフォルトで有効(無効化不可) |
| 設定 | 不要 |
1次キャッシュは、同一トランザクション内で同じエンティティを複数回取得する際にデータベースアクセスを省略します。
|
|
2次キャッシュの特徴
2次キャッシュはSessionFactory(EntityManagerFactory)レベルで動作し、以下の特徴を持ちます。
| 特性 | 説明 |
|---|---|
| スコープ | SessionFactory(EntityManagerFactory)単位 |
| ライフサイクル | アプリケーションの起動から終了まで |
| 共有範囲 | すべてのSession(トランザクション)間で共有 |
| 有効化 | 明示的な設定が必要 |
| 設定 | キャッシュプロバイダーとエンティティ単位の設定が必要 |
2次キャッシュを使用することで、異なるトランザクション間でもキャッシュされたエンティティを再利用できます。
|
|
1次キャッシュと2次キャッシュの比較
| 項目 | 1次キャッシュ | 2次キャッシュ |
|---|---|---|
| 保存形式 | Javaオブジェクト | 脱水化(dehydrated)された状態 |
| メモリ効率 | 低い(オブジェクト全体を保持) | 高い(正規化された属性値を保持) |
| クラスタ対応 | 不可 | プロバイダーにより可能 |
| 設定の複雑さ | なし | 中〜高 |
| 一貫性保証 | トランザクション内で完全 | 同時実行戦略に依存 |
2次キャッシュの有効化
Spring Boot環境で2次キャッシュを有効化するには、依存関係の追加、設定ファイルの編集、エンティティへのアノテーション付与が必要です。
依存関係の追加
JCache(JSR-107)を使用したEhcache 3の設定例です。
|
|
Gradleを使用する場合は以下のように記述します。
|
|
application.ymlの設定
|
|
Ehcache設定ファイル
|
|
@Cacheableと@Cacheアノテーションの使い方
2次キャッシュを使用するエンティティには、JPA標準の@CacheableアノテーションとHibernate固有の@Cacheアノテーションを組み合わせて設定します。
基本的なエンティティキャッシュ設定
|
|
コレクションキャッシュの設定
エンティティに関連するコレクションもキャッシュできます。ただし、コレクションキャッシュには関連エンティティのIDのみが格納されるため、関連エンティティ自体もキャッシュ対象にする必要があります。
|
|
キャッシュリージョンのカスタマイズ
デフォルトでは、エンティティの完全修飾クラス名がキャッシュリージョン名として使用されますが、region属性でカスタマイズできます。
|
|
キャッシュプロバイダーの選定ポイント
Hibernateは複数のキャッシュプロバイダーをサポートしています。アプリケーションの要件に応じて適切なプロバイダーを選択することが重要です。
主要キャッシュプロバイダーの比較
| プロバイダー | 特徴 | 適用シナリオ | クラスタ対応 |
|---|---|---|---|
| Ehcache 3 | 高機能、ディスク永続化対応 | 一般的なWebアプリケーション | 部分的(Terracotta経由) |
| Caffeine | 高速、シンプル、軽量 | 単一インスタンス、高スループット | 非対応 |
| Infinispan | 分散キャッシュ、JBoss統合 | マイクロサービス、クラスタ環境 | 完全対応 |
| Redis | 外部キャッシュサーバー | 大規模分散システム | 完全対応 |
Ehcache 3
Ehcache 3はJCache(JSR-107)標準に準拠した成熟したキャッシュライブラリです。
メリット
- ヒープ、オフヒープ、ディスクの3層キャッシュをサポート
- 豊富な設定オプションと監視機能
- Jakarta EE環境との高い互換性
デメリット
- クラスタ環境では追加の設定が必要
- 他のプロバイダーと比較してメモリ使用量が多い傾向
Caffeine
CaffeineはGoogle Guava Cacheの後継として開発された高性能キャッシュライブラリです。
設定例
|
|
|
|
メリット
- 非常に高速(Window TinyLFUアルゴリズム採用)
- メモリ効率が良い
- 設定がシンプル
デメリット
- クラスタ環境では使用不可
- ディスク永続化非対応
Redis(Redisson経由)
分散環境でのキャッシュ共有が必要な場合は、Redisの使用を検討します。
設定例
|
|
|
|
|
|
メリット
- 複数アプリケーションインスタンス間でキャッシュを共有
- 水平スケーリングに対応
- キャッシュの永続化と高可用性
デメリット
- ネットワークレイテンシが発生
- 追加のインフラストラクチャ管理が必要
- シリアライゼーションのオーバーヘッド
プロバイダー選定のフローチャート
flowchart TD
A[キャッシュプロバイダーの選定] --> B{クラスタ環境が必要?}
B -->|はい| C{外部キャッシュサーバーを許容?}
B -->|いいえ| D{パフォーマンス重視?}
C -->|はい| E[Redis / Redisson]
C -->|いいえ| F[Infinispan]
D -->|はい| G[Caffeine]
D -->|いいえ| H{ディスク永続化が必要?}
H -->|はい| I[Ehcache 3]
H -->|いいえ| GCacheConcurrencyStrategyの選定基準
CacheConcurrencyStrategyは、複数のトランザクションが同時に同じエンティティにアクセスする際の振る舞いを制御します。
各戦略の詳細
| 戦略 | 説明 | 整合性 | パフォーマンス | 適用シナリオ |
|---|---|---|---|---|
READ_ONLY |
読み取り専用。更新不可 | 完全 | 最高 | マスターデータ、定数テーブル |
NONSTRICT_READ_WRITE |
緩やかな読み書き。時折の古いデータを許容 | 弱い | 高い | 更新頻度が低く、一時的な不整合を許容できるデータ |
READ_WRITE |
厳密な読み書き。ソフトロックを使用 | 強い | 中程度 | 一般的なトランザクショナルデータ |
TRANSACTIONAL |
JTAトランザクションと統合 | 最強 | 低い | XA分散トランザクション環境 |
READ_ONLY
エンティティが決して更新されない場合に使用します。不変オブジェクトに最適です。
|
|
注意点: READ_ONLYでキャッシュされたエンティティを更新しようとすると、UnsupportedOperationExceptionがスローされます。
NONSTRICT_READ_WRITE
更新頻度が低く、一時的なデータの不整合を許容できる場合に使用します。
|
|
動作原理: 更新時にキャッシュエントリを無効化し、次回アクセス時に再読み込みします。ロックを取得しないため、同時更新時に古いデータを読み取る可能性があります。
READ_WRITE
一般的なトランザクショナルデータに推奨される戦略です。
|
|
動作原理: ソフトロック機構を使用し、更新中のエンティティへの同時アクセスを制御します。トランザクションがコミットされるまで、他のトランザクションはキャッシュからではなくデータベースから直接読み取ります。
TRANSACTIONAL
JTA(Java Transaction API)環境でのみ使用可能です。完全なトランザクション分離を提供します。
|
|
注意点: すべてのキャッシュプロバイダーがこの戦略をサポートしているわけではありません。Infinispan JTA CacheはTRANSACTIONAL戦略をサポートしています。
戦略選定のガイドライン
flowchart TD
A[CacheConcurrencyStrategy選定] --> B{エンティティは更新される?}
B -->|いいえ| C[READ_ONLY]
B -->|はい| D{厳密な一貫性が必要?}
D -->|いいえ| E{更新頻度は?}
D -->|はい| F{JTA環境?}
E -->|低い| G[NONSTRICT_READ_WRITE]
E -->|高い| H[キャッシュ対象から除外を検討]
F -->|はい| I[TRANSACTIONAL]
F -->|いいえ| J[READ_WRITE]クエリキャッシュの設定方法
クエリキャッシュは、JPQLやCriteria APIで実行されるクエリの結果をキャッシュします。エンティティキャッシュとは別に設定が必要です。
クエリキャッシュの有効化
|
|
クエリへのキャッシュ設定
JPA/Hibernateでは、個々のクエリに対してキャッシュを有効化します。
|
|
EntityManagerを直接使用する場合は以下のように設定します。
|
|
クエリキャッシュの動作原理
クエリキャッシュは以下の2つのリージョンで構成されます。
- default-query-results-region: クエリ結果(エンティティIDのリスト)を格納
- default-update-timestamps-region: テーブルの最終更新タイムスタンプを格納
sequenceDiagram
participant App as アプリケーション
participant QC as クエリキャッシュ
participant TS as タイムスタンプキャッシュ
participant EC as エンティティキャッシュ
participant DB as データベース
App->>QC: クエリ実行(キャッシュ有効)
QC->>TS: テーブルのタイムスタンプを確認
alt キャッシュが有効
TS-->>QC: タイムスタンプ有効
QC-->>App: キャッシュされたIDリストを返却
App->>EC: IDでエンティティを取得
EC-->>App: エンティティを返却
else キャッシュが無効または存在しない
TS-->>QC: タイムスタンプ無効
QC->>DB: クエリを実行
DB-->>QC: 結果を返却
QC->>QC: 結果をキャッシュ
QC-->>App: 結果を返却
end
~~~
### クエリキャッシュの注意点
クエリキャッシュには以下の注意点があります。
1. **タイムスタンプリージョンの設定**: `default-update-timestamps-region`には有効期限やサイズ制限を設定しないでください。LRU(Least Recently Used)ポリシーは不適切です
2. **エンティティキャッシュとの併用**: クエリキャッシュはエンティティIDのみを格納するため、エンティティ自体もキャッシュしないとN+1問題が発生する可能性があります
3. **更新頻度の高いテーブル**: 頻繁に更新されるテーブルに対するクエリキャッシュは効果が薄い(すぐに無効化される)
~~~java
// 悪い例: 頻繁に更新されるテーブルへのクエリキャッシュ
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Order> findByStatus(OrderStatus status); // 注文は頻繁に更新される
// 良い例: 更新頻度が低いテーブルへのクエリキャッシュ
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Country> findAll(); // 国マスターは滅多に更新されない
~~~
## キャッシュ無効化とライフサイクル管理
キャッシュの適切な無効化は、データの整合性を維持するために不可欠です。
### 自動無効化
Hibernateは、EntityManagerを通じたエンティティの変更を自動的に検出し、関連するキャッシュエントリを無効化します。
~~~java
@Service
@Transactional
public class ProductService {
@Autowired
private ProductRepository productRepository;
public Product updateProduct(Long id, String newName) {
Product product = productRepository.findById(id).orElseThrow();
product.setName(newName);
// トランザクションコミット時に自動的にキャッシュが更新される
return product;
}
public void deleteProduct(Long id) {
productRepository.deleteById(id);
// 自動的にキャッシュから削除される
}
}
~~~
### 手動無効化
データベースを直接更新した場合や、外部システムによる変更があった場合は、手動でキャッシュを無効化する必要があります。
~~~java
@Service
public class CacheManagementService {
@PersistenceContext
private EntityManager entityManager;
@Autowired
private EntityManagerFactory entityManagerFactory;
/**
* 特定のエンティティをキャッシュから削除
*/
public void evictEntity(Class<?> entityClass, Object id) {
entityManagerFactory.getCache().evict(entityClass, id);
}
/**
* 特定のエンティティクラスのすべてのキャッシュを削除
*/
public void evictEntityClass(Class<?> entityClass) {
entityManagerFactory.getCache().evict(entityClass);
}
/**
* すべての2次キャッシュをクリア
*/
public void evictAllCache() {
entityManagerFactory.getCache().evictAll();
}
/**
* クエリキャッシュの特定リージョンをクリア(Hibernate固有)
*/
public void evictQueryCache(String region) {
Session session = entityManager.unwrap(Session.class);
session.getSessionFactory().getCache().evictQueryRegion(region);
}
}
~~~
### ネイティブクエリ使用時の注意
ネイティブSQLクエリでデータを更新する場合、Hibernateはキャッシュを自動的に無効化できません。`@Modifying`アノテーションと`clearAutomatically`オプションを使用するか、手動でキャッシュを無効化してください。
~~~java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Modifying(clearAutomatically = true) // 永続化コンテキストをクリア
@Query(value = "UPDATE products SET price = price * 1.1 WHERE category_id = :categoryId",
nativeQuery = true)
int increasePriceByCategory(@Param("categoryId") Long categoryId);
}
~~~
~~~java
@Service
@Transactional
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Autowired
private CacheManagementService cacheManagementService;
public void increasePriceByCategory(Long categoryId) {
productRepository.increasePriceByCategory(categoryId);
// ネイティブクエリ後は手動でキャッシュを無効化
cacheManagementService.evictEntityClass(Product.class);
}
}
~~~
### TTL(Time To Live)によるキャッシュの有効期限管理
Ehcacheでは、キャッシュエントリに有効期限を設定できます。
~~~xml
<!-- ehcache.xml -->
<cache alias="com.example.domain.Product">
<expiry>
<ttl unit="minutes">60</ttl> <!-- 60分後に期限切れ -->
</expiry>
<heap unit="entries">1000</heap>
</cache>
<cache alias="com.example.domain.Country">
<expiry>
<tti unit="hours">24</tti> <!-- 最終アクセスから24時間後に期限切れ -->
</expiry>
<heap unit="entries">500</heap>
</cache>
~~~
## よくある誤解とアンチパターン
2次キャッシュの導入時によく見られる誤解とアンチパターンを紹介します。
### アンチパターン1: すべてのエンティティをキャッシュする
~~~java
// 悪い例: 頻繁に更新されるエンティティをキャッシュ
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class AuditLog { // 監査ログは常に追加されるためキャッシュ効果なし
// ...
}
~~~
**推奨**: 読み取り頻度が高く、更新頻度が低いエンティティのみをキャッシュ対象にします。
### アンチパターン2: クエリキャッシュの過剰使用
~~~java
// 悪い例: すべてのクエリをキャッシュ
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Order> findByUserId(Long userId); // ユーザーごとに結果が異なり、キャッシュヒット率が低い
}
~~~
**推奨**: パラメータが少なく、結果が共通化されやすいクエリにのみキャッシュを適用します。
### アンチパターン3: キャッシュ統計の無視
~~~yaml
# 悪い例: 統計を無効化
spring:
jpa:
properties:
hibernate:
generate_statistics: false # 問題の特定が困難に
~~~
**推奨**: 開発・テスト環境では統計を有効化し、キャッシュヒット率を監視します。
~~~java
@Component
public class CacheStatisticsReporter {
@Autowired
private EntityManagerFactory entityManagerFactory;
@Scheduled(fixedRate = 60000) // 1分ごとに実行
public void reportCacheStatistics() {
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
Statistics stats = sessionFactory.getStatistics();
log.info("2次キャッシュ統計: ヒット={}, ミス={}, ヒット率={:.2f}%",
stats.getSecondLevelCacheHitCount(),
stats.getSecondLevelCacheMissCount(),
calculateHitRatio(stats.getSecondLevelCacheHitCount(),
stats.getSecondLevelCacheMissCount()));
}
private double calculateHitRatio(long hits, long misses) {
if (hits + misses == 0) return 0.0;
return (double) hits / (hits + misses) * 100;
}
}
~~~
### アンチパターン4: 遅延ロードのコレクションキャッシュの誤解
~~~java
// 誤解: コレクションをキャッシュすれば関連エンティティも取得できる
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category {
@OneToMany(mappedBy = "category", fetch = FetchType.LAZY)
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Product> products; // キャッシュされるのはProduct IDのリストのみ
}
~~~
**注意点**: コレクションキャッシュには関連エンティティのIDのみが格納されます。関連エンティティ自体もキャッシュしないと、アクセス時にデータベースクエリが発行されます。
### アンチパターン5: @Filterとの併用
~~~java
// 動作しない例: @Filterとキャッシュの併用
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@FilterDef(name = "activeOnly", parameters = @ParamDef(name = "active", type = Boolean.class))
@Filter(name = "activeOnly", condition = "active = :active")
public class Product {
// @Filterが適用されたエンティティはキャッシュと互換性がない
}
~~~
**理由**: フィルタはセッションレベルで適用されるため、2次キャッシュに格納されたデータにはフィルタが適用されません。フィルタ使用時は、そのエンティティのキャッシュを無効化するか、別のアプローチを検討してください。
## まとめと実践Tips
### 2次キャッシュ導入のチェックリスト
1. **対象エンティティの選定**
- 読み取り頻度 > 更新頻度
- 複数トランザクションで共有される
- データサイズが適切
2. **キャッシュプロバイダーの選択**
- 単一インスタンス: Caffeine(高速)またはEhcache(機能豊富)
- クラスタ環境: Redis(外部サーバー)またはInfinispan(組み込み)
3. **CacheConcurrencyStrategyの決定**
- 読み取り専用: `READ_ONLY`
- 一般的なCRUD: `READ_WRITE`
- 緩い一貫性で可: `NONSTRICT_READ_WRITE`
4. **モニタリングの設定**
- Hibernate統計の有効化
- キャッシュヒット率の監視
- メモリ使用量の追跡
### パフォーマンス最適化のTips
1. **エンティティキャッシュを優先**: クエリキャッシュはエンティティキャッシュと組み合わせて使用
2. **適切なTTLを設定**: データの鮮度とパフォーマンスのバランスを考慮
3. **オフヒープメモリの活用**: 大量のエンティティをキャッシュする場合はEhcacheのオフヒープを使用
4. **統計に基づく調整**: キャッシュヒット率が低いエンティティは対象から除外
### トラブルシューティング
| 問題 | 考えられる原因 | 対処法 |
|------|----------------|--------|
| キャッシュが効かない | `@Cacheable`アノテーションの欠落 | エンティティクラスにアノテーションを追加 |
| 古いデータが返される | ネイティブクエリによる更新 | 手動でキャッシュを無効化 |
| OutOfMemoryError | キャッシュサイズが大きすぎる | ヒープサイズを縮小、オフヒープを使用 |
| パフォーマンスが改善しない | 対象エンティティの選定が不適切 | 統計を確認し、対象を見直す |
## 参考リンク
- [Hibernate ORM 6.6 User Guide - Caching](https://docs.jboss.org/hibernate/orm/6.6/userguide/html_single/Hibernate_User_Guide.html#caching)
- [Spring Boot Reference Documentation - Caching](https://docs.spring.io/spring-boot/reference/io/caching.html)
- [Ehcache 3 Documentation](https://www.ehcache.org/documentation/3.10/)
- [Caffeine GitHub Repository](https://github.com/ben-manes/caffeine)
- [Redisson Hibernate Integration](https://redisson.org/glossary/hibernate-second-level-cache.html)
- [Jakarta Persistence Specification](https://jakarta.ee/specifications/persistence/3.2/)