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