Javaを用いたエンタープライズアプリケーション開発において、データベースの整合性を保つことは最も重要な課題の一つです。

特に Hibernate や JPA (Java Persistence API) を使用している環境で、複数のユーザーが同時に同じデータを更新しようとした際に発生するのが org.hibernate.StaleObjectStateException です。

この例外は、一般的に「楽観的ロック (Optimistic Locking) 」の仕組みにおいて、データの不整合(ロストアップデート)を防ぐためにスローされます。

この例外が発生するということは、システムが正常にデータの整合性を守ったという証拠でもありますが、エンドユーザーにとっては操作の失敗を意味するため、適切なハンドリングと設計上の対策が求められます。

本記事では、StaleObjectStateException が発生する内部メカニズムから、その原因、そして実務で使える具体的な解決策までを詳しく解説します。

org.hibernate.StaleObjectStateExceptionとは何か

StaleObjectStateException は、Hibernate がデータベースのレコードを更新または削除しようとした際、そのデータが 「すでに他のトランザクションによって変更されていた」 場合に発生する例外です。

Hibernate は楽観的ロックを実現するために、エンティティに「バージョン番号」や「タイムスタンプ」を保持させます。

データを更新する際、Hibernate は内部的に以下のような SQL を実行します。

sql
UPDATE employees SET name = 'New Name', version = 2 WHERE id = 1 AND version = 1;

このとき、もし別のスレッドが先に更新を完了させて version がすでに 2 になっていた場合、上記の SQL の更新件数は 0 件になります。

Hibernate は「更新されるべき行があったはずなのに、1行も更新できなかった」ことを検知し、データが 「古くなった (Stale) 」 と判断してこの例外を投げます。

JPAの OptimisticLockException との関係

Hibernate を JPA 実装として使用している場合、アプリケーションコードには javax.persistence.OptimisticLockException (または Jakarta EE では jakarta.persistence.OptimisticLockException) がラップされて届くことがあります。

StaleObjectStateException は Hibernate 固有の例外であり、JPA 標準の例外の根本原因 (Cause) となっていることが多いのが特徴です。

楽観的ロックの仕組みと実装方法

まずは、この例外の前提となる「楽観的ロック」の基本的な実装方法を確認しましょう。

Hibernate で楽観的ロックを有効にするには、エンティティクラスに @Version アノテーションを付与したフィールドを定義します。

エンティティの実装例

Java
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Version;
import javax.persistence.Column;

@Entity
public class Product {

    @Id
    private Long id;

    private String name;

    private Integer price;

    // 楽観的ロックのためのバージョン管理フィールド
    @Version
    @Column(name = "version")
    private Integer version;

    // Getter, Setter, コンストラクタは省略
}

この @Version フィールドにより、Hibernate は自動的にバージョンチェックを行うようになります。

開発者が手動でバージョンをインクリメントする必要はありません。

なぜ楽観的ロックが必要なのか

不特定多数がアクセスする Web システムにおいて、データベースの行を物理的にロックする「悲観的ロック (Pessimistic Locking) 」は、パフォーマンスの低下やデッドロックのリスクを伴います。

一方で、何のロックも行わない場合、後から更新した人の内容が先に更新した人の内容を上書きしてしまう 「ロストアップデート (Lost Update) 」 が発生します。

楽観的ロックは、「ほとんどの場合は同時更新は起きないだろう」という楽観的な前提に基づき、更新の瞬間だけ整合性をチェックすることで、高いスループットを維持しながらデータの安全性を確保する手法です。

StaleObjectStateExceptionが発生する主な原因

この例外が発生するパターンは、主に以下の3つに集約されます。

1. 同一データに対する競合

最も一般的なケースです。

2人のユーザー(ユーザーAとユーザーB)がほぼ同時に同じ編集画面を開き、ユーザーAが先に保存し、その直後にユーザーBが保存ボタンを押した場合に発生します。

