数万件のデータを saveAll() で保存したら、やたら遅い。ログを見るとINSERT文が件数分だけずらっと並んでいる。こういう経験、ありますよね。
saveAll() という名前からは「まとめて保存してくれそう」な雰囲気がしますが、デフォルト設定のままだと実は1件ずつINSERTを投げています。この記事では、なぜバッチINSERTが効かないのか、どう設定すれば効くのかを、SQLログで確認しながら順番に潰していきます。
saveAll()は本当にバッチINSERTしているのか
まず現状を疑うところから始めましょう。saveAll() はコレクションを受け取りますが、内部では単に1件ずつ save() を呼んでいるだけで、JDBCのバッチ機能とは無関係です。バッチを有効化していなければ、往復のたびに1つのINSERTがDBへ飛びます。
確認は発行SQLを数えるのが一番はっきりします。開発時なら show-sql で十分です。
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
1万件保存してINSERTログが1万行出ていれば、バッチはまったく効いていません。もう少し正確に「何回JDBCへ往復したか」を見たいときは、p6spyのようなSQLロガーやHibernateの統計(generate_statistics)を使うと、バッチの実行回数まで追えます。ここが後で効果を検証するときの基準になります。
hibernate.jdbc.batch_size を設定する
バッチINSERTを有効にする最初の一歩がこれです。spring.jpa.properties プレフィックスを付けて、Hibernateへプロパティを渡します。
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
batch_size はいくつのステートメントを1回のバッチにまとめるかの上限です。目安は20〜100程度。大きくすればネットワーク往復は減りますが、その分メモリにステートメントを溜め込むので、むやみに1000などにすると逆効果になることもあります。まずは50あたりで実測し、そこから調整するのが現実的です。
ただし、これを書いただけではまだ効かないケースがあります。理由が2つあるので順番に見ていきましょう。
order_inserts / order_updates が必要な理由
JDBCのバッチは「同じSQL文が連続している」ときにまとまります。つまり、User を1件、Order を1件、また User を1件、というように別テーブルのINSERTが交互に並ぶと、そのたびにバッチが分断されてしまうんですね。
そこで order_inserts=true を付けると、Hibernateがフラッシュ時にINSERTをテーブル単位で並べ替えてくれます。同じテーブルへのINSERTがまとまって連続するので、バッチとして束ねられるわけです。更新側も同様に order_updates=true で並べ替わります。
バージョン管理された(@Version 付き)エンティティを一括更新する場合は、hibernate.jdbc.batch_versioned_data=true も併せて有効にしておくと、更新もバッチ対象になります。
IDENTITY採番がバッチINSERTを無効化する仕組み
いちばんハマりやすいのがここです。batch_size も order_inserts も設定したのに、まだ1件ずつ出る。原因はIDの採番戦略であることが多いです。
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
GenerationType.IDENTITY は、DBのAUTO_INCREMENTに採番を任せる方式です。採番される値はINSERTを実行してみないと分かりません。ところがHibernateは、永続化した時点でエンティティのIDを確定させる必要があります。結果として、INSERTを溜め込んで後でまとめる、ということが原理的にできず、バッチが無効化されます。
回避策は採番戦略を変えることです。PostgreSQLやOracleなら SEQUENCE が使えます。
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "user_seq", allocationSize = 50)
private Long id;
allocationSize を大きめにすると、Hibernateがシーケンスをまとめて確保(pooled最適化)し、INSERT前にIDを割り当てられます。これでバッチが束ねられるようになります。
MySQLのようにシーケンスがないDBでは、TABLE 戦略という選択肢もありますが、専用テーブルへのロック競合で遅くなりがちです。AUTO_INCREMENTを維持したまま大量INSERTを速くしたいなら、後述のJdbcTemplateへ切り替えるほうが素直なことも多いです。
PostgreSQLではreWriteBatchedInsertsを併用する
Hibernate側でバッチをまとめても、PostgreSQLのJDBCドライバは標準では複数のINSERT文を1つずつ順に送ります。ここでドライバの書き換え機能を有効にすると、複数のINSERTを1本のマルチVALUES INSERTへ書き換えてくれます。
spring.datasource.url=jdbc:postgresql://localhost:5432/app?reWriteBatchedInserts=true
これを付けると、INSERT INTO users (...) VALUES (...), (...), (...) の形にまとめられ、ネットワーク往復とパース処理が大きく減ります。ポイントは、Hibernateの batch_size 設定とセットで初めて最大の効果が出ることです。片方だけでは中途半端なので、PostgreSQLなら両方入れておきましょう。
JdbcTemplate.batchUpdateとの比較
そもそも永続化コンテキストの管理が要らない、純粋な大量INSERTなら、JPAにこだわらず JdbcTemplate.batchUpdate を使うのが速くて単純です。
jdbcTemplate.batchUpdate(
"INSERT INTO users (name, email) VALUES (?, ?)",
new BatchPreparedStatementSetter() {
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, users.get(i).getName());
ps.setString(2, users.get(i).getEmail());
}
public int getBatchSize() {
return users.size();
}
});
エンティティの変更追跡やカスケード、@Version の楽観ロックといったJPAの機能が要らない場面では、これがいちばん見通しがよく、余計なメモリも使いません。逆に、保存後のエンティティをそのまま更新したり、関連を含めてカスケード保存したい場合はJPAを維持したほうが素直です。適用場面・速度・コードの複雑さで割り切って使い分けましょう。JdbcTemplateの基礎は Spring BootでJdbcTemplateを使う でも扱っています。
設定前後の実測と検証
最後に、効いているかを必ず自分の目で確かめます。見るべきは発行SQL件数と所要時間の2つです。設定前は1万件で1万回の往復、設定後はバッチ回数が数百回に減っていれば成功です。前述の統計ログやp6spyで、バッチが何回実行されたかを確認しましょう。
もう1つ、大量ループでは永続化コンテキストがエンティティを溜め込み続けるので、一定件数ごとに flush() と clear() を挟んでメモリを解放します。
@Transactional
public void bulkInsert(List<User> users) {
for (int i = 0; i < users.size(); i++) {
entityManager.persist(users.get(i));
if (i % 50 == 0) {
entityManager.flush();
entityManager.clear();
}
}
}
batch_size と同じ間隔でflushすると相性がよいです。なお、大量書き込みではコネクションプールも詰まりやすいので、あわせて HikariCPのコネクションプールチューニング も眺めておくと安心です。SELECT側のN+1が気になる人は Spring Data JPAのパフォーマンス最適化 も参考にどうぞ。
まとめ
saveAll() が遅いときは、hibernate.jdbc.batch_size と order_inserts を設定し、IDが IDENTITY 採番になっていないか確認するのが基本の流れです。PostgreSQLなら reWriteBatchedInserts=true も忘れずに。純粋な大量INSERTなら JdbcTemplate.batchUpdate に切り替えるほうがシンプルなこともあります。どの手を打つにしても、発行SQL件数を前後で比べて「本当にバッチが効いているか」を検証する癖をつけておくと、次にハマったときも自分で切り分けられるようになります。