メモリを逼迫させずにJPAで大量データを取得する方法
JPA(Java Persistence API)は、データベースから取得したデータをメモリ上に保持する仕様になっている。
そのため、不用意に大量のデータを取得すると、メモリ容量を圧迫してしまい、最悪の場合はOutOfMemoryError
が起きる可能性がある。
本稿では、JPAの標準機能およびネイティブ機能のそれぞれについて、メモリ使用量を抑えながら大量データを取得する方法と、ネイティブ機能がうまく動作しない場合の回避策を紹介する。
JPAは読み込んだデータを一次キャッシュとして保持する
JPAは、読み込んだEntity
をPersistenceContext
(永続化コンテキスト)と呼ぶ領域で管理する。
ここに格納された状態を一次キャッシュと呼び、一次キャッシュされた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
である。
JPAとScrollableResults
の違いは次の通り。
JPA
データの取得時に全てのレコードが読み込まれ、一次キャッシュに格納されるScrollableResults
ScrollableResults#get
を呼び出してEntity
を取得したタイミングで対象データが読み込まれ、一次キャッシュに格納される。
ScrollableResults
は、java.sql.ResultSet
と似ているが、キャッシュの扱いは異なる。すなわちScrollableResults
の場合は、JPAと同様にEntity
を取得するたびにPersistenceContext
に一次キャッシュとして格納されてしまう。このため、先ほどのように、処理済みのEntity
はEntityManager#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
が発生した。
この問題が発生する条件は次の通り。
- アプリケーションサーバー:WebSphere Application Server 8.0.0.5
- transaction-type:JTA
- 宣言的トランザクション(@Transactional):指定しない
簡単な回避策は、@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(); } }
この仕組みを利用すれば、一次キャッシュのクリア処理や、トランザクション宣言の考慮は不要になる。
[高橋 友樹]