ユーザーBが保持しているエンティティのバージョン番号は、データベース上の最新バージョンよりも古くなっているため、Hibernate は更新を拒否します。

2. デタッチ(Detached)オブジェクトの再アタッチ

Web アプリケーションでは、一度検索したデータを画面(クライアント側)に返し、その後リクエストを受けて再度更新を行うという流れが一般的です。

このとき、セッションが一度閉じられているため、エンティティは 「デタッチ状態」 になります。

もし、そのデータが画面に表示されている間にバッチ処理などでデータベース上の値が更新されていた場合、画面から戻ってきた古いバージョンのエンティティを saveOrUpdatemerge しようとすると、この例外が発生します。

3. 長時間実行されるトランザクション

トランザクションの開始から終了までの時間が長い場合、その間に他のプロセスがデータを変更する確率が高まります。

特に、ユーザーの入力を待つような処理(対話型処理)をトランザクション内に含めてしまうと、この問題が頻発する原因となります。

例外発生時の再現コード例

実際にどのようにして例外が発生するのか、擬似的なコードで再現してみます。

Java
// サービス層の処理を想定
public void reproduceStaleObjectException(Long productId) {
    // 1. ユーザーAがデータを取得
    Product productA = entityManager.find(Product.class, productId);
    
    // 2. ユーザーBが同じデータを取得 (別のトランザクション/スレッド)
    Product productB = entityManager.find(Product.class, productId);

    // 3. ユーザーAが更新してコミット
    EntityTransaction txA = entityManager.getTransaction();
    txA.begin();
    productA.setPrice(2000);
    txA.commit(); // ここでDBのversionが1から2に上がる

    // 4. ユーザーBが更新してコミットを試みる
    EntityTransaction txB = entityManager.getTransaction();
    txB.begin();
    productB.setPrice(3000);
    try {
        // ここで内部的に UPDATE ... WHERE version = 1 が実行されるが、
        // すでにDB側は version = 2 なので、更新件数が0件になる
        txB.commit(); 
    } catch (Exception e) {
        // org.hibernate.StaleObjectStateException が発生
        System.out.println("例外発生: " + e.getMessage());
    }
}

実行結果
[INFO] Hibernate: update Product set price=?, version=? where id=? and version=?
例外発生: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.example.Product#1]

StaleObjectStateExceptionの解決策と対処法

この例外はシステムの設計に起因することが多いため、単に try-catch で囲むだけでなく、アプリケーションの特性に合わせた戦略を選択する必要があります。

1. アプリケーション層でのリトライ(再試行)

一時的な競合であれば、最新のデータを再度読み込み、変更を再適用して保存し直すことで解決できます。

Spring Framework を使用している場合は、@Retryable アノテーションを利用すると簡潔に実装できます。

Java
import org.springframework.retry.annotation.Retryable;
import org.springframework.orm.ObjectOptimisticLockingFailureException;

@Service
public class ProductService {

    @Retryable(value = {ObjectOptimisticLockingFailureException.class}, maxAttempts = 3)
    @Transactional
    public void updateProductPrice(Long id, Integer newPrice) {
        // 最新の状態をロード
        Product product = productRepository.findById(id).orElseThrow();
        // 変更を適用
        product.setPrice(newPrice);
        // 保存(コミット時にチェックが行われる)
    }
}

ただし、リトライを行う場合は 「ビジネスロジックとして自動的に上書きして良いのか」 を検討する必要があります。

ユーザーが意図した変更が、他のユーザーの変更を勝手に消してしまう可能性があるからです。

2. ユーザーへの通知と再操作の促し

Web 画面など、人間が操作するアプリケーションでは、最も誠実な対応は 「他のユーザーによって更新されました」というメッセージを表示し、最新のデータを再表示する ことです。

  1. Catch節で例外を捕捉する。
  2. ユーザーに「同時更新エラー」を通知する。
  3. 画面に最新のデータベースの状態を表示し、再度入力を求める。

