🚀

Spring Data JPAのsaveAllはなぜ遅い? IDENTITY戦略の罠とJdbcTemplateによる高速化

に公開

はじめに

こんにちは。金融システム開発というチームに所属し、バックエンド開発を担当している中村です。
私は、2025年4月に新卒としてウェルスナビに入社し、現在は主に社内業務システムの開発に携わっています。

この記事では、新卒研修を終え、初めて本格的な機能開発に携わる中で直面した「大量データ挿入時のパフォーマンス課題」について書きます。便利なフレームワークの裏側で何が起きているのかを深く理解するきっかけとなったこの経験と、原因調査から JdbcTemplate を使った解決に至るまでの学びをまとめます。

直面した課題:データのインサートがとにかく遅い

私が研修後初めて本格的に担当した機能は、マーケティング部門が行うDM発送業務を自動化する社内システム開発でした。

これまで、DMの発送対象となるお客様のリストアップは、手作業でのデータ抽出と目視による確認に頼っていました。手作業での確認には、どうしてもヒューマンエラーのリスクが潜在的に伴うことに加え、一度に発送できる件数や頻度にも限界があるという課題を抱えていました。

私が開発したシステムは、数万件規模の発送対象者リストを安全かつ正確に取り込み、発送業者向けの送付リストを自動生成することを目的としています。これにより、業務の属人化を解消し、将来的にはより多くのDMを、より高い頻度で発送できる体制を整えることが期待されていました。

このシステムを実現する上で、最初の関門となったのが「一度に数万件の対象者データをデータベースに登録する」という処理でした。
開発当初、エンティティは以下のように定義されており、ごく自然にSpring Data JPAの saveAll メソッドを使ってデータ登録処理を実装しました。

Book.java
@Entity
@Table(name = "books")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String author;

    // ... other fields, constructors, getters and setters
}
BookService.java
@Service
@RequiredArgsConstructor
public class BookService {

    private final BookRepository bookRepository;

    @Transactional
    public void createBooks(List<BookDto> bookDtos) {
        List<Book> books = bookDtos.stream()
                .map(dto -> new Book(dto.getTitle(), dto.getAuthor()))
                .collect(Collectors.toList());
        
        // 数万件のデータを一括保存
        bookRepository.saveAll(books);
    }
}

saveAll にリストを渡す。それはまるで、引越し業者に荷物の詰まった段ボール箱の山を渡して「あとはよろしく!」と頼むようなものです。私は当然、彼らがトラックで一気に運んでくれる(=バルクインサートしてくれる)ものだと思っていました。

しかし、実際の処理速度は期待に反し、まるでトラックを使わずに段ボールを一つ一つ手で運ぶかのように、一件ずつ処理が進んでいるような遅さでした。なぜ、トラック(バルクインサート)は使われなかったのでしょうか?

原因調査:IDENTITY戦略がJDBCバッチ処理を阻害していた

調査を進めると、原因はJPAのID生成戦略である @GeneratedValue(strategy = GenerationType.IDENTITY) にありました。

Spring Data JPAの saveAll は、通常であれば内部でJDBCのバッチ処理を有効にし、複数のINSERT文をまとめてデータベースに送ることでパフォーマンスを最適化してくれます。
具体的には、application.yml などで spring.jpa.properties.hibernate.jdbc.batch_size を設定することで、指定した件数ごとにバッチ処理が行われます。

application.yml
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 1000 # 1000件ごとにバッチ処理

しかし、GenerationType.IDENTITY を指定している場合、このバッチ処理が機能しません。

IDENTITY戦略では、IDの生成をデータベースの自動採番機能(MySQLの AUTO_INCREMENT など)に完全に委ねます。これは、実際にINSERT文がデータベースで実行されるまで、JPA(Hibernate)は採番されるIDを知ることができない、ということを意味します。

一方で、JPAの仕様では、エンティティを永続化(persist)した直後から、そのエンティティはデータベース上のIDを持つ必要があります。

このため、Hibernateは savepersist)が呼ばれるたびに、IDを取得するために即座にINSERT文を実行せざるを得ないのです。結果として、saveAll を使っても、内部的には1件ずつINSERT文が実行され、バッチ処理の恩恵を受けられなかったのです。

解決策の検討

