注:該筆記最后更新于 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ǔ)句赊琳。總耗時(shí) 15 秒多砰碴,性能提升了 1.5 倍躏筏。
不難看出,這其中的 500 條單語(yǔ)句是 JPA 默認(rèn)的查詢語(yǔ)句(為什么會(huì)有查詢語(yǔ)句在上一節(jié)的 note 中有描述呈枉,對(duì)此的優(yōu)化下面會(huì)介紹)趁尼,原先的 500 條插入請(qǐng)求變成了一條批量語(yǔ)句一次性插入〔瑁——換句話說(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)庭猩。