Spring Data JPA 执行 INSERT 时跳过 SELECT

1、概览

在某些情况下,当使用 Spring Data JPA Repository 保存实体时,可能会在日志中遇到额外的 SELECT。这可能会因大量额外调用而导致性能问题。

本文将带你了解如何在 Spring Data JPA 中执行 INSERT 时跳过 SELECT,以提高性能。

2、设置

在深入 Spring Data JPA 并对其进行测试之前,先要做一些准备工作。

2.1、依赖

为了创建测试 Repository,需要使用 Spring Data JPA 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

使用 H2 数据库作为测试数据库:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
</dependency>

使用 Spring Context 进行集成测试。添加 spring-boot-starter-test 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

2.2、配置

本例中使用的 JPA 配置如下:

spring.jpa.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.hibernate.show_sql=true
spring.jpa.hibernate.hbm2ddl.auto=create-drop

如上,让 Hibernate 生成 schema,并将所有 SQL 查询记录到日志中。

3、导致 SELECT 查询的原因

首先,创建一个 Task 实体:

@Entity
public class Task {

    @Id
    private Integer id;
    private String description;

    // Getter/Setter 省略
}

为实体创建 Repository:

@Repository
public interface TaskRepository extends JpaRepository<Task, Integer> {
}

现在,保存一个新的 Task,手动指定 ID:

@Autowired
private TaskRepository taskRepository;

@Test
void givenRepository_whenSaveNewTaskWithPopulatedId_thenExtraSelectIsExpected() {
    Task task = new Task();
    task.setId(1);
    taskRepository.saveAndFlush(task);
}

当我们调用 Repository 的 saveAndFlush() 方法时和save() 方法的行为将相同。在内部,使用以下代码:

