メインコンテンツへスキップ
  1. Posts/

[JPA] 永続性コンテキストとキャッシュ — Auto Scaling環境でデータ整合性は大丈夫か?

NineKoo9
著者
NineKoo9
目次

このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。Auto Scaling環境でJPAの1次キャッシュと2次キャッシュのスコープがどのように異なるか、どんな場合にデータ整合性の問題が生じうるかを整理します。

結論から
#

キャッシュスコープAuto Scaling時の整合性
1次キャッシュ(永続性コンテキスト)Session / EntityManagerスコープ(一般的なSpringアプリでは通常トランザクションスコープ)ノード間で共有されないため、分散環境の整合性問題の直接の原因にはならない
2次キャッシュ(ローカル — Ehcacheなど)サーバーのJVM崩れる可能性がある
2次キャッシュ(共有/分散 — Redisなど)複数のサーバーが同じキャッシュを共有ローカルキャッシュよりも有利だが、設定/無効化/外部変更の有無によってstale dataが起こりうる

1次キャッシュ — 永続性コンテキスト(Persistence Context)
#

定義
#

Hibernateの公式ドキュメントによると:

A persistence context acts as a cache, often called the ‘first-level cache’. It maintains a unique mapping of entity identifiers to entity instances that have been read or made persistent within its scope.

永続性コンテキストはエンティティID → エンティティインスタンスのマップとして動作する1次キャッシュだ。

エンティティのライフサイクル
#

エンティティのlifecycleは通常4つの状態で説明する:

┌──────────┐   persist()   ┌──────────┐   detach()/clear()  ┌──────────┐
│ Transient │ ───────────> │ Managed  │ ─────────────────> │ Detached │
│ (非永続)   │              │ (永続)    │                     │ (準永続)  │
└──────────┘              └──────────┘                     └──────────┘
     │                         │                                │
     │                         │ remove()                       │ merge()
     │                         ▼                                │
     │                    ┌──────────┐                          │
     │                    │ Removed  │                          │
     │                    │ (削除)    │                          │
     │                    └──────────┘                          │
     │                                                          │
     └──────────────────────────────────────────────────────────┘
                          merge()で再び永続状態に
状態説明
Transientnewで生成しただけの状態。永続性コンテキストと無関係
Managedpersist()または照会によって永続性コンテキストに入った状態
Detached永続性コンテキストから切り離された状態(トランザクション終了など)
Removedremove()呼び出し。コミット時にDELETEを実行

核心:一般的なSpringアプリではトランザクションスコープとして理解してよい
#

@Service
public class UserService {

    @Transactional  // ← トランザクション開始 = 永続性コンテキストの生成
    public void updateUser(Long id) {
        User user = userRepository.findById(id).get();  // DBから照会 → 1次キャッシュに保存
        User same = userRepository.findById(id).get();   // 1次キャッシュから返却(SQLが発行されない)

        System.out.println(user == same);  // true(同じインスタンス)

        user.setName("田中太郎");  // ダーティチェックの対象
    }  // ← トランザクション終了 = flush + commit + 永続性コンテキストの消滅
}

一般的なSpring Boot + @Transactionalのリクエスト処理では、トランザクションが終了すると該当の永続性コンテキストも一緒に片付けられると理解してよい。

ただし正確に言うと1次キャッシュのスコープは**現在のSession / EntityManager**だ。つまり通常はトランザクションスコープで短く使われるが、extended persistence contextのような例外的な構成も可能だ。

1次キャッシュが提供する機能
#

Hibernate公式ドキュメント:

Persistence contexts offer benefits such as preventing data aliasing, enabling automatic dirty checking, improving performance by caching entities, and facilitating transparent batching of database operations.

機能説明
同一性の保証同じトランザクション内で同じIDのエンティティは常に同じインスタンス
繰り返し読み取り同じエンティティを複数回照会してもSQLは1回だけ実行
ダーティチェックエンティティのフィールド変更時に自動的にUPDATEを検出
書き込み遅延flushのタイミングまでSQLをまとめてバッチ実行

Auto Scalingでなぜ直接的な整合性問題の原因にならないのか
#

[Server A]                              [Server B]
リクエスト1到着                          リクエスト2到着
  │                                       │
  ▼                                       ▼
トランザクション開始                     トランザクション開始
永続性コンテキストA生成                   永続性コンテキストB生成
  │                                       │
User(id=1)照会 → DB SELECT              User(id=1)照会 → DB SELECT
1次キャッシュに保存                       1次キャッシュに保存
  │                                       │
