注:該筆記最后更新于 2011.11.10,方法和結(jié)論具有時(shí)間和版本的局限性
如果我們直接使用 Spring Data JPA 默認(rèn)的批量插入方法 saveAll(...),會(huì)發(fā)現(xiàn)效率很低。最直接的原因是 saveAll(...) 在插入數(shù)據(jù)時(shí)默認(rèn)是一條一條插入的。如何實(shí)現(xiàn)真實(shí)的批量插入(一次插入多條)?以及是否還能進(jìn)一步調(diào)優(yōu)?這篇文章將詳細(xì)討論和介紹。
調(diào)優(yōu)策略與測(cè)試
首先我們創(chuàng)建一個(gè)最常見(jiàn)的 entity class 和 repository 來(lái)作為例子:
@Entity
public class Student{
@Id
private String id;
private String name; // 學(xué)生姓名
private int age; // 學(xué)生年齡
}
public interface StudentRepository extends CrudRepository<Student,String>{
// saveAll(...) 方法是默認(rèn)提供的,無(wú)需顯性聲明
}
添加 generate_statistics 配置——打印出 JPA 實(shí)際執(zhí)行語(yǔ)句的統(tǒng)計(jì)信息,便于觀察 JPA 的實(shí)際執(zhí)行過(guò)程
spring:
jpa:
properties:
hibernate:
generate_statistics: true
此時(shí)調(diào)用 StudentRepository 的 saveAll(...) 方法,傳入一個(gè)包含了 500 個(gè) Student 的 Student List。觀察日志輸出的統(tǒng)計(jì)信息 :
2021-11-10 15:26:44.586 INFO 23588 --- [ main] i.StatisticalLoggingSessionEventListener : Session Metrics {
23801000 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
190531900 nanoseconds spent preparing 1000 JDBC statements;
36422238400 nanoseconds spent executing 1000 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;
14233607900 nanoseconds spent executing 1 flushes (flushing a total of 500 entities and 0 collections);
0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}
該過(guò)程一共準(zhǔn)備 1000 條 sql 語(yǔ)句,最終執(zhí)行了 1000 條 sql 語(yǔ)句。JDBC 批數(shù)量為 0。整個(gè)插入耗時(shí),在我的個(gè)人電腦上是 37 秒多。根據(jù)這個(gè)測(cè)試,我們發(fā)現(xiàn),JPA 的 saveAll(...) 方法默認(rèn)是一條條插入。而且并沒(méi)有走任何的批量插入。
注意:觀察仔細(xì)的伙伴會(huì)問(wèn)?我們插入數(shù)量是 500 條,為什么準(zhǔn)備和執(zhí)行的 sql 語(yǔ)句有 1000 條呢?
這個(gè)就關(guān)系到 JPA 在 save 過(guò)程中可優(yōu)化的另一個(gè)地方——JPA 所有的 save 操作都隱含了插入或更新這兩種操作,無(wú)論是 save 還是 saveAll,默認(rèn)都是先根據(jù)主鍵做一次 select 查詢,根據(jù)查詢結(jié)果,如果數(shù)據(jù)庫(kù)中不存在該數(shù)據(jù),則插入,如果已存在,則更新。所以這 1000 條語(yǔ)句,分別是 500 條 select 和 500 條 insert。這一過(guò)程,可以通過(guò)配置 spring.jpa.show-sql = true 打印出所有執(zhí)行的 sql 語(yǔ)句來(lái)證實(shí)。
關(guān)于如何批量實(shí)現(xiàn)真實(shí)的批量插入,以及如何優(yōu)化 JPA 默認(rèn)的先查再插入/更新這一流程,我們接下來(lái)會(huì)一一介紹。
實(shí)現(xiàn)真實(shí)的批量插入
JPA 的 saveAll(...) 方法默認(rèn)是一條條插入,想要真實(shí)的批量插入,需要聲明一個(gè) Hibernate batch_size 配置:
spring.datasource.jpa:
show-sql: true
properties:
hibernate:
jdbc:
batch_size: 500
batch_size 這個(gè)配置告訴 JPA,當(dāng)插入/更新時(shí),按最大 500 條一批來(lái)進(jìn)行批處理。增加這條配置后,我們清空數(shù)據(jù)庫(kù)數(shù)據(jù),然后重新測(cè)試一次看看:
2021-11-10 15:37:21.486 INFO 23344 --- [ main] i.StatisticalLoggingSessionEventListener : Session Metrics {
23515600 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
147416200 nanoseconds spent preparing 501 JDBC statements;
13960803100 nanoseconds spent executing 500 JDBC statements;
78168100 nanoseconds spent executing 1 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
364531000 nanoseconds spent executing 1 flushes (flushing a total of 500 entities and 0 collections);
0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}
這次的執(zhí)行過(guò)程明顯發(fā)生了變化,執(zhí)行的 sql 語(yǔ)句變成了 501 條,其中 500 條為單語(yǔ)句,1條為批量語(yǔ)句??偤臅r(shí) 15 秒多,性能提升了 1.5 倍。
不難看出,這其中的 500 條單語(yǔ)句是 JPA 默認(rèn)的查詢語(yǔ)句(為什么會(huì)有查詢語(yǔ)句在上一節(jié)的 note 中有描述,對(duì)此的優(yōu)化下面會(huì)介紹),原先的 500 條插入請(qǐng)求變成了一條批量語(yǔ)句一次性插入?!獡Q句話說(shuō),我們實(shí)現(xiàn)了真實(shí)的批量插入,且性能較之前得到了極大的提升。
當(dāng)然 15 秒對(duì)于 500 條的數(shù)據(jù)插入而言依然太久,這是因?yàn)?JPA 的默認(rèn)查詢過(guò)程造成的,接下來(lái)我們看看如何進(jìn)一步優(yōu)化。
如何禁止 JPA 在插入前查詢
JPA 為什么在插入前會(huì)做查詢,我們?cè)谇懊嬗薪榻B過(guò):
JPA 所有的 save 操作都隱含了插入或更新這兩種操作,無(wú)論是 save 還是 saveAll,默認(rèn)都是先根據(jù)主鍵做一次 select 查詢,根據(jù)查詢結(jié)果,如果數(shù)據(jù)庫(kù)中不存在該數(shù)據(jù),則插入,如果已存在,則更新。
優(yōu)化這一過(guò)程的策略很簡(jiǎn)單,那就是不要讓 JPA 去通過(guò)查詢來(lái)判斷插入還是更新,我們明確告訴 JPA 做插入就可以了。針對(duì)這一問(wèn)題,Spring 實(shí)際上也給出了方案,具體實(shí)現(xiàn)過(guò)程稍微變了一下思路,但是本質(zhì)上是一致的。我們來(lái)看看 Spring 的方案:
@MappedSuperclass
public abstract class AbstractEntity<ID> implements Persistable<ID> {
@Transient
private boolean isNew = true;
@Override
public boolean isNew() {
return isNew;
}
@PrePersist
@PostLoad
void markNotNew() {
this.isNew = false;
}
// More code…
}
這個(gè)寫(xiě)法比較抽象,為了方便大家理解,我把他搬到 Student 這個(gè)例子中,如下:
@Entity
public class Student implements Persistable<String> {
@Id
private String id;
private String name; // 學(xué)生姓名
private int age; // 學(xué)生年齡
@Transient
private boolean isNew = true;
@Override
public boolean isNew() {
return isNew;
}
@PrePersist
@PostLoad
void markNotNew() {
this.isNew = false;
}
// More code…
}
首先實(shí)現(xiàn) Persistable 接口,然后實(shí)現(xiàn)一個(gè) isNew() 方法返回一個(gè) boolean 值。我們通過(guò)這個(gè)方法告訴 JPA,一個(gè) Student 對(duì)象對(duì)應(yīng)的數(shù)據(jù)是否是全新的。true 代表的是新數(shù)據(jù),需要插入操作。false 代表的是老數(shù)據(jù),需要執(zhí)行更新操作。JPA 在執(zhí)行 save 類(lèi)的操作時(shí),會(huì)調(diào)用帶存儲(chǔ) Student 對(duì)象的 isNew() 方法來(lái)獲取這一信息。
我這順便在解釋一下代碼上其他增加的部分:
- 我們新定義一個(gè) isNew 私有變量,上面加了一個(gè) @Transient 注解,這個(gè)注解的作用是告訴 JPA,isNew 這個(gè)字段不需要持久化到數(shù)據(jù)庫(kù)。該字段默認(rèn)為 true,意思是所有新建的 Student 默認(rèn)都是新的數(shù)據(jù)。
- 我們寫(xiě)了一個(gè) markNotNew() 方法,這個(gè)方法的作用,就是把這個(gè) Student 對(duì)象聲明成“舊的數(shù)據(jù)”。上面的兩個(gè) @PrePersist 和 @PostLoad 用的非常巧妙。
- @PrePersist 注解告訴 Spring 在正式把該數(shù)據(jù)插入到數(shù)據(jù)庫(kù)之前,需要調(diào)用一下 markNotNew() 方法。換句話說(shuō),每當(dāng)一個(gè)全新的 Student 對(duì)象,被 save 過(guò)且執(zhí)行了 insert 的時(shí)候,這個(gè)對(duì)象的 markNotNew() 方法會(huì)在這一過(guò)程被調(diào)用,save 結(jié)束后的 Student 對(duì)象的 isNew 變量會(huì)變成 false。也就是,一個(gè) Student 對(duì)象被存儲(chǔ)過(guò)了,自動(dòng)就變成一個(gè)舊數(shù)據(jù)對(duì)象,重復(fù)再 save 時(shí),觸發(fā)的就是 update 操作了。
- @PostLoad 注解告訴 Spring,如果這個(gè)對(duì)象是通過(guò)持久化提供者加載的,比如:這個(gè)對(duì)象是我們通過(guò)調(diào)用 JPA 的 repository 查詢接口獲取到的,那么這個(gè)對(duì)象在獲取的時(shí)候,需要自動(dòng)調(diào)用一下 markNotNew() 方法。也就是,所有從 JPA 查詢到的 Student 對(duì)象,isNew 都會(huì)是 false。
- 綜上所述,@PrePersist 和 @PostLoad 幫我們巧妙的自動(dòng)處理了【我們自己 new 的,后來(lái)被存儲(chǔ)過(guò)的對(duì)象,都是就舊數(shù)據(jù)對(duì)象】和【所有從數(shù)據(jù)庫(kù)里面查詢出來(lái)的對(duì)象都是舊數(shù)據(jù)對(duì)象】這兩件事情。
最后,我們?cè)贉y(cè)一下,增加這個(gè)優(yōu)化之后的執(zhí)行情況:
2021-11-10 16:32:44.667 INFO 23448 --- [ main] i.StatisticalLoggingSessionEventListener : Session Metrics {
23202100 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
43099600 nanoseconds spent preparing 1 JDBC statements;
0 nanoseconds spent executing 0 JDBC statements;
76179100 nanoseconds spent executing 1 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
440183300 nanoseconds spent executing 1 flushes (flushing a total of 500 entities and 0 collections);
0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}
從這次的執(zhí)行日志我們可以很清晰的發(fā)現(xiàn),整個(gè)過(guò)程只有一次批量插入動(dòng)作,插入數(shù)量是 500 條。這是我們最終想要的批量插入效果,總耗時(shí)僅 0.7 秒。
JPA 批量存儲(chǔ)調(diào)優(yōu)的瓶頸
JPA 批量存儲(chǔ) saveAll(...) 方法,默認(rèn)會(huì)返回一個(gè) List<Entity>,相比 void 而言,這個(gè)肯定會(huì)消耗時(shí)間,特別是當(dāng)我們存儲(chǔ)的對(duì)象數(shù)量比較多的時(shí)候。很多時(shí)候,特別是批量插入,我們并不需要插入成功的返回?cái)?shù)據(jù),這個(gè)時(shí)候 JPA saveAll(...) 方法拼裝 List<Entity> 返回結(jié)果所用的時(shí)間就是多余的。
遺憾的是,我并沒(méi)有找到很方便的方法能夠在 JPA 上優(yōu)化這一點(diǎn),所以我把這一點(diǎn)歸納為 JPA 批量存儲(chǔ)的調(diào)優(yōu)瓶頸。
針對(duì)這一點(diǎn),如果我們的場(chǎng)景數(shù)據(jù)量特別大,而且性能要求很苛刻,可以直接采用原始 JDBC 的方式,靈活編寫(xiě)批量插入/更新的返回類(lèi)型。我們自己嘗試了一下,如果直接使用 JDBC,單次 500 條數(shù)據(jù)的批量存儲(chǔ),返回類(lèi)型 void,最終耗時(shí)在 0.3 秒。
總結(jié)
測(cè)試結(jié)果:總數(shù)據(jù)量 500,單批 500
| 默認(rèn)JPA saveAll | 優(yōu)化后 JAP saveAll | JDBC 最佳 | |
|---|---|---|---|
| 耗時(shí) | 37 秒 | 0.7 秒 | 0.3 秒 |
結(jié)論:
直接使用 Spring Data JPA 的 saveAll 做批量插入效率是很低的,我們可以很輕松的通過(guò)一些優(yōu)化來(lái)極大的提升效率,從而滿足大部分的場(chǎng)景。但 JPA 的調(diào)優(yōu)本身是有瓶頸的,默認(rèn)會(huì)返回所有插入成功的數(shù)據(jù)。如果我們所使用的的場(chǎng)景數(shù)據(jù)量特別大,以及性能要求很高,且不要求返回插入數(shù)據(jù)的話,直接使用 JDBC 實(shí)現(xiàn)一個(gè) void 返回類(lèi)型的批量插入會(huì)有更優(yōu)的表現(xiàn)。