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ǔ)句??偤臅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)獲取這一信息。

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

  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)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容