概要
JPA(Hibernate)でバッチ処理を行う際のパフォーマンスについて調べていたところ、Hibernate DocumentationのBatch processingのページに、主キーの生成方法にGenerationType.IDENTITYを使用した場合、JDBCレベルでバッチ処理が無効になるという記述(下記に当該箇所を引用しました)を見たので、GenerationTypeの違いでどの程度差がでるのか調べてみました。
Chapter 15. Batch processing
Hibernate disables insert batching at the JDBC level transparently if you use an identity identifier generator.
また、参考記録としてJPAを使わずにJDBCで同様のバッチ処理をおこなった場合も調べました。(Spring JdbcTemplateを使用)
環境
- Windows10 Professional
- Java 1.8.0_144
- JDBC 4.2
- Spring Boot 1.5.6
- Spring Data JPA 1.11.6
- Hibernate 5.0.12
- Spring JDBC 4.3.10
- MySQL CE 5.6.25
- Connector/J 5.1.43
- PostgreSQL 9.6.1
- postgresql 9.4.1212.jre7
参考
- 5.1 Driver/Datasource Class Names, URL Syntax and Configuration Properties for Connector/J
- Hibernate Reference Documentation Chapter 15. Batch processing
- Spring Data JPA - Reference Documentation
- org.springframework.jdbc.core.JdbcTemplate
- MySQL の JDBC ドライバで設定しておきたい rewriteBatchedStatements プロパティ
検証方法
パフォーマンスの検証はJUnitのUnitテストの実行時間を計測することで行いました。
UnitテストコードはSpring BootとSpring Data JPAの環境で作成し、データベースにはローカル上のPostgreSQLおよびMySQLを利用しました。(念のため補足しますが、この検証はどちらのデータベースが速いかという目的ではありません。)
環境設定および検証コード
application.ymlのデータソースの設定
PostgreSQL用
spring:
datasource:
url: jdbc:postgresql://localhost:5432/sample_db
username: test_user
password: test_user
driverClassName: org.postgresql.Driver
MySQL用
spring:
datasource:
url: jdbc:mysql://localhost:3306/sample_db
username: test_user
password: test_user
driverClassName: com.mysql.jdbc.Driver
DataSourceの設定クラス
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = {"com.example.domain.repository"},
entityManagerFactoryRef = "entityManagerFactory",
transactionManagerRef = "transactionManager"
)
public class DataSourceConfigure {
@ConfigurationProperties(prefix = "spring.datasource")
@Bean
public DataSource datasource() {
DataSource dataSource = DataSourceBuilder.create().build();
return dataSource;
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
EntityManagerFactoryBuilder builder) {
LocalContainerEntityManagerFactoryBean factory = builder
.dataSource(datasource())
.persistenceUnit("default")
.packages("com.example.domain.entity")
.build();
return factory;
}
@Bean
public PlatformTransactionManager transactionManager(
EntityManagerFactory entityManagerFactory) {
JpaTransactionManager tm = new JpaTransactionManager();
tm.setEntityManagerFactory(entityManagerFactory);
tm.setDefaultTimeout(300);
tm.afterPropertiesSet();
return tm;
}
}
Unitテストコード
最初に10万件のダミーデータを生成しそれを使ってインサート処理を行い、テストの開始から正常終了までにかかった時間を計測しました。
また、時間の計測はJUnitのStopwatchルールを使用しました。
EntityManagerを使うテストコード
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = {
TestApplication.class, DataSourceConfigure.class})
public class MemoWithEntityManagerTest {
@Rule
public final Stopwatch stopwatch = StopwatchRule.create();
@PersistenceContext
private EntityManager entityManager;
@Value("${spring.jpa.properties.hibernate.jdbc.batch_size:30}")
private int batchSize;
private List<Memo> lists;
@Before
public void setup() {
lists = new ArrayList<>();
for (int i=0; i<100_000; i++) {
String title = UUID.randomUUID().toString();
String description = i + " : " + title;
lists.add(Memo.of(title, description));
}
}
@Test
@Transactional
public void testPersist() {
for (int i=0; i<lists.size(); i++) {
entityManager.persist(lists.get(i));
if (i > 0 && i % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
}
entityManager.flush();
entityManager.clear();
}
}
JdbcTemplateを使うテストコード
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = {
TestApplication.class, DataSourceConfigure.class})
public class MemoWithJdbcTemplateTest {
@Rule
public final Stopwatch stopwatch = StopwatchRule.create();
@Autowired
private JdbcTemplate jdbcTemplate;
private List<Memo> lists;
@Before
public void setup() {
lists = new ArrayList<>();
for (int i=0; i<100_000; i++) {
String title = UUID.randomUUID().toString();
String description = i + " : " + title;
lists.add(Memo.of(title, description));
}
}
@Test
@Transactional
public void testBatchUpdate() {
String sql = "insert into memo (title, description, done, updated) values (?, ?, ?, ?)";
BatchPreparedStatementSetter setter = new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Memo memo = lists.get(i);
ps.setString(1, memo.getTitle());
ps.setString(2, memo.getDescription());
ps.setBoolean(3, memo.getDone());
ps.setTimestamp(4, new java.sql.Timestamp(memo.getUpdated().getTime()));
}
@Override
public int getBatchSize() {
return lists.size();
}
};
jdbcTemplate.batchUpdate(sql, setter);
}
}
時間計測のStopwatchルール
public class StopwatchRule {
public static Stopwatch create() {
return new Stopwatch() {
@Override
public long runtime(TimeUnit unit) {
return super.runtime(unit);
}
@Override
protected void succeeded(long nanos, Description description) {
System.out.println(description.getMethodName() + " succeeded, time taken " + toSeconds(nanos));
}
@Override
protected void failed(long nanos, Throwable e, Description description) {
super.failed(nanos, e, description);
}
@Override
protected void skipped(long nanos, AssumptionViolatedException e, Description description) {
super.skipped(nanos, e, description);
}
@Override
protected void finished(long nanos, Description description) {
System.out.println(description.getMethodName() + " finished, time taken " + toSeconds(nanos));
}
private double toSeconds(long nanos) {
return (double) nanos / 1000000000.0;
}
};
}
}
計測パターン1) PostgreSQL + GenerationType.SEQUENCE
バッチサイズを指定すればバッチ処理として実行されるパータンです。
テーブル定義
主キーの生成にsequenceを使用します。
CREATE TABLE memo (
id bigint NOT NULL,
title varchar(255) NOT NULL,
description text NOT NULL,
done boolean DEFAULT false NOT NULL,
updated timestamp without time zone DEFAULT current_timestamp NOT NULL,
CONSTRAINT memo_pkey PRIMARY KEY(id)
);
CREATE SEQUENCE memo_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
NO CYCLE;
エンティティクラス
package com.example.domain.entity;
@Entity
@Table(name="memo")
public class Memo implements Serializable {
private static final long serialVersionUID = -7888970423872473471L;
@Id
@SequenceGenerator(name = "memo_id_seq", sequenceName = "memo_id_seq", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "memo_id_seq")
private Long id;
@Column(name="title", nullable = false)
private String title;
@Column(name="description", nullable = false)
private String description;
@Column(name="done", nullable = false)
private Boolean done;
@Column(name="updated", nullable = false)
@Temporal(TemporalType.TIMESTAMP)
private Date updated;
public static Memo of(String title, String description) {
Memo memo = new Memo();
memo.title = title;
memo.description = description;
memo.done = false;
memo.updated = new Date();
return memo;
}
// getter/setterは省略
}
バッチサイズの設定「なし」のときの設定内容
spring:
jpa:
properties:
hibernate:
generate_statistics: true
バッチサイズの設定「あり」のときの設定内容
batch_sizeは30と50の2パターンで計測しました。
spring:
jpa:
properties:
hibernate:
generate_statistics: true
jdbc:
batch_size: 30
パターン1) 計測結果
計測はそれぞれ2回ずつ行いました。
No | バッチサイズ | 処理時間(秒) 1回目 |
処理時間(秒) 2回目 |
備考 |
---|---|---|---|---|
1 | 設定なし | 43.603787094 | 43.645273886 | EntityManagerを使い30件ごとにflush,clearを実行 |
2 | バッチサイズ30 | 35.651785986 | 35.052646148 | EntityManagerを使い30件ごとにflush,clearを実行 |
3 | バッチサイズ50 | 35.159041777 | 34.690860229 | EntityManagerを使い50件ごとにflush,clearを実行 |
バッチ処理として実行されているかどうかはJPAが出力する統計情報から判断しました。
この情報を出力するにはgenerate_statisticsパラメータにtrueを設定します。
No1の1回目の統計情報
10万件のデータインサートに、JDBC statementの実行回数が20万件となっている理由は、主キー値をselect nextval('memo_id_seq')
で取得しているためです。
StatisticalLoggingSessionEventListener : Session Metrics {
1535888 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
454642612 nanoseconds spent preparing 200000 JDBC statements;
38436776034 nanoseconds spent executing 200000 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
24964533986 nanoseconds spent executing 3334 flushes (flushing a total of 100000 entities and 0 collections);
0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}
No2の1回目の統計情報
StatisticalLoggingSessionEventListener : Session Metrics {
2525719 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
289639147 nanoseconds spent preparing 103334 JDBC statements;
16091539786 nanoseconds spent executing 100000 JDBC statements;
15787659766 nanoseconds spent executing 3335 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
16543820527 nanoseconds spent executing 3334 flushes (flushing a total of 100000 entities and 0 collections);
0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}
No3の1回目の統計情報
StatisticalLoggingSessionEventListener : Session Metrics {
1430965 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
308382598 nanoseconds spent preparing 102000 JDBC statements;
16036841752 nanoseconds spent executing 100000 JDBC statements;
15643983778 nanoseconds spent executing 2001 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
16309985446 nanoseconds spent executing 2000 flushes (flushing a total of 100000 entities and 0 collections);
0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}
計測パターン2) PostgreSQL + GenerationType.IDENTITY
バッチサイズを指定してもバッチ処理として実行されないパータンです。主キーの生成をGenerationType.IDENTITYとするには、テーブルの主キーの型にbigserial型を使用します。
テーブル定義
主キーの生成にbigserial型を使用します。
CREATE TABLE memo (
id bigserial NOT NULL,
title varchar(255) NOT NULL,
description text NOT NULL,
done boolean DEFAULT false NOT NULL,
updated timestamp without time zone DEFAULT current_timestamp NOT NULL,
CONSTRAINT memo_pkey PRIMARY KEY(id)
);
エンティティクラス
package com.example.domain.entity;
@Entity
@Table(name="memo")
public class Memo implements Serializable {
private static final long serialVersionUID = -7888970423872473471L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="title", nullable = false)
private String title;
@Column(name="description", nullable = false)
private String description;
@Column(name="done", nullable = false)
private Boolean done;
@Column(name="updated", nullable = false)
@Temporal(TemporalType.TIMESTAMP)
private Date updated;
public static Memo of(String title, String description) {
Memo memo = new Memo();
memo.title = title;
memo.description = description;
memo.done = false;
memo.updated = new Date();
return memo;
}
// getter/setter省略
}
バッチサイズの設定「なし」のときの設定内容
設定は計測パターン1と同じ内容です。
GenerationType.IDENTITYはバッチサイズの設定は無効化されるのでバッチサイズ設定ありのパターンの計測は行っていません。
パターン2)計測結果
No | バッチサイズ | 処理時間(秒) 1回目 |
処理時間(秒) 2回目 |
備考 |
---|---|---|---|---|
1 | 設定なし | 33.262405883 | 33.097327786 | EntityManagerを使い30件ごとにflush,clearを実行 |
2 | 設定なし | 16.062690625 | 15.846104684 | JdbcTemplateを使った参考記録 |
計測パターン3) MySQL + GenerationType.IDENTITY
このパターンは、パターン2と同様にバッチ処理として実行されないパターンです。
MySQLはPostgreSQLのSequenceと同等の機能が無いためGenerationType.SEQUENCEの検証はできませんでした。
テーブル定義
CREATE TABLE IF NOT EXISTS memo (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE NOT NULL,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (id)
) ENGINE = INNODB, CHARACTER SET = utf8mb4, COLLATE utf8mb4_general_ci;
エンティティクラス
package com.example.domain.entity;
@Entity
@Table(name="memo")
public class Memo implements Serializable {
private static final long serialVersionUID = -7888970423872473471L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="title", nullable = false)
private String title;
@Column(name="description", nullable = false)
private String description;
@Column(name="done", nullable = false)
private Boolean done;
@Column(name="updated", nullable = false)
@Temporal(TemporalType.TIMESTAMP)
private Date updated;
public static Memo of(String title, String description) {
Memo memo = new Memo();
memo.title = title;
memo.description = description;
memo.done = false;
memo.updated = new Date();
return memo;
}
// getter/setter省略
}
バッチサイズの設定「なし」のときの設定内容
spring:
jpa:
properties:
hibernate:
generate_statistics: true
GenerationType.IDENTITYはバッチサイズの設定は無効化されるのでバッチサイズ設定ありのパターンの計測は行っていません。
パターン3)計測結果
No | バッチサイズ | 処理時間(秒) 1回目 |
処理時間(秒) 2回目 |
備考 |
---|---|---|---|---|
1 | 設定なし | 28.461122403 | 27.934141586 | EntityManagerを使い30件ごとにflush,clearを実行 |
2 | 設定なし | 16.115392204 | 15.964329785 | JdbcTemplateを使った参考記録 |
計測パターン4) MySQL + GenerationType.IDENTITY + rewriteBatchedStatements=true
このパターンはパターン3の条件にrewriteBatchedStatementsを追加したパターンです。このパラメータはMySQL固有のJDBC URLパラメータで、これを有効にした場合パフォーマンスの改善が望める場合があります。
テーブル定義
計測パターン3と同様
エンティティクラス
計測パターン3と同様
バッチサイズの設定「なし」のときの設定内容
計測パターン3と同じ内容
rewriteBatchedStatements
下記のようにJDBC URLにパラメータとして追加します。
spring:
datasource:
url: jdbc:mysql://localhost:3306/sample_db?rewriteBatchedStatements=true
username: test_user
password: test_user
driverClassName: com.mysql.jdbc.Driver
このパラメータを有効にすると下記の例のようにinsert文を書き換えます。
例
元のinsert文
INSERT INTO tbl_name (a,b,c) VALUES (1,2,3);
INSERT INTO tbl_name (a,b,c) VALUES (4,5,6);
INSERT INTO tbl_name (a,b,c) VALUES (7,8,9);
書き換え後のinsert文
INSERT INTO tbl_name (a,b,c) VALUES (1,2,3),(4,5,6),(7,8,9);
この例のように複数のinsert文をまとめることで実行時のパフォーマンスをあげています。
ただし、この方法にはいくつか制約があり、その1つに書き換え後のsql文のサイズがmax_allowed_packetで指定するサイズを超えてはいけないというものがあります。
パターン4)計測結果
この設定はJPA(Hibernate)の動作には影響がないようで、EntitiManagerを使った場合ではパフォーマンスに改善はみられませんでした。
No | バッチサイズ | 処理時間(秒) 1回目 |
処理時間(秒) 2回目 |
備考 |
---|---|---|---|---|
1 | 設定なし | 28.258041058 | 28.514153595 | EntityManagerを使い30件ごとにflush,clearを実行 |
2 | 設定なし | 3.553322863 | 3.49220706 | JdbcTemplateを使った参考記録 |
まとめ
主キーの生成方法がGenerationType.SEQUENCEの場合、バッチサイズを設定することでパフォーマンスが向上することが確認できました(計測パターン1)が、総じてJPAではバッチ処理に時間がかかるという印象を持ちました。
バッチ処理など大量データを扱う処理ではJPA以外の方法で実装した方がいいのかもしれないと思いました。