LoginSignup
8
6

More than 1 year has passed since last update.

[Java] JPAでbulk insertをする方法

Last updated at Posted at 2021-09-29

概要

JPAでbulk insertを行いたいのだが、@GeneratedValueを使ってidを自動採番させるとbulk insertができない。@GeneratedValueを使わない場合、primary keyを明示的に入力しなければならないので面倒。

自動採番した上でbulk insertする方法はないのか。
中々情報がないが、JPAの仕様を理解し直すところも含め、なんとか実現方法がないのか調査してみた。

今回はPostgreSQLを使っているが、MySQLでも原因と解決策はほとんど同じである。

↓↓解決策だけ知りたい方は↓↓
解決策

環境

  • OpenJDK 15.0.4
  • Spring Boot 2.5.4
  • org.springframework.data:spring-data-jpa:2.5.4
  • PostgreSQL 9.6.23

bulk insertを有効にするための設定

まずはbulk insertを有効にするために、application.ymlで以下の設定をする。
これらの設定をし repository.saveAll()entityManger.persist() を実行するとbulk insertになる。

  1. jdbc_uriに?reWriteBatchedInserts=trueを付ける。
  2. spring.jpa.properties.hibernate.jdbc.batch_sizeを設定する。
application.yml
spring:
  jpa:
    database: POSTGRESQL
    open-in-view: True
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        order_inserts: true
        order_updates: true
        jdbc:
          batch_size: 100
  datasource:
    url: ${DATABASE_JDBC_URI}?reWriteBatchedInserts=true

batch_sizeとは、bulk insertの単位。
例えば、batch_sizeが2であれば、

insert into users (status, name, id) values ($1, $2, $3)($4, $5, $6)

例えば、batch_sizeが5であれば、

insert into users (status, name, id) values ($1, $2, $3)($4, $5, $6)($7, $8, $9)($10, $11, $12)($13, $14, $15)

のようにクエリが実行される。

@GeneratedValueでprimary keyを自動採番するとbulk insertにならない

概要に書いたとおり、@GeneratedValueを使うとbulk insertにならない。

調べたところ、hibernateのドキュメントにある通り、identifier generatorを使うとJDBCレベルで自動でbulk insertが無効になるらしい。

Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.
https://docs.jboss.org/hibernate/orm/5.0/manual/en-US/html/ch15.html

@SequenceGenerator@GeneratedValueを使うと、insertする前に select nextval ('tablename_id_seq') を実行し、insert対象のprimary keyのidを1レコードずつ決定するので、bulk insertが無効になるようだ。

@GeneratedValueを使ったパターンを検証

@GeneratedValueを使ってbulk insertが無効になるパターンを検証する。
Userというテーブルにbulk insertをする例。

Service

saveAll()を実行し、DB(EntityManager)にinsertする箇所。

UserService
@service
class UserService {
    @Autowired
    private UserRepository userRepository;

    @PostMapping("/")
    public List<UserModel> add(
            @Validated @RequestBody List<UserModel> user
    ) {
        /*
        // userの中身
        List<UserModel> user = Arrays.asList(
                new UserEntity('sample001', true),
                new UserEntity('sample002', true)
        );
        */
        return UserRepository.saveAll(user);
    }
}

public interface UserRepository extends JpaRepository<UserModel, Long> {
}

Model

@GeneratedValueを使ってprimary keyのidを自動採番する。

UserModel
@Entity
@Table(name="users")
public class UserModel {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;
    public String name;
    public Boolean status;
}

実行結果

postgresのqueryログを確認する。

COMMIT回数は一回だが、insert into userss (status, name, id) values ($1, $2, $3),($4, $5, $6) のような形式になっておらず、1レコードずつinsertが実行されている。

LOG:  execute <unnamed>: BEGIN
LOG:  execute <unnamed>: select nextval ('users_id_seq')
LOG:  execute <unnamed>: select nextval ('users_id_seq')
LOG:  execute S_3: insert into users (status, name, id) values ($1, $2, $3)
DETAIL:  parameters: $1 = 't', $2 = 'sample001', $3 = '1'
LOG:  execute S_3: insert into users (status, name, id) values ($1, $2, $3)
DETAIL:  parameters: $1 = 't', $2 = 'sample002', $3 = '2'
LOG:  execute S_2: COMMIT