これにより、データの競合による「情報の先祖返り」を確実に防ぐことができます。

3. バージョン情報の明示的な管理

デタッチ状態のオブジェクトを扱う場合、画面(HTML)の隠しフィールド (hidden field) などにバージョン番号を保持させておく必要があります。

HTML
<!-- Thymeleafなどのテンプレートエンジンの例 -->
<input type="hidden" name="version" th:value="${product.version}" />

サーバー側に戻ってきたこのバージョン番号をエンティティにセットしてから merge することで、表示時点から更新時点までの間に他者が変更を加えたかどうかを Hibernate が正しく判定できるようになります。

4. 悲観的ロックへの切り替え(特定のケース)

もし特定の機能で競合が頻発し、かつビジネス的に絶対にリトライやユーザー通知を避けたい場合は、悲観的ロック の検討が必要です。

Java
// JPAでの悲観的ロック(SELECT ... FOR UPDATE)
Product product = entityManager.find(Product.class, id, LockModeType.PESSIMISTIC_WRITE);

悲観的ロックを使用すると、データを読み込んだ時点で他のトランザクションによる更新をブロックするため、StaleObjectStateException は発生しなくなります。

ただし、前述の通りスケーラビリティが低下するため、限定的な利用に留めるべきです。

実践的なベストプラクティス

StaleObjectStateException を適切に管理するために、以下の設計指針を推奨します。

トランザクションを短く保つ

トランザクションの範囲は、純粋にデータベースを更新する短い期間に限定してください。

ユーザーの思考時間や外部 API のレスポンス待ちをトランザクションに含めてはいけません。

適切なバージョン型の選択

@Version には、通常 Integer または Long を使用します。

java.sql.Timestamp も使用可能ですが、システムクロックの精度やデータベース側の分解能に依存するため、数値型によるカウントアップ方式が最も安全で推奨されます。

拡張された永続コンテキスト(Extended Persistence Context)の検討

Stateful なアプリケーション(Desktop アプリや特定の Web フロー)の場合、JPA の Extended Persistence Context を使用することで、複数のリクエストにまたがって同一の EntityManager を保持し、バージョンの管理を容易にする手法もあります。

しかし、通常の REST API や Stateless な Web アプリでは、各リクエストで確実にバージョンをやり取りする設計が一般的です。

StaleObjectStateExceptionと不具合の切り分け

時折、同時更新が起きていないはずなのにこの例外が発生することがあります。

その場合は以下の点を確認してください。

確認項目内容
手動でのSQL実行アプリケーション外(DBクライアントなど)で直接データを書き換えていないか。
トリガーの影響データベース側のトリガーが自動的にバージョン列を更新していないか。
複数回のフラッシュ1つのトランザクション内で同じエンティティを何度も不自然に flush() していないか。
IDの重複新規保存時に ID が重複しており、Hibernate が既存データの更新と誤認していないか。

特にトリガーによる更新は、Hibernate が把握できないところでバージョンが変わってしまうため、原因特定が難しくなるポイントです。

バージョン列の管理は Hibernate に完全に任せるのが原則です。

まとめ

org.hibernate.StaleObjectStateException は、データの不整合を未然に防いでくれる Hibernate の守護神のような存在です。

この例外が発生したときは、単なるエラーとして排除するのではなく、システムがデータの整合性を正しく守った結果であることを理解しましょう。

対策としては、@Version による楽観的ロックを正しく実装すること を基本とし、発生時にはリトライロジックの導入や、ユーザーへの適切な通知を行うインターフェース設計を行うことが重要です。

また、トランザクションの範囲を最小化し、デタッチされたオブジェクトのバージョンを適切に引き回すことで、この例外と上手に付き合いながら、信頼性の高いシステムを構築することができます。

高い並行性が求められる現代の Web アプリケーションにおいて、この例外のハンドリングは開発者の腕の見せ所といえるでしょう。

適切なエラーハンドリングを実装し、堅牢なデータ永続化レイヤーを実現してください。