トランザクション終了                     トランザクション終了
永続性コンテキストA消滅 ← ここで消える    永続性コンテキストB消滅
  • 各サーバーの1次キャッシュは完全に独立
  • 通常リクエスト/トランザクション単位で生成・片付けられるため、サーバー間で共有するものがそもそも存在しない
  • したがって**「Server Aの1次キャッシュのせいでServer Bが古い値を見る」といった問題は起きない**

ただしこれが**「常に最新のデータを見る」という意味ではない。同じ永続性コンテキスト内ではすでに管理中のエンティティを返すことがあり、最新性は結局トランザクション分離レベル / 照会タイミング / refresh()の有無**にも影響を受ける。


2次キャッシュ — サーバーレベルのキャッシュ
#

定義
#

1次キャッシュとは異なり、トランザクションが終わっても存在し続けるキャッシュ。SessionFactory(アプリケーション)レベルで動作する。

リクエスト → 1次キャッシュ(永続性コンテキスト) → miss → 2次キャッシュ → miss → DB
                                                        hit ↓
                                                      そのまま返却

重要な制約が一つある。Hibernateの2次キャッシュはHibernateの外で発生したDBの変更を自動的に知ることができない。

  • JDBCで直接変更
  • バッチプログラムが直接変更
  • 別のアプリケーションが同じテーブルを変更

このような変更はキャッシュに自動反映されないため、必要な場合は明示的な無効化や期限切れポリシーが必要だ。

設定方法
#

Hibernateで2次キャッシュを有効にするには:

# application.properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=jcache
spring.jpa.properties.hibernate.javax.cache.uri=/ehcache.xml

エンティティに@Cacheアノテーションを適用:

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
    @Id
    private Long id;
    private String name;
    private int price;
}

並行性戦略(CacheConcurrencyStrategy)
#

Hibernate公式ドキュメント:

The second-level cache offers four concurrency strategies: READ_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, and TRANSACTIONAL. READ_WRITE is generally recommended as a default for strong consistency.

戦略説明適している場合
READ_ONLY読み取り専用。変更時に例外コードテーブル、設定値などの不変データ
NONSTRICT_READ_WRITE変更後にキャッシュを無効化(弱い一貫性)偶発的な変更、わずかな不整合を許容
READ_WRITEsoft lockベース。Hibernateが管理する修正に対して比較的強い一貫性を提供一般的に推奨
TRANSACTIONALJTAと統合するtransactional cache providerを前提特殊なエンタープライズ環境

キャッシュ管理API
#

// SessionFactoryでキャッシュを直接制御
Cache cache = sessionFactory.getCache();
cache.evict(Product.class, 1L);        // 特定のエンティティを削除
cache.evict(Product.class);            // 該当タイプ全体を削除
cache.evictAllRegions();               // 全キャッシュを初期化
boolean cached = cache.contains(Product.class, 1L);  // キャッシュの存在確認

2次キャッシュ + Auto Scaling = 整合性の問題
#

問題のシナリオ(ローカルキャッシュ使用時)
#

時系列    Server A (Ehcache)             Server B (Ehcache)             DB
────────────────────────────────────────────────────────────────────────────────
T1         Product(id=1, price=1000)       Product(id=1, price=1000)       price=1000
           2次キャッシュに保存             2次キャッシュに保存

T2         priceを2000にUPDATE            (変更を知らない)              price=2000
           自分のキャッシュを更新: price=2000  キャッシュ: price=1000 ← 古い値!

T3                                        Product(id=1)を照会
                                          → 2次キャッシュがhit
                                          → price=1000を返却 ← 間違った値!

核心:ローカルの2次キャッシュは各JVMの中にしか存在しないため、他のサーバーの変更を知ることができない。

コードで見る問題の状況
#

// Server Aで実行
@Transactional
public void updatePrice(Long productId, int newPrice) {
    Product product = productRepository.findById(productId).get();
    product.setPrice(newPrice);  // DBに反映 + Server Aの2次キャッシュを更新
    // Server Bの2次キャッシュは? → そのまま古い値
}

// Server Bで実行(直後)
@Transactional(readOnly = true)
public Product getProduct(Long productId) {
    return productRepository.findById(productId).get();
    // 2次キャッシュがhit → 古い価格を返却!
}

これはAuto Scalingだけの問題ではない
#

サーバーが2台以上あれば同じ問題が発生する。 Auto Scalingはサーバー数が動的に変わることで問題の予測がより難しくなるだけだ。


解決方法
#

方法1:2次キャッシュを使わない(最もシンプル)
#

# providerを設定していても全体的に無効にしたい場合
spring.jpa.properties.hibernate.cache.use_second_level_cache=false

ほとんどのサービスは1次キャッシュ + 適切なクエリの最適化だけで十分だ。Hibernateはproviderのセットアップとエンティティマッピング(@Cache)がないと実際には2次キャッシュを活用しないため、多くのプロジェクトではそもそも積極的に使用していない。