GenerationType.IDENTITYを使うとダメで、GenerationType.SEQUENCE@TableGeneratorを使えば解決できるという情報もあったが、試したところどちらもbulk insertにならなかった。

SEQUENCEを使った例
ちなみにSEQUENCEはMySQLでは利用できない。

    @Id
    @SequenceGenerator(name = "users_id_seq", sequenceName = "users_id_seq", allocationSize = 1)
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_id_seq")
    public Long id;

TableGeneratorを使った例
こちらは自動採番用のテーブルを自作する方法。postgresのテーブル名_id_seqを自作する感じ。

    @Id
    @TableGenerator(
            name = "seqTable",
            table = "seq_table",
            pkColumnName = "seq_name",
            pkColumnValue = "parent_seq",
            valueColumnName = "seq_value",
            initialValue = 1,
            allocationSize = 1
    )
    @GeneratedValue(strategy = GenerationType.TABLE, generator="seqTable")
    public Long id;

@GeneratedValueを使わないパターン

@GeneratedValueでprimary keyを自動採番せず、明示的にprimary keyのidを指定する方法。

Controller

primary keyのidを明示的に指定してsaveAll()する。

UserService
@service
class UserService {
    @Autowired
    private UserRepository userRepository;

    @PostMapping("/")
    public List<UserModel> add(
            @Validated @RequestBody List<UserModel> user
    ) {
        // 明示的に採番する
        user.get(0).setId(1L);
        user.get(1).setId(2L);
        /*
        // userの中身
        List<UserModel> user = Arrays.asList(
                new UserEntity(1L, 'sample001', true),
                new UserEntity(2L, 'sample002', true)
        );
        */
        return UserRepository.saveAll(user);
    }
}


public interface UserRepository extends JpaRepository<UserModel, Long> {
}

Model

@GeneratedValueを使わない。

UserModel
@Entity
@Table(name="users")
public class UserModel implements Persistable<Long> {
    @Id
    public Long id;
    public String name;
    public Boolean status;

    public void setId(Long id) {
        this.id = id;
    }
}

実行結果

postgresのqueryログを確認する。

insert into userss (status, parent_id, name, id) values ($1, $2, $3),($4, $5, $6) のような形式になっているので、bulk insertになっている。

LOG:  execute <unnamed>: BEGIN
LOG:  execute <unnamed>: insert into userss (status, name, id) values ($1, $2, $3),($4, $5, $6)
DETAIL:  parameters: $1 = 't', $2 = 'sample003', $3 = '1', $4 = 't', $5 = 'sample004', $6 = '2'
LOG:  execute S_2: COMMIT

idの入力を省略することでDBの機能で自動で採番されないのか

通常SQLでinsert文を書くとき、以下のようにprimary keyを省略すると、DBが自動で採番してくれる。

-- idカラムがprimary keyであるとき、insertでidを省略すると自動採番される
insert into users (name) values('sample001');

同じことができないのだろうか。

しかしJPAではModelで定義している全てのカラムに値をセットしてsaveしないと以下のエラーが発生しinsertできない。
@column(insertable = false) を使ってidの入力を省略できるが、結局idが自動生成されないので以下のエラーが発生してダメ。

 ids for this class must be manually assigned before calling save()

EntityManagerを使う

EntityManagerを使ったところで、idが自動生成されるところが問題なので、解決にはならなかった。

UserService
@service
class UserService {
    @Autowired
    private UserRepository userRepository;

    @PersistenceContext
    private EntityManager entityManager;
    private EntityManagerFactory entityManagerFactory;
    private EntityTransaction txn;

    @PostMapping("/")
    public List<UserModel> add(
            @Validated @RequestBody List<UserModel> user
    ) {
        EntityManager em = entityManager.getEntityManagerFactory().createEntityManager();

        this.txn = em.getTransaction();
        this.txn.begin();

        int batchSize = 100;

        List<UserModel> entities = user;

        for ( int i = 0; i < entities.size(); ++i ) {
            em.persist( entities.get(i) );

            if ( i % batchSize == 0 ) {
                // flush a batch of inserts and release memory
                em.flush();
                em.clear();
            }
        }

        this.txn.commit();
}

public interface UserRepository extends JpaRepository<UserModel, Long> {
}

insert前のselectを実行しない方法

JPAのinsert文の前にselect文が発行されるのを無効にする方法もある。
しかしこの方法をとっても、selectを実行して最新のprimary keyを取得する処理がなくなるので、結局idを手動入力にしなければならないことには変わりないので解決にはならない。

insert前にselectを実行しないようにする例。
PersistableをextendsしてisNew()を実装する。

UserModel
@Entity
@Table(name="users")
@NoArgsConstructor
@AllArgsConstructor
public class UserModel implements Persistable<Long> {
    @Id
    public Long id;
    public String name;
    public Boolean status;

