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

## 概要
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`を付ける。
1. spring.jpa.properties.hibernate.jdbc.batch_sizeを設定する。

```yaml: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であれば、

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

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

```sql
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を１レコードずつ決定するので、bulk insertが無効になるようだ。

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

### Service
`saveAll()`を実行し、DB（EntityManager）にinsertする箇所。

```java: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を自動採番する。

```java: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が実行されている。

```sql
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では利用できない。

```java
    @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`を自作する感じ。

```java
    @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()`する。

```java: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`を使わない。

```java: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になっている。

```sql
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が自動で採番してくれる。

```sql
-- 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が自動生成されるところが問題なので、解決にはならなかった。

```java: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を手動入力にしなければならないことには変わりないので解決にはならない。

- [Spring Data JPAのsaveのselect-insertをinsertだけにする](https://qiita.com/kagamihoge/items/4d3a515a56d48afdcc7b)
- [【Spring Data JPA】save・saveAllでselectを省略する方法](https://hosochin.com/2020/08/16/post-589/)

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

```java: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に以下を定義する。

```java: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を使って登録。

```java: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が複数回実行されている。

```sql
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<String, Object>のようなデータ構造となっている。

![dataaccess_jpa_lifecycle.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105936/f0f77a97-8c85-9771-da9e-2af299e519fc.png)
引用元： [データベースアクセス（JPA編）](https://terasolunaorg.github.io/guideline/public_review/ArchitectureInDetail/DataAccessJpa.html)

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

ただし、むやみに`clear()`だけを実行すると、まだPersistenceContextからDBに保存されていないデータが破棄されてしまう可能性があるため、必ず`flush()`を実行してから`clear()`を実行するように、この２つはセットで使うこと。また、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`ならできるというようなことを書いてあるサイトもあったが、どちらでもできなかった。

- [spring-data-jpaでbulk insertするにはentity manager を使うしかないのでしょうか。](https://teratail.com/questions/219176)
- [HowTo: Getting batch inserts to work on MySQL](https://forum.hibernate.org/viewtopic.php?p=2435599)
- [How to do bulk (multi row) inserts with JpaRepository?](https://stackoverflow.com/questions/50772230/how-to-do-bulk-multi-row-inserts-with-jparepository)
- https://github.com/Cepr0/sb-jpa-batch-insert-demo
- https://github.com/amrutprabhu/spring-boot-jpa-bulk-insert-performance


### JPAについて
- [JPAを使ったデータアクセスでポイントとなる永続コンテキストについて](https://ryoasai.hatenadiary.org/entry/20110731/1312128474)
- [はじめてのJPAでちょっと怖い勘違い](https://megane-s24.hatenablog.com/entry/2016/12/06/232051)
- [メモリを逼迫させずにJPAで大量データを取得する方法
JPA Hibernate](https://enterprisegeeks.hatenablog.com/entry/2014/11/24/110723)

## JPAのflushについて
- [永続化コンテキストのデータベースとの同期](https://gist.github.com/momotar/a9c858ca28fcf2d64e3c#:~:text=JPA%E3%81%A7%E3%81%AF%E3%80%81%E4%BB%A5%E4%B8%8B%E3%81%AE3,%E3%81%A7%E3%83%95%E3%83%A9%E3%83%83%E3%82%B7%E3%83%A5%E3%82%92%E5%AE%9F%E8%A1%8C%E3%81%99%E3%82%8B%E3%80%82&text=%E3%83%88%E3%83%A9%E3%83%B3%E3%82%B6%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E3%82%B3%E3%83%9F%E3%83%83%E3%83%88%E3%81%99%E3%82%8B%E7%9B%B4%E5%89%8D,%E3%81%B0SQL%E3%82%92%E7%99%BA%E8%A1%8C%E3%81%99%E3%82%8B%E3%80%82&text=JPQL%E3%82%92%E7%99%BA%E8%A1%8C%E3%81%99%E3%82%8B%E7%9B%B4%E5%89%8D,%E3%81%B0SQL%E3%82%92%E7%99%BA%E8%A1%8C%E3%81%99%E3%82%8B%E3%80%82)
- [[Spring Data JPA] saveAndFlush 後 find しても DB の値が取れない
](https://agohack.com/spring-data-jpa-save-and-flush-entity-manager-refresh/)

### インピーダンスミスマッチについて
- [O/Rマッピングで緩和されるインピーダンスミスマッチには静的と動的の側面がある](https://ryoasai.hatenadiary.org/entry/20110501/1304252041)
- [インピーダンスミスマッチとは？を分かりやすく説明](https://rainbow-engine.com/inpedance-mismatch/)
- [Mapping Objects to Relational Databases: O/R Mapping In Detail](http://www.agiledata.org/essays/mappingObjects.html#MappingInheritance)

### JPQL, Native Queryとか
- [NativeQueryじゃだめ？〜JPAクエリ表現ごとのパフォーマンス比較](https://hiranasu.hatenadiary.org/entry/20121219/1355936977)
- [JPQL速かった！〜JPAクエリ表現ごとのパフォーマンス比較 その2](https://hiranasu.hatenadiary.org/entry/20130103/1357196494)

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