public<S extends T> S save(S entity){
    if(isNew(entity)){
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

因此,如果实体被认为不是新的,就会调用实体管理器的 merge() 方法。在 merge() 中,JPA 会检查实体是否存在于缓存和持久化上下文中。由于对象是新的,所以不会在那里找到。最后,它会尝试从数据源加载实体。

这就是我们在日志中遇到 SELECT 查询的地方。由于数据库中没有这个实体,因此在此之后调用 INSERT 保存:

Hibernate: select task0_.id as id1_1_0_, task0_.description as descript2_1_0_ from task task0_ where task0_.id=?
Hibernate: insert into task (id, description) values (default, ?)

isNew() 方法的实现中,可以找到如下代码:

public boolean isNew(T entity) {
    ID id = this.getId(entity);
    return id == null;
}

如果我们指定了 ID,实体将被视为新实体。在这种情况下,将会向数据库发起一个额外的 SELECT 查询。

4、使用 @GeneratedValue

可能的解决方案之一是不在应用端指定 ID。可以使用 @GeneratedValue 注解,并指定用于在数据库端生成 ID 的策略。

创建 TaskWithGeneratedId 实体,为 ID 指定生成策略:

@Entity
public class TaskWithGeneratedId {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
}

然后,保存一个 TaskWithGeneratedId 实体,但不设置 ID:

@Autowired
private TaskWithGeneratedIdRepository taskWithGeneratedIdRepository;

@Test
void givenRepository_whenSaveNewTaskWithGeneratedId_thenNoExtraSelectIsExpected() {
    TaskWithGeneratedId task = new TaskWithGeneratedId();
    TaskWithGeneratedId saved = taskWithGeneratedIdRepository.saveAndFlush(task);
    assertNotNull(saved.getId());
}

可以从执行日志中看到,并没有 SELECT 查询,而且为实体生成了一个新的 ID。

5、实现 Persistable 接口

另一个选择是在实体中实现 Persistable 接口:

@Entity
public class PersistableTask implements Persistable<Integer> {
    @Id
    private int id;

    @Transient
    private boolean isNew = true;

    @Override
    public Integer getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return isNew;
    }
    
    // Getter、Setter 省略
}

如上,添加了一个新字段 isNew,并将其注解为 @Transient,表示非数据表中的列。覆写 isNew() 方法,可以将实体视为新实体,即使指定了一个 ID。

现在,JPA 在底层使用另一种逻辑来判断实体是否是新的:

public class JpaPersistableEntityInformation {
    public boolean isNew(T entity) {
        return entity.isNew();
    }
}

使用 PersistableTaskRepository 保存 PersistableTask

@Autowired
private PersistableTaskRepository persistableTaskRepository;

@Test
void givenRepository_whenSaveNewPersistableTask_thenNoExtraSelectIsExpected() {
    PersistableTask persistableTask = new PersistableTask();
    persistableTask.setId(2);
    persistableTask.setNew(true);
    PersistableTask saved = persistableTaskRepository.saveAndFlush(persistableTask);
    assertEquals(2, saved.getId());
}

你可以看到,只有 INSERT 日志信息,实体中包含我们指定的 ID。

如果尝试保存几个具有相同 ID 的新实体,就会出现异常:

@Test
void givenRepository_whenSaveNewPersistableTasksWithSameId_thenExceptionIsExpected() {
    PersistableTask persistableTask = new PersistableTask();
    persistableTask.setId(3);
    persistableTask.setNew(true);
    persistableTaskRepository.saveAndFlush(persistableTask);

    PersistableTask duplicateTask = new PersistableTask();
    duplicateTask.setId(3);
    duplicateTask.setNew(true);

    assertThrows(DataIntegrityViolationException.class,
      () -> persistableTaskRepository.saveAndFlush(duplicateTask));
}

因此,如果我们负责生成 ID,需要注意其唯一性。

6、直接使用 persist() 方法

如前例所示,所做的所有操作都会调用 persist() 方法。我们也可以为 Repository 创建一个扩展,允许我们直接调用该方法。

创建一个 TaskRepositoryExtension 接口,包含 persist 方法:

public interface TaskRepositoryExtension {
    Task persistAndFlush(Task task);
}

然后,为这个接口创建一个实现 Bean:

@Component
public class TaskRepositoryExtensionImpl implements TaskRepositoryExtension {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public Task persistAndFlush(Task task) {
        entityManager.persist(task);
        entityManager.flush();
        return task;
    }
}

现在,让 TaskRepository 继承此接口:

@Repository
public interface TaskRepository extends JpaRepository<Task, Integer>, TaskRepositoryExtension {
}

调用自定义的 persistAndFlush() 方法来保存 Task 实例:

@Test
void givenRepository_whenPersistNewTaskUsingCustomPersistMethod_thenNoExtraSelectIsExpected() {
    Task task = new Task();
    task.setId(4);
    Task saved = taskRepository.persistAndFlush(task);

    assertEquals(4, saved.getId());
}

你可以看到日志信息中只有 INSERT 调用,没有额外的 SELECT 调用。

7、使用 Hypersistence 中的 BaseJpaRepository

上一节的想法已经在 Hypersistence Utils 项目中实现。该项目提供了一个 BaseJpaRepository,其中有 persistAndFlush() 方法的实现,以及它的批量版本

要使用它,必须添加额外的 依赖(需要根据 Hibernate 版本选择正确的 Maven 构件):

<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-55</artifactId>
</dependency>

现在实现另一个 Repository,它同时继承了 Hypersistence Utils 中的 BaseJpaRepository 和 Spring Data JPA 中的 JpaRepository

@Repository
public interface TaskJpaRepository extends JpaRepository<Task, Integer>, BaseJpaRepository<Task, Integer> {
}

此外,还必须使用 @EnableJpaRepositories 注解启用 BaseJpaRepository 的实现:

@EnableJpaRepositories(
    repositoryBaseClass = BaseJpaRepositoryImpl.class
)

使用新 Repository 保存 Task

@Autowired
private TaskJpaRepository taskJpaRepository;

@Test
void givenRepository_whenPersistNewTaskUsingPersist_thenNoExtraSelectIsExpected() {
    Task task = new Task();
    task.setId(5);
    Task saved = taskJpaRepository.persistAndFlush(task);

    assertEquals(5, saved.getId());
}

成功保存了 Task,而日志中没有 SELECT 查询。

与在应用端指定 ID 的所有示例一样,可能会出现违反唯一性约束的情况:

@Test
void givenRepository_whenPersistTaskWithTheSameId_thenExceptionIsExpected() {
    Task task = new Task();
    task.setId(5);
    taskJpaRepository.persistAndFlush(task);

    Task secondTask = new Task();
    secondTask.setId(5);

    assertThrows(DataIntegrityViolationException.class,
      () ->  taskJpaRepository.persistAndFlush(secondTask));
}

8、使用 @Query 注解方法

还可以通过直接使用本地查询来避免额外调用。在 TaskRepository 中添加如下方法:

@Repository
public interface TaskRepository extends JpaRepository<Task, Integer> {

    @Modifying
    @Query(value = "insert into task(id, description) values(:#{#task.id}, :#{#task.description})", 
      nativeQuery = true)
    void insert(@Param("task") Task task);
}

该方法直接调用 INSERT 查询,避免了持久化上下文的工作。ID 将从方法参数中的 Task 对象中获取。

用该方法保存 Task

@Test
void givenRepository_whenPersistNewTaskUsingNativeQuery_thenNoExtraSelectIsExpected() {
    Task task = new Task();
    task.setId(6);
    taskRepository.insert(task);

    assertTrue(taskRepository.findById(6).isPresent());
}

成功使用 ID 保存了实体,无需在 INSERT 之前进行额外的 SELECT 查询。需要注意的是,使用这种方法可以避免 JPA 上下文和 Hibernate 缓存的使用。

9、总结

本文介绍了在使用 Spring Data JPA 保存(INSERT)实体时生成额外 SELECT 查询的原因,以及如何避免这个问题。


Ref:https://www.baeldung.com/spring-data-jpa-skip-select-insert