原因がわかったところで、解決策は大きく2つ考えられました。

  1. ID生成戦略を SEQUENCE に変更する

    • SEQUENCE 戦略などに変更すれば、Hibernateはアプリケーション側でIDをまとめて払い出してからINSERT文を実行するため、JDBCバッチ処理が有効になります。JPAの機能の範囲内でパフォーマンス問題を解決するという意味では、これが最も標準的なアプローチと言えるでしょう。
    • しかし、今回はプロジェクト全体の設計方針や、すでに IDENTITY で稼働している他のテーブルとの兼ね合いから、このテーブルだけID生成戦略を変更するのは難しい状況でした。
  2. この処理だけJPA以外の方法で実装する

    • 大量挿入のパフォーマンスが問題になるのはこの特定の箇所だけでした。
    • そこで、通常のCRUD処理は引き続きSpring Data JPAの恩恵を受けつつ、このバルクインサート処理だけを別の方法で実装するアプローチを検討しました。

今回は、影響範囲の少なさや実装のシンプルさから、2番目のアプローチを選択しました。そして、その具体的な手段として JdbcTemplate を採用することにしました。

JdbcTemplateによる解決

JdbcTemplate は、JPAのようにエンティティからSQLを自動生成させるのではなく、開発者がSQLを直接記述してJDBCを細かく制御できるSpringのテンプレートクラスです。これにより、バッチ更新機能を意図どおりに確実に活用できるだけでなく、挿入戦略やバッチサイズなどの細かな設定も柔軟に反映できます。

なぜJdbcTemplateか

  • 確実なバッチ処理: JdbcTemplatebatchUpdate メソッドを使えば、意図通りにバルクインサートを用いたバッチ処理を実行できます。
  • 導入の容易さ: Spring Bootプロジェクトであれば特別なライブラリを追加することなく、すぐに利用できます。
  • JPAとの共存: BookRepository とは別に、バルクインサート専用のクラス(例: BookBulkRepository)を作成することで、既存のJPAを使ったコードに影響を与えることなく新機能を追加できます。

実装とパフォーマンス改善

以下のように JdbcTemplate を使ったクラスを実装しました。

BookBulkRepository.java
@Repository
@RequiredArgsConstructor
public class BookBulkRepository {

    private final JdbcTemplate jdbcTemplate;
    private static final int BATCH_SIZE = 1000;

    public void bulkInsert(List<Book> books) {
        String sql = "INSERT INTO books (title, author) VALUES (?, ?)";
        
        jdbcTemplate.batchUpdate(
            sql,
            books, 
            BATCH_SIZE, 
            (PreparedStatement ps, Book book) -> {
                ps.setString(1, book.getTitle());
                ps.setString(2, book.getAuthor());
        });
    }
}

BookService からはこの BookBulkRepository を呼び出すように変更します。

BookService.java
// ...
private final BookBulkRepository bookBulkRepository;

@Transactional
public void createBooks(List<BookDto> bookDtos) {
    List<Book> books = bookDtos.stream()
            .map(dto -> new Book(dto.getTitle(), dto.getAuthor()))
            .collect(Collectors.toList());
    
    // JdbcTemplateを使ったバルクインサート処理を呼び出す
    bookBulkRepository.bulkInsert(books);
}

この変更により、数万件のデータ挿入にかかっていた時間は当初の1/3~1/5程度にまで劇的に短縮されました。SQLログを確認しても、狙い通りにバルクインサートを用いたバッチ処理が実行されていることを確認できました。

まとめ

今回の経験から、便利な抽象化技術であるSpring Data JPAも、その内部的な仕組みを理解せずに使うと思わぬ落とし穴にはまることがあると学びました。

  • GenerationType.IDENTITY はJDBCバッチ処理を無効化する。
  • 大量データ挿入では、ID生成戦略の見直しや、SQLを直接記述してJDBCを細かく制御できるJdbcTemplateの部分的採用が有効。
  • 技術選定においては、その技術のメリットだけでなく、今回のような制約やトレードオフを正しく理解することが重要。

「フレームワークがよしなにやってくれる」という考えだけでは通用しない、パフォーマンスというシビアな問題に新卒として初めて直面し、非常に学びの多い経験となりました。
原因を深く掘り下げ、技術の特性を理解した上で適切な解決策を導き出すプロセスは、エンジニアとしての大きな一歩になったと感じています。

これからも技術の「なぜ」を大切にする探求心を忘れずに、開発に取り組んでいきたいと思います。

参考資料

WealthNavi Engineering Blog

Discussion