注:该笔记最后更新于 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() 方法来获取这一信息。
我这顺便在解释一下代码上其他增加的部分:
- 我们新定义一个 isNew 私有变量,上面加了一个 @Transient 注解,这个注解的作用是告诉 JPA,isNew 这个字段不需要持久化到数据库。该字段默认为 true,意思是所有新建的 Student 默认都是新的数据。
- 我们写了一个 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 返回类型的批量插入会有更优的表现。