方法2:共有キャッシュを使用(Redisなど)
#

すべてのサーバーが1つの外部キャッシュを参照するようにすることで、ローカルキャッシュよりもノード間のstale dataの可能性を減らせる。

Server A ──┐
Server B ──┼──> Redis(共有キャッシュ) ──> DB
Server C ──┘
// Redisを2次キャッシュとして使用(redisson-hibernateなど)
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
    @Id
    private Long id;
    private String name;
    private int price;
}
# RedissonをHibernateの2次キャッシュとして設定
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory
spring.jpa.properties.hibernate.cache.redisson.config=redisson.yaml
時系列    Server A                    Server B                    Redis           DB
──────────────────────────────────────────────────────────────────────────────────────
T1         Product(id=1)照会          Product(id=1)照会
           → Redisにキャッシュ        → RedisキャッシュがHIT        price=1000      price=1000

T2         price=2000にUPDATE
           → Redisキャッシュを更新                                  price=2000      price=2000

T3                                    Product(id=1)照会
                                      → RedisキャッシュがHIT
                                      → price=2000を返却可能        price=2000

ただし、この結論は以下の前提がある場合にのみ比較的成立する。

  • すべての書き込みがHibernateを通じて行われる
  • cache providerの無効化/ロック戦略が正しく設定されている
  • Hibernateの外からDBを直接変更する経路がないか、別途無効化戦略がある

つまり、共有Redisを使うからといって無条件に「問題なし」にはならない。 それでもサーバーごとにローカルキャッシュを別々に持つよりは一般的に安全だ。

方法3:短いTTL + 許容可能な不整合
#

<!-- ehcache.xml -->
<cache alias="products">
    <expiry>
        <ttl unit="seconds">30</ttl>  <!-- 30秒後に期限切れ -->
    </expiry>
    <heap unit="entries">1000</heap>
</cache>
  • 最大30秒間古いデータを見る可能性がある
  • 商品リスト、記事の閲覧数など多少の不整合が許容される場合に適している
  • これは整合性の保証ではなくstale dataが続く時間を減らす緩和策

方法4:Spring @Cacheable + Redis(2次キャッシュの代わりにアプリケーションレベル)
#

JPAの2次キャッシュの代わりにSpringのキャッシュ抽象化を使う方法もある。

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#id")
    public Product getProduct(Long id) {
        return productRepository.findById(id).get();  // キャッシュmiss時のみ実行
    }

    @Transactional
    @CacheEvict(value = "products", key = "#id")
    public void updateProduct(Long id, int newPrice) {
        Product product = productRepository.findById(id).get();
        product.setPrice(newPrice);
    }
}
spring.cache.type=redis
  • @CacheEvictで変更時にキャッシュを無効化
  • Redisのような共有キャッシュを使うとサーバーごとのローカルキャッシュの不整合を減らせる
  • ただしSpring Cacheは抽象化層なので、TTL、伝播方式、即時性はRedis/CacheManagerの設定による

各方法の比較
#

方法整合性パフォーマンス複雑度適している場合
2次キャッシュを使わないキャッシュによるstaleリスクが最も低い普通低いほとんどのサービス
共有キャッシュ(Redisなど)適切に構成すると高い高い中程度読み取り比率が高いサービス
短いTTL弱い不整合高い低い閲覧数、リストなど許容可能な場合
@Cacheable + Redis適切に構成すると高い高い中程度JPAの2次キャッシュより直感的

全体像
#

                    1次キャッシュ                    2次キャッシュ
                  (トランザクションスコープ)        (サーバー/共有スコープ)
                       │                              │
[Server A] ── リクエスト → 永続性コンテキスト → miss → ローカル or Redis → miss → DB
                       │                              │
                  トランザクション終了で消滅          サーバーが起動中は維持
                       │                              │
                  サーバー間で共有しない              ローカル:サーバー間で共有しない
                                                      Redis:サーバー間で共有する

まとめ
#

  1. 1次キャッシュ(永続性コンテキスト)は現在のSession / EntityManagerスコープであり、一般的なSpringアプリでは通常トランザクションスコープで短く使われる
  2. したがって1次キャッシュはノード間の共有キャッシュ不整合の直接の原因にはならない
  3. 2次キャッシュをローカル(Ehcacheなど)で使用すると、サーバーが2台以上のときにstale dataが発生しうる
  4. **共有キャッシュ(Redisなど)**はこの問題を減らせるが、外部DBの変更・無効化・providerの設定によってstale dataの可能性は残る
  5. そのため実務的な解決策は2次キャッシュを使わないか共有キャッシュを慎重に構成するかTTL/無効化戦略を明示的に持つこと