Spring Data JPA 批量插入調(diào)優(yōu)

注:該筆記最后更新于 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)獲取這一信息有滑。

我這順便在解釋一下代碼上其他增加的部分:

  1. 我們新定義一個(gè) isNew 私有變量,上面加了一個(gè) @Transient 注解嵌削,這個(gè)注解的作用是告訴 JPA毛好,isNew 這個(gè)字段不需要持久化到數(shù)據(jù)庫(kù)。該字段默認(rèn)為 true苛秕,意思是所有新建的 Student 默認(rèn)都是新的數(shù)據(jù)肌访。
  2. 我們寫(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)庭猩。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市陈症,隨后出現(xiàn)的幾起案子蔼水,更是在濱河造成了極大的恐慌,老刑警劉巖录肯,帶你破解...
    沈念sama閱讀 222,946評(píng)論 6 518
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件趴腋,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡论咏,警方通過(guò)查閱死者的電腦和手機(jī)优炬,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,336評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)厅贪,“玉大人穿剖,你說(shuō)我怎么就攤上這事∝砸纾” “怎么了糊余?”我有些...
    開(kāi)封第一講書(shū)人閱讀 169,716評(píng)論 0 364
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)单寂。 經(jīng)常有香客問(wèn)我贬芥,道長(zhǎng),這世上最難降的妖魔是什么宣决? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 60,222評(píng)論 1 300
  • 正文 為了忘掉前任蘸劈,我火速辦了婚禮,結(jié)果婚禮上尊沸,老公的妹妹穿的比我還像新娘威沫。我一直安慰自己贤惯,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,223評(píng)論 6 398
  • 文/花漫 我一把揭開(kāi)白布棒掠。 她就那樣靜靜地躺著孵构,像睡著了一般。 火紅的嫁衣襯著肌膚如雪烟很。 梳的紋絲不亂的頭發(fā)上颈墅,一...
    開(kāi)封第一講書(shū)人閱讀 52,807評(píng)論 1 314
  • 那天,我揣著相機(jī)與錄音雾袱,去河邊找鬼恤筛。 笑死,一個(gè)胖子當(dāng)著我的面吹牛芹橡,可吹牛的內(nèi)容都是我干的毒坛。 我是一名探鬼主播,決...
    沈念sama閱讀 41,235評(píng)論 3 424
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼林说,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼煎殷!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起述么,我...
    開(kāi)封第一講書(shū)人閱讀 40,189評(píng)論 0 277
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤蝌数,失蹤者是張志新(化名)和其女友劉穎愕掏,沒(méi)想到半個(gè)月后度秘,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,712評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡饵撑,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,775評(píng)論 3 343
  • 正文 我和宋清朗相戀三年剑梳,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片滑潘。...
    茶點(diǎn)故事閱讀 40,926評(píng)論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡垢乙,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出语卤,到底是詐尸還是另有隱情追逮,我是刑警寧澤,帶...
    沈念sama閱讀 36,580評(píng)論 5 351
  • 正文 年R本政府宣布粹舵,位于F島的核電站钮孵,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏眼滤。R本人自食惡果不足惜巴席,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,259評(píng)論 3 336
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望诅需。 院中可真熱鬧漾唉,春花似錦荧库、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,750評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至料睛,卻和暖如春丐箩,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背恤煞。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,867評(píng)論 1 274
  • 我被黑心中介騙來(lái)泰國(guó)打工屎勘, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人居扒。 一個(gè)月前我還...
    沈念sama閱讀 49,368評(píng)論 3 379
  • 正文 我出身青樓概漱,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親喜喂。 傳聞我的和親對(duì)象是個(gè)殘疾皇子瓤摧,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,930評(píng)論 2 361

推薦閱讀更多精彩內(nèi)容