JPAで少しずつデータを処理する方法を考える

OutOfMemoryErrorの主な要因例として、DBMSからデータを取得しすぎがあります。

LASYフェッチによるN + 1 問題を回避するために、結合先テーブルの要素を一気に持ってくるJOIN FETCHを使ったところ、引き換えにJavaヒープ使用量が多くなるのはよくあるケースです。

以下のような、1つのIssueに対して複数のIssueAttributeを持つ1対多関連のエンティティの操作を例に、OutOfMemoryErrorを少しでも避ける方法を考えてみます。

@Entity
public class Issue implements Serializable {
    private static final long serialVersionUID = 1L;
    
    @Id
    @GeneratedValue
    private int issueId;
    private String title;
    
    @OneToMany
    @JoinColumn(name = "issueId")
    private List<IssueAttribute> attributes = new ArrayList<>();

ページングして少しずつ取ってくる

JPAではHibernate固有のScrollableResultsのようなAPIがないため、SQLのoffsetを示すsetFirstResult()とlimitを示すsetMaxResult()を組み合わせてページングします。後述していますが、JOIN FETCHではsetFirstResult()とsetMaxResult()の組み合わせが効かないため、普通のJOINを使っています。

private static final int FETCH_SIZE = 100;
// 途中省略 ...
String sql = "SELECT i FROM Issue i JOIN i.attributes ia";
int offset = 0;
List<Issue> chunk = pagingQuery(em.createQuery(sql), offset, FETCH_SIZE);
while (!chunk.isEmpty()) {
    for (Issue i : chunk) {
        for (IssueAttribute ia : i.getAttributes()) {
            // なんらかの処理
        }
    }
    // ページングによって取得したエンティティをデタッチ
    em.flush();
    em.clear();
    offset += FETCH_SIZE;
    chunk = pagingQuery(em.createQuery(sql), offset, FETCH_SIZE);
}

private List<Issue> pagingQuery(Query query, int offset, int limit) {
    query.setFirstResult(offset).setMaxResults(limit);
    return query.getResultList();
}
@BatchSizeの設定

JOINでは結合先テーブルのエンティティがLASYフェッチになってしまうため、N + 1問題のように関連先の要素を取得するSQLが多く発行されてしまいます。Hibernate固有の機能ですが@BatchSizeを使うと、関連先エンティティ取得SQLの実行回数を減らすことができます。

@Entity
public class Issue implements Serializable {

    @OneToMany
    @JoinColumn(name = "issueId")
    @org.hibernate.annotations.BatchSize(size = 100)
    private List<IssueAttribute> attributes = new ArrayList<>();

LASYフェッチでは1つのIssueごとに関連先のIssueAttributeを取得するSQLが実行されますが、上記のように@BatchSizeを設定した場合、100個のIssueエンティティごとに関連先のIssueAttribute取得するようになります。

JOIN FETCH ではページングがうまくいかない

手元のHibernate4.3.5&PostgreSQL9.3ではJOIN FETCHとsetFirstResult()およびsetMaxResult()を組み合わせると、以下のような警告が出力され、offsetとlimitが投げられるSQLに反映されずに一気にデータを取ってしまい、意図したようにページングができません。

19:28:32,647 WARN  [org.hibernate.hql.internal.ast.QueryTranslatorImpl] (default task-1) HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
19:28:32,648 INFO  [stdout] (default task-1) Hibernate: /* SELECT i FROM Issue i JOIN FETCH i.attributes ia */ select issue0_.issueId as issueId1_1_0_, attributes1_.attributeName as attribut1_2_1_, attributes1_.issueId as issueId2_2_1_, issue0_.priority as priority2_1_0_, issue0_.reporter_id as reporter4_1_0_, issue0_.title as title3_1_0_, attributes1_.attributeValue as attribut3_2_1_, attributes1_.issueId as issueId2_1_0__, attributes1_.attributeName as attribut1_2_0__, attributes1_.issueId as issueId2_2_0__ from Issue issue0_ inner join IssueAttribute attributes1_ on issue0_.issueId=attributes1_.issueId

JPA2.1仕様では、fetch joinとsetMaxResultsまたはsetFirstResultの併用時の挙動は定義しないとされています。

The effect of applying setMaxResults or setFirstResult to a query involving fetch joins over collections is undefined.

3.10.7 QueryExecution

StackOverFlowにこのWARNに関するQ&Aがあり、何行とってくるとエンティティが組み立てられるかわからないので、とりあえず毎回全部取ってくる実装になっているようです。

エンティティをこまめにデタッチする

JOIN FETCHをしながら少しでもJavaヒープの消費を抑えるには、EntityManager.clear()を使って少しでも処理済みの不要なエンティティをデタッチする方法もよく見かけますが、効果は限定的です。

String sql = "SELECT i FROM Issue i JOIN FETCH i.attributes ia";
// ↓ ここでヒープ消費が最大になる場合では効果薄
List<Issue> issues = em.createQuery(sql).getResultList();
int i = 0;
for (Issue issue : issues) {
    for (IssueAttribute ia : issue.getAttributes()) {
        // ここの処理が時間がかかったり、
        // この中でたくさんオブジェクトを展開する場合は効果あり
    }
    i++;
    if (i % 1000 == 0) {
        em.flush();
        em.clear();
    }
}

getResultList()を実行した行で、既に取得した全てのエンティティがJavaヒープに展開されているため、この後でclear()しても最大消費量を抑えることはできません。取得したデータを処理する段階で、時間がかかったり、たくさんオブジェクトを展開する必要がある場合であれば効果が得られると思います。

補足 LasyInitializationException

@OneToManyのデフォルトやJPQLのJOINによって行われるLASYフェッチを使用してた場合、処理の途中でEntityManager.clear()を実行すると、以下のような例外を招きます。

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: net.agetsuma.jpatest.entity.Issue.attributes, could not initialize proxy - no Session

まとめ

  • setFirstResult()とsetMaxResult()を組み合わせてページングする
  • ページングとJOIN FETCHは相性が悪い
  • そのため普通のJOINを使うが、N + 1問題を防ぐためにHibernate固有機能の@BatchSizeを併用する
  • EntityManager.clear()でこまめにデタッチ方法は効果が限定的

いろいろとハマったので、ページングに関する仕様がJPAに入ると嬉しいなと思う、今日この頃です。