    public void setId(Long id) {
        this.id = id;
    }

    @Override
    public Long getId() {
       return this.id;
    }

    @Override
    public boolean isNew() {
        // trueの場合、insert前にselectを実行しない
        // falseの場合、selectを実行してからupdate or insert
        return true;
    }
}

解決策

色々調べたがよい方法が見つからないので、少し力技だが、以下の方法で解決することにした。

  • @GeneratedValueは使わない
  • primary keyのidの現在の最新の値をinsert前に取得する
    • select id from users order by id desc limit 1;
  • 取得した最新のidに1を足した値を開始値とし、insertするレコードの数だけincrementしてsetIdをする

repository

repositoryに以下を定義する。

UserRepository
public interface UserRepository extends JpaRepository<UserModel, Long> {
    // 現在の最新のidを取得する
    @Query(value = "select id from users order by id desc limit 1;", nativeQuery = true)
    public int fetchCurrentVal();

    // postgres専用。sequenceの値更新する。MySQLの場合は不要。
    @Query(value = "select setval('users_id_seq', ?1)", nativeQuery = true)
    public void setCurrentVal(int currentVal);
}

Service

EntityMangerを使って登録。

UserService
@service
class SampleController {
    @Autowired
    private UserRepository userRepository;

    @PersistenceContext
    private EntityManager entityManager;
    private EntityManagerFactory entityManagerFactory;
    private EntityTransaction txn;

