概要
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になる。
- jdbc_uriに
?reWriteBatchedInserts=true
を付ける。 - spring.jpa.properties.hibernate.jdbc.batch_sizeを設定する。
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する箇所。
@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を自動採番する。
@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()
する。
@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
を使わない。
@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が自動生成されるところが問題なので、解決にはならなかった。
@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()
を実装する。
@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に以下を定義する。
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を使って登録。
@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のようなデータ構造となっている。
引用元: データベースアクセス(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
ならできるというようなことを書いてあるサイトもあったが、どちらでもできなかった。
- spring-data-jpaでbulk insertするにはentity manager を使うしかないのでしょうか。
- HowTo: Getting batch inserts to work on MySQL
- 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のflushについて
インピーダンスミスマッチについて
- O/Rマッピングで緩和されるインピーダンスミスマッチには静的と動的の側面がある
- インピーダンスミスマッチとは?を分かりやすく説明
- Mapping Objects to Relational Databases: O/R Mapping In Detail
JPQL, Native Queryとか
Native QueryはPersistentContextを参照せずに直接DBを操作するらしい。パフォーマンスに影響するかもしれないので注意。