1. 概要

Hibernateで遅延読み込みを使用しているときに、セッションがないと言って例外に直面する可能性があります。

このチュートリアルでは、これらの遅延読み込みの問題を解決する方法について説明します。 これを行うには、Spring Bootを使用して例を調べます。

2. 遅延読み込みの問題

遅延読み込みの目的は、メインオブジェクトを読み込むときに、関連するオブジェクトをメモリに読み込まないことでリソースを節約することです。 代わりに、レイジーエンティティの初期化を必要になるまで延期します。 Hibernateはプロキシとコレクションラッパーを使用して遅延読み込みを実装します。

遅延ロードされたデータを取得する場合、プロセスには2つのステップがあります。 まず、メインオブジェクトにデータを入力し、次に、そのプロキシ内のデータを取得します。 データをロードするには、常にHibernateで開いているセッションが必要です。

この問題は、トランザクションが閉じた後に2番目のステップが発生したときに発生しLazyInitializationExceptionが発生します。

推奨されるアプローチは、データの取得が1回のトランザクションで確実に行われるようにアプリケーションを設計することです。 ただし、ロードされたものとロードされていないものを判別できないコードの別の部分でレイジーエンティティを使用する場合、これは難しい場合があります。

Hibernateには回避策、enable_lazy_load_no_transプロパティがあります。 これをオンにすると、レイジーエンティティの各フェッチが一時セッションを開き、個別のトランザクション内で実行されることを意味します。

3. 遅延読み込みの例

いくつかのシナリオでの遅延読み込みの動作を見てみましょう。

3.1. エンティティとサービスを設定する

UserDocumentの2つのエンティティがあるとします。 1つのユーザーには多数のドキュメントが含まれる場合があり、その関係を説明するために@OneToManyを使用します。 また、効率を上げるために @Fetch(FetchMode.SUBSELECT)を使用します。

デフォルトでは、@OneToManyには遅延フェッチタイプがあることに注意してください。

次に、Userエンティティを定義しましょう。

@Entity
public class User {

    // other fields are omitted for brevity

    @OneToMany(mappedBy = "userId")
    @Fetch(FetchMode.SUBSELECT)
    private List<Document> docs = new ArrayList<>();
}

次に、さまざまなオプションを説明するために、2つのメソッドを備えたサービスレイヤーが必要です。 それらの1つには、@Transactionalという注釈が付けられています。 ここで、両方のメソッドは、すべてのユーザーからのすべてのドキュメントをカウントすることにより、同じロジックを実行します。

@Service
public class ServiceLayer {

    @Autowired
    private UserRepository userRepository;

    @Transactional(readOnly = true)
    public long countAllDocsTransactional() {
        return countAllDocs();
    }

    public long countAllDocsNonTransactional() {
        return countAllDocs();
    }

    private long countAllDocs() {
        return userRepository.findAll()
            .stream()
            .map(User::getDocs)
            .mapToLong(Collection::size)
            .sum();
    }
}

それでは、次の3つの例を詳しく見てみましょう。 また、 SQLStatementCountValidator を使用して、実行されたクエリの数をカウントすることにより、ソリューションの効率を理解します。

3.2. 周囲のトランザクションでの遅延読み込み

まず、推奨される方法で遅延読み込みを使用しましょう。 したがって、サービスレイヤーで@Transactionalメソッドを呼び出します。

@Test
public void whenCallTransactionalMethodWithPropertyOff_thenTestPass() {
    SQLStatementCountValidator.reset();

    long docsCount = serviceLayer.countAllDocsTransactional();

    assertEquals(EXPECTED_DOCS_COLLECTION_SIZE, docsCount);
    SQLStatementCountValidator.assertSelectCount(2);
}

ご覧のとおり、これは機能し、データベースへの2回のラウンドトリップになります。 最初のラウンドトリップはユーザーを選択し、2番目のラウンドトリップはユーザーのドキュメントを選択します。

3.3. トランザクション外での遅延読み込み

次に、非トランザクションメソッドを呼び出して、周囲のトランザクションなしで発生するエラーをシミュレートしましょう。

@Test(expected = LazyInitializationException.class)
public void whenCallNonTransactionalMethodWithPropertyOff_thenThrowException() {
    serviceLayer.countAllDocsNonTransactional();
}

予想どおり、UsergetDocs関数がトランザクションの外部で使用されるため、このはエラーになります。

3.4. 自動トランザクションによる遅延読み込み

これを修正するには、プロパティを有効にします。

spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true

プロパティをオンにすると、LazyInitializationExceptionが発生しなくなります。

ただし、クエリの数は、データベースに対して6回のラウンドトリップが行われたことを示しています。 ここでは、1回のラウンドトリップでユーザーが選択され、5回のラウンドトリップで5人のユーザーごとにドキュメントが選択されます。

@Test
public void whenCallNonTransactionalMethodWithPropertyOn_thenGetNplusOne() {
    SQLStatementCountValidator.reset();
    
    long docsCount = serviceLayer.countAllDocsNonTransactional();
    
    assertEquals(EXPECTED_DOCS_COLLECTION_SIZE, docsCount);
    SQLStatementCountValidator.assertSelectCount(EXPECTED_USERS_COUNT + 1);
}

悪名高いN+1の問題に遭遇しましたが、それを回避するためにフェッチ戦略を設定しました。

4. アプローチの比較

長所と短所について簡単に説明しましょう。

プロパティがオンになっているので、トランザクションとその境界について心配する必要はありません。Hibernateがそれを管理します。

ただし、Hibernateはフェッチごとにトランザクションを開始するため、ソリューションの動作は遅くなります。

デモやパフォーマンスの問題を気にしない場合に最適です。 これは、1つの要素のみを含むコレクション、または1対1の関係にある単一の関連オブジェクトをフェッチするために使用される場合は問題ない可能性があります。

プロパティがなければ、トランザクションをきめ細かく制御できます。パフォーマンスの問題に直面することはなくなりました。

全体として、これは本番環境に対応した機能ではありません。Hibernateのドキュメントでは次のように警告されています。

この構成を有効にすると、 LazyInitializationException がなくなる可能性がありますが、セッションを閉じる前にすべてのプロパティが適切に初期化されることを保証するフェッチプランを使用することをお勧めします。

5. 結論

このチュートリアルでは、遅延読み込みの処理について説明しました。

LazyInitializationException を克服するために、Hibernateプロパティを試しました。 また、効率が低下し、限られた数のユースケースでのみ実行可能なソリューションになる可能性があることもわかりました。

いつものように、すべてのコード例はGitHubから入手できます。