    @Transactional
    @PostMapping("/")
    public List<UserModel> add(
            @Validated @RequestBody List<UserModel> user
    ) {
        // 現在の最新のprimary keyのidを取得
        int currentVal = userRepository.fetchCurrentVal();

        EntityManager em = entityManager.getEntityManagerFactory().createEntityManager();

        this.txn = em.getTransaction();
        this.txn.begin();

        List<UserModel> entities = user;

        Long id;
        for (int i = 0; i < entities.size(); ++i) {
            // setするid
            id = (long) (currentVal + i + 1);

            // primary keyのidをset
            entities.get(i).setId(id);
            // insertする
            em.persist(entities.get(i));

            /*
            // application.ymlでbatch_sizeを指定していれば不要
            if ( i % 100 == 0 && i > 100) {
                //flush a batch of inserts and release memory
                em.flush();
                em.clear();
            }
            */
        }

        // sequenceテーブルを更新。postgresの場合のみ実行。
        UserRepository.setCurrentVal(currentVal + entities.size());

        // commit
        this.txn.commit();
}

public interface UserRepository extends JpaRepository<UserModel, Long> {
}

結果

bulk insertはできるようになったはなったのだが・・・

以下は7レコード一括登録したときのpostgresのログだが、batch_sizeを100としたのに、100よりも少ない数に分割され、bulk insertが複数回実行されている。

LOG:  execute <unnamed>: BEGIN
LOG:  execute <unnamed>: select id from users order by id desc limit 1
LOG:  execute <unnamed>: BEGIN
LOG:  execute <unnamed>: insert into users (status, name, id) values ($1, $2, $3),($4, $5, $6),($7, $8, $9),($10, $11, $12)
DETAIL:  parameters: $1 = 't', $2 = 'sample001', $3 = '1', $4 = 't', $5 = 'sample002', $6 = '2', $7 = 't', $8 = 'sample003', $9 = '3', $10 = 't', $11 = 'sample004', $12 = '4'
LOG:  execute <unnamed>: insert into users (status, name, id) values ($1, $2, $3),($4, $5, $6)
DETAIL:  parameters: $1 = 't', $2 = 'sample005', $3 = '5', $4 = 't', $5 = 'sample006', $6 = '6'
LOG:  execute <unnamed>: insert into users (status, name, id) values ($1, $2, $3)
DETAIL:  parameters: $1 = 't', $2 = 'sample007', $3 = '7'
LOG:  execute S_1: COMMIT
LOG:  execute S_2: COMMIT

JPAの仕様、ライフサイクルについて

RedHatのドキュメントで以下のように、「明示的にflush()を実行したとき以外の、EntityManagerがJDBC呼び出しを実行するタイミングについての保証はまったくない、実行される順序のみが保証される」、と書いてある。
(HibernateはRed Hat社によって提供されるオープンソースのJPA実装)

Except when you explicity flush(), there are absolutely no guarantees about when the entity manager executes the JDBC calls, only the order in which they are executed. However, Hibernate does guarantee that the Query.getResultList()/Query.getSingleResult() will never return stale data; nor will they return wrong data if executed in an active transaction.
https://access.redhat.com/documentation/en-us/red_hat_jboss_web_server/1.0/html/hibernate_entity_manager_reference_guide/ch04s09

つまり、JPAで登録、更新、削除を行うと、PersistenceContextに変更内容が保持されるが、DBに反映されるタイミングはアプリケーションからは分からない。PersistenceContextにデータが保持されたが、直接DBを見てみるとレコードがまだ登録されていないといったことも起こりうる。しかし、参照もPersistenceContextから行うので、DBに未反映であっても、登録直後でもデータは参照できるし、削除されたデータは参照できなくなる。というのがJPAの仕様である。ちなみにflush()を使うことで明示的にDBに反映させるといったことも可能。

PersistenceContextの中身は、EntityのIDをキーとし、値にEntityをセットしたMapのようなデータ構造となっている。

dataaccess_jpa_lifecycle.png
引用元: データベースアクセス(JPA編)

また、PersistenceContextはメモリ上に保持されるため、大量のデータを一度に扱うとOut of Memoryを引き起こす可能性がある。そのため、必要であれば、都度clear()を実行して明示的にキャッシュクリアを行う必要がある。
参考:メモリを逼迫させずにJPAで大量データを取得する方法

ただし、むやみにclear()だけを実行すると、まだPersistenceContextからDBに保存されていないデータが破棄されてしまう可能性があるため、必ずflush()を実行してからclear()を実行するように、この2つはセットで使うこと。また、PersistenceContextからclearされた後にJpaRepository#find()などを呼び出すと、DBとPersistenceContextで同期が行われるようになっている。

bulk insertが分割される原因の考察

bulk insertが分割される話に戻るが、

つまり上記のコードの例では、persist()はEntityに登録をするが、実際にDBに登録されるタイミングは任意であるため、仮にbatch_sizeを100に設定していても、Entityは任意のタイミングでDBに反映を行っていく。

つまり、レコード数(persistが呼び出される回数)が100に達していない状態でも、都度任意のタイミングでDBへの反映が実施されていく。そのため、任意のタイミングで分割されbulk insertが複数回実行されるのだと思われる。(定期的にflush()clear()を実施しないとOOMになる可能性があるからそういう仕様なのだろうか)

ちなみに、指定したbatch_sizeの値を上回った数でのbulk insertは実行されない模様。試しにbatch_sizeを1にしたら、何度実行しても1レコードずつのinsertになることをpostgresのクエリログで確認した。

つまり、batch_sizeは上限とはなるが、下限とはならない。

結論

完全なbulk insertはできなそう。

また、大量データを取り扱うような場合、JPAは向いていない。その場合は確実にbulk insertがされるORマッパー、もしくは方法をとったほうがよい。

JPAの仕様をしっかり理解してから使わないと、色々混乱の元となるため注意。

参考サイト

bulk insertについて

以下のサイトに書いてあることは全て試したが、@GeneratedValueを使った上でのbulk inserはできなかった。
GenerationType.IDENTITYではできないが、 GenerationType.SEQUENCEならできるというようなことを書いてあるサイトもあったが、どちらでもできなかった。

JPAについて

JPAのflushについて

インピーダンスミスマッチについて

JPQL, Native Queryとか

Native QueryはPersistentContextを参照せずに直接DBを操作するらしい。パフォーマンスに影響するかもしれないので注意。

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6