エンタープライズギークス (Enterprise Geeks)

企業システムの企画・開発に携わる技術者集団のブログです。開発言語やフレームワークなどアプリケーション開発に関する各種情報を発信しています。ウルシステムズのエンジニア有志が運営しています。

メモリを逼迫させずにJPAで大量データを取得する方法

JPAJava Persistence API)は、データベースから取得したデータをメモリ上に保持する仕様になっている。
そのため、不用意に大量のデータを取得すると、メモリ容量を圧迫してしまい、最悪の場合はOutOfMemoryErrorが起きる可能性がある。
本稿では、JPAの標準機能およびネイティブ機能のそれぞれについて、メモリ使用量を抑えながら大量データを取得する方法と、ネイティブ機能がうまく動作しない場合の回避策を紹介する。

JPAは読み込んだデータを一次キャッシュとして保持する

JPAは、読み込んだEntityPersistenceContext(永続化コンテキスト)と呼ぶ領域で管理する。
ここに格納された状態を一次キャッシュと呼び、一次キャッシュされたEntityをManagedな状態(JPAに管理された状態)と呼ぶ(なお、二次キャッシュもあるが割愛する)。
PersistenceContextは、キーにEntityのIDを、値にEntityをセットしたMap<String, Object>のようなデータ構造である。

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
...
public class ProductService {

  @PersistenceContext
  private EntityManager entityManager;

  public void loadAll() {
    CriteriaBuilder builder = entityManager.getCriteriaBuilder();
    CriteriaQuery<ProductEntity> criteriaQuery
        = builder.createQuery(ProductEntity.class);
    Query query = entityManager.createQuery(criteriaQuery);

    List<ProductEntity> products = query.getResultList();
    for (ProductEntity product : products) {
      LOG.debug("ProductEntity: " + entity);
    }
  }
}

上記のコードを実行すると、QueryオブジェクトのgetResultListを呼び出した時点で、データベースに格納されている全レコードがメモリに展開されてしまう。レコード件数が少なければ問題ないが、大量のレコードが格納されているテーブルの場合には、メモリを大きく消費してしまう。

一次キャッシュをクリアしながら段階的にクエリーを実行する

この問題に対処する方法の一つとして、段階的にクエリーを実行する方法がある。

次のように、件数を絞ってクエリーを実行し、定期的にEntityManager#clearを呼び出せば、PersistenceContextに格納された一次キャッシュをクリアできる。

public class ProductService {

  @PersistenceContext
  private EntityManager entityManager;

  public void loadAll() {
    CriteriaBuilder builder = entityManager.getCriteriaBuilder();
    CriteriaQuery<ProductEntity> criteriaQuery
        = builder.createQuery(ProductEntity.class);
 
    int offset = 0;
    Query query = entityManager.createQuery(criteriaQuery);
    List<ProductEntity> products;
    // 1000件ずつに件数を絞る
    while ((products = query.setFirstResult(offset)
                            .setMaxResults(1000)
                            .getResultList())
                            .size()
                            > 0) {
      for (ProductEntity product : products) {
        LOG.debug("ProductEntity: " + entity);
      }
      entityManager.clear(); // 一次キャッシュのクリア
      offset += products.size();
    }
  }

上記のコードでは、クエリー実行時にQueryオブジェクトに対して、setFirstResult()setMaxResults()を呼び出すことで、1回に読み込むデータ量を制御している。そして、読み込んだデータの処理が終わったあとでEntytManager#clearを呼び出すことで、一次キャッシュをクリアしている。

ネイティブ機能を利用して全件取得を実行する

上の方法では、コードが煩わしくなることに加えて、指定した件数分だけのメモリを使ってしまう。
これを避ける方法としては、JPAのインタフェースを使わずに、実装ライブラリ(Hibernateなど)のネイティブ機能を利用する方法がある。
Hibernateの代替機能は、ScrollableResultsである。

JPAScrollableResultsの違いは次の通り。

  • JPA
    データの取得時に全てのレコードが読み込まれ、一次キャッシュに格納される

  • ScrollableResults
    ScrollableResults#getを呼び出してEntityを取得したタイミングで対象データが読み込まれ、一次キャッシュに格納される。

ScrollableResultsは、java.sql.ResultSetと似ているが、キャッシュの扱いは異なる。すなわちScrollableResultsの場合は、JPAと同様にEntityを取得するたびにPersistenceContextに一次キャッシュとして格納されてしまう。このため、先ほどのように、処理済みのEntityEntityManager#clearを呼び出してクリアする必要がある。

以下にScrollableResultsを使用して1000件毎に一次キャッシュをクリアする処理コードを紹介する。

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.hibernate.ScrollableResults;
...
public class ProductService {

