Spring Data JPA 批量插入调优

注:该笔记最后更新于 2011.11.10,方法和结论具有时间和版本的局限性

如果我们直接使用 Spring Data JPA 默认的批量插入方法 saveAll(...),会发现效率很低。最直接的原因是 saveAll(...) 在插入数据时默认是一条一条插入的。如何实现真实的批量插入(一次插入多条)?以及是否还能进一步调优?这篇文章将详细讨论和介绍。

调优策略与测试

首先我们创建一个最常见的 entity class 和 repository 来作为例子:

@Entity
public class Student{
    @Id
    private String id;
    private String name; // 学生姓名
    private int age; // 学生年龄
}
public interface StudentRepository extends CrudRepository<Student,String>{
    // saveAll(...) 方法是默认提供的,无需显性声明
}

添加 generate_statistics 配置——打印出 JPA 实际执行语句的统计信息,便于观察 JPA 的实际执行过程

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

此时调用 StudentRepository 的 saveAll(...) 方法,传入一个包含了 500 个 Student 的 Student List。观察日志输出的统计信息 :

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)
}

该过程一共准备 1000 条 sql 语句,最终执行了 1000 条 sql 语句。JDBC 批数量为 0。整个插入耗时,在我的个人电脑上是 37 秒多。根据这个测试,我们发现,JPA 的 saveAll(...) 方法默认是一条条插入。而且并没有走任何的批量插入。

注意:观察仔细的伙伴会问?我们插入数量是 500 条,为什么准备和执行的 sql 语句有 1000 条呢?
这个就关系到 JPA 在 save 过程中可优化的另一个地方——JPA 所有的 save 操作都隐含了插入或更新这两种操作,无论是 save 还是 saveAll,默认都是先根据主键做一次 select 查询,根据查询结果,如果数据库中不存在该数据,则插入,如果已存在,则更新。所以这 1000 条语句,分别是 500 条 select 和 500 条 insert。这一过程,可以通过配置 spring.jpa.show-sql = true 打印出所有执行的 sql 语句来证实。
关于如何批量实现真实的批量插入,以及如何优化 JPA 默认的先查再插入/更新这一流程,我们接下来会一一介绍。

实现真实的批量插入

JPA 的 saveAll(...) 方法默认是一条条插入,想要真实的批量插入,需要声明一个 Hibernate batch_size 配置:

spring.datasource.jpa:
    show-sql: true
    properties:
        hibernate:
            jdbc:
                batch_size: 500

batch_size 这个配置告诉 JPA,当插入/更新时,按最大 500 条一批来进行批处理。增加这条配置后,我们清空数据库数据,然后重新测试一次看看:

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)
}

这次的执行过程明显发生了变化,执行的 sql 语句变成了 501 条,其中 500 条为单语句,1条为批量语句。总耗时 15 秒多,性能提升了 1.5 倍。

不难看出,这其中的 500 条单语句是 JPA 默认的查询语句(为什么会有查询语句在上一节的 note 中有描述,对此的优化下面会介绍),原先的 500 条插入请求变成了一条批量语句一次性插入。——换句话说,我们实现了真实的批量插入,且性能较之前得到了极大的提升。

当然 15 秒对于 500 条的数据插入而言依然太久,这是因为 JPA 的默认查询过程造成的,接下来我们看看如何进一步优化。

如何禁止 JPA 在插入前查询

JPA 为什么在插入前会做查询,我们在前面有介绍过:

JPA 所有的 save 操作都隐含了插入或更新这两种操作,无论是 save 还是 saveAll,默认都是先根据主键做一次 select 查询,根据查询结果,如果数据库中不存在该数据,则插入,如果已存在,则更新。

优化这一过程的策略很简单,那就是不要让 JPA 去通过查询来判断插入还是更新,我们明确告诉 JPA 做插入就可以了。针对这一问题,Spring 实际上也给出了方案,具体实现过程稍微变了一下思路,但是本质上是一致的。我们来看看 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…
}

这个写法比较抽象,为了方便大家理解,我把他搬到 Student 这个例子中,如下:

@Entity
public class Student implements Persistable<String> {
  @Id
  private String id;
  private String name; // 学生姓名
  private int age; // 学生年龄
 
 
 
  @Transient
  private boolean isNew = true;
 
  @Override
  public boolean isNew() {
    return isNew;
  }
 
  @PrePersist
  @PostLoad
  void markNotNew() {
    this.isNew = false;
  }
 
  // More code…
}

首先实现 Persistable 接口,然后实现一个 isNew() 方法返回一个 boolean 值。我们通过这个方法告诉 JPA,一个 Student 对象对应的数据是否是全新的。true 代表的是新数据,需要插入操作。false 代表的是老数据,需要执行更新操作。JPA 在执行 save 类的操作时,会调用带存储 Student 对象的 isNew() 方法来获取这一信息。

我这顺便在解释一下代码上其他增加的部分:

  1. 我们新定义一个 isNew 私有变量,上面加了一个 @Transient 注解,这个注解的作用是告诉 JPA,isNew 这个字段不需要持久化到数据库。该字段默认为 true,意思是所有新建的 Student 默认都是新的数据。
  2. 我们写了一个 markNotNew() 方法,这个方法的作用,就是把这个 Student 对象声明成“旧的数据”。上面的两个 @PrePersist 和 @PostLoad 用的非常巧妙。
    • @PrePersist 注解告诉 Spring 在正式把该数据插入到数据库之前,需要调用一下 markNotNew() 方法。换句话说,每当一个全新的 Student 对象,被 save 过且执行了 insert 的时候,这个对象的 markNotNew() 方法会在这一过程被调用,save 结束后的 Student 对象的 isNew 变量会变成 false。也就是,一个 Student 对象被存储过了,自动就变成一个旧数据对象,重复再 save 时,触发的就是 update 操作了。
    • @PostLoad 注解告诉 Spring,如果这个对象是通过持久化提供者加载的,比如:这个对象是我们通过调用 JPA 的 repository 查询接口获取到的,那么这个对象在获取的时候,需要自动调用一下 markNotNew() 方法。也就是,所有从 JPA 查询到的 Student 对象,isNew 都会是 false。
    • 综上所述,@PrePersist 和 @PostLoad 帮我们巧妙的自动处理了【我们自己 new 的,后来被存储过的对象,都是就旧数据对象】和【所有从数据库里面查询出来的对象都是旧数据对象】这两件事情。

最后,我们再测一下,增加这个优化之后的执行情况:

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)
}

从这次的执行日志我们可以很清晰的发现,整个过程只有一次批量插入动作,插入数量是 500 条。这是我们最终想要的批量插入效果,总耗时仅 0.7 秒。

JPA 批量存储调优的瓶颈

JPA 批量存储 saveAll(...) 方法,默认会返回一个 List<Entity>,相比 void 而言,这个肯定会消耗时间,特别是当我们存储的对象数量比较多的时候。很多时候,特别是批量插入,我们并不需要插入成功的返回数据,这个时候 JPA saveAll(...) 方法拼装 List<Entity> 返回结果所用的时间就是多余的。

遗憾的是,我并没有找到很方便的方法能够在 JPA 上优化这一点,所以我把这一点归纳为 JPA 批量存储的调优瓶颈。

针对这一点,如果我们的场景数据量特别大,而且性能要求很苛刻,可以直接采用原始 JDBC 的方式,灵活编写批量插入/更新的返回类型。我们自己尝试了一下,如果直接使用 JDBC,单次 500 条数据的批量存储,返回类型 void,最终耗时在 0.3 秒。

总结

测试结果:总数据量 500,单批 500

默认JPA saveAll 优化后 JAP saveAll JDBC 最佳
耗时 37 秒 0.7 秒 0.3 秒

结论:
直接使用 Spring Data JPA 的 saveAll 做批量插入效率是很低的,我们可以很轻松的通过一些优化来极大的提升效率,从而满足大部分的场景。但 JPA 的调优本身是有瓶颈的,默认会返回所有插入成功的数据。如果我们所使用的的场景数据量特别大,以及性能要求很高,且不要求返回插入数据的话,直接使用 JDBC 实现一个 void 返回类型的批量插入会有更优的表现。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容