数万件のデータを 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_sizeorder_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_sizeorder_inserts を設定し、IDが IDENTITY 採番になっていないか確認するのが基本の流れです。PostgreSQLなら reWriteBatchedInserts=true も忘れずに。純粋な大量INSERTなら JdbcTemplate.batchUpdate に切り替えるほうがシンプルなこともあります。どの手を打つにしても、発行SQL件数を前後で比べて「本当にバッチが効いているか」を検証する癖をつけておくと、次にハマったときも自分で切り分けられるようになります。