  @PersistenceContext
  private EntityManager entityManager;

  // 大量データを取得する検証メソッド
  public void loadAll() {
    CriteriaBuilder builder = entityManager.getCriteriaBuilder();
    CriteriaQuery<ProductEntity> criteriaQuery
        = builder.createQuery(ProductEntity.class);

    Query query = entityManager.createQuery(criteriaQuery);
    org.hibernate.Query hibernateQuery
        = query.unwrap(org.hibernate.Query.class);
    ScrollableResults results = hibernateQuery.scroll();

    int count = 0;
    while (results.next()) {
      count++;
      ProductEntity entity = (ProductEntity) results.get(0);
      LOG.debug("ProductEntity: " + entity);
      if (count % 1000 == 0) {
        // 1000件毎に解放
        entityManager.clear();
      }
    }
    results.close();
  }
}

ネイティブ機能がうまく動作しない場合の回避策

このコードは基本的に正しく動作するはずだが、我々が関わったあるシステムでは正しく動作しなかった。
すなわち、EntityManager#clear()を呼び出しても一次キャッシュがクリアできず、OutOfMemoryErrorが発生した。

この問題が発生する条件は次の通り。

簡単な回避策は、@Transactionalを指定することである。
しかし、大量データを参照するアプリケーションに対してトランザクションを指定すると、タイムアウト時の例外ハンドリングについて考慮する必要が出てくる。安易にタイムアウト時間を延ばせば、本来のトランザクション処理にも影響するだろう。

一次キャッシュを利用しないScrollableResultsの利用

この問題の回避策として、一次キャッシュしないScrollableResultsを生成する方法がある。

このScrollableResultsは、org.hibernate.Sessionの代わりに、org.hibernate.StatelessSessionを利用することで取得できる。
実装例は次の通り。

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.hibernate.ScrollableResults;
...
public class ProductService {

  @PersistenceContext
  private EntityManager entityManager;

  // 大量データを取得する検証メソッド
  public void loadAll() {
    CriteriaBuilder builder = entityManager.getCriteriaBuilder();
    CriteriaQuery<ProductEntity> criteriaQuery
        = builder.createQuery(ProductEntity.class);

    // QueryではなくTypedQueryを使用
    TypedQuery<MyEntity> typedQuery
        = entityManager.createQuery(criteriaQuery);

    // HibernateのAPIを使用するためにダウンキャスト
    AbstractQueryImpl queryImpl = typedQuery
        .unwrap(AbstractQueryImpl.class);
    org.hibernate.ejb.AbstractQueryImpl<T> ejbQuery = typedQuery
        .unwrap(org.hibernate.ejb.AbstractQueryImpl.class);

    // Query文字列の取得
    String namedSql = queryImpl.getQueryString();

    // HibernateのStatelessSessionを取得
    // 補足 ////////////////////////////////////////////
    // StatelessSession は関連する永続コンテキストを持た
    // ず、一次キャッシュを実装していない。
    // (JPA)PersistenceContextにEntityオブジェクトをキャッ
    // シュしないDetachedなEntityなので、
    // EntityManager#clear()を呼ぶ必要がない。
    // つまり、Entityオブジェクトがメモリに蓄積されない。
    ////////////////////////////////////////////////////
    StatelessSession statelessSession = ((Session)em.getDelegate())
        .getSessionFactory().openStatelessSession();
 
    // StatelessSessionでQueryを再生成
    org.hibernate.Query hibernateQuery = statelessSession
        .createQuery(namedSql);

    // パラメタのバインド
    for (String name : (Set<String>) queryImpl.getParameterMetadata()
                                              .getNamedParameterNames()) {
      Object value = ejbQuery.getParameterValue(name);
      hibernateQuery.setParameter(name, value);
    }

    // #scroll()を実行、ScrollablrResultsを取得する。
    // 次の設定がメモリ使用量と応答時間が良かったので採用している。
    ScrollableResults results = hibernateQuery.setReadOnly(true)
        .setCacheable(false).scroll(ScrollMode.FORWARD_ONLY);
    while (results.next()) {
      ProductEntity entity = (ProductEntity) results.get(0);
      LOG.debug("ProductEntity: " + entity);
      // メモリの解放が不要
    }
    results.close();
    statelessSession.close();
  }
}

この仕組みを利用すれば、一次キャッシュのクリア処理や、トランザクション宣言の考慮は不要になる。

[高橋 友樹]