Как исправить jpa

Автор оригинала: Marcos Lopez Gonzalez.

1. введение

Конечно, мы никогда бы не предположили, что мы можем привести String к массиву String в Java:

java.lang.String cannot be cast to [Ljava.lang.String;

Но, оказывается, это распространенная ошибка JPA.

В этом кратком уроке мы покажем, как это происходит и как это решить.

В JPA нередко возникает эта ошибка , когда мы работаем с собственными запросами и используем метод createNativeQuery в EntityManager .

Его Javadoc фактически предупреждает нас , что этот метод вернет список Object [] или просто Object , если запрос возвращает только один столбец.

Давайте рассмотрим пример. Во-первых, давайте создадим исполнителя запросов, который мы хотим повторно использовать для выполнения всех наших запросов:

public class QueryExecutor {
    public static List executeNativeQueryNoCastCheck(String statement, EntityManager em) {
        Query query = em.createNativeQuery(statement);
        return query.getResultList();
    }
}

Как было показано выше, мы используем метод createNativeQuery() и всегда ожидаем результирующий набор, содержащий массив String .

После этого давайте создадим простую сущность для использования в наших примерах:

@Entity
public class Message {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String text;

    // getters and setters

}

И, наконец, давайте создадим тестовый класс, который вставляет Сообщение перед запуском тестов:

public class SpringCastUnitTest {

    private static EntityManager em;
    private static EntityManagerFactory emFactory;

    @BeforeClass
    public static void setup() {
        emFactory = Persistence.createEntityManagerFactory("jpa-h2");
        em = emFactory.createEntityManager();

        // insert an object into the db
        Message message = new Message();
        message.setText("text");

        EntityTransaction tr = em.getTransaction();
        tr.begin();
        em.persist(message);
        tr.commit();
    }
}

Теперь мы можем использовать наш Исполнитель запросов для выполнения запроса, который извлекает текстовое поле нашей сущности:

@Test(expected = ClassCastException.class)
public void givenExecutorNoCastCheck_whenQueryReturnsOneColumn_thenClassCastThrown() {
    List results = QueryExecutor.executeNativeQueryNoCastCheck("select text from message", em);

    // fails
    for (String[] row : results) {
        // do nothing
    }
}

Как мы видим, поскольку в запросе есть только один столбец, JPA фактически вернет список строк, а не список строковых массивов. Мы получаем ClassCastException , потому что запрос возвращает один столбец, и мы ожидали массив.

3. Ручная Фиксация Литья

Самый простой способ исправить эту ошибку-проверить тип объектов результирующего набора , чтобы избежать исключения ClassCastException. Давайте реализуем метод для этого в нашем Исполнителе запросов :

public static List executeNativeQueryWithCastCheck(String statement, EntityManager em) {
    Query query = em.createNativeQuery(statement);
    List results = query.getResultList();

    if (results.isEmpty()) {
        return new ArrayList<>();
    }

    if (results.get(0) instanceof String) {
        return ((List) results)
          .stream()
          .map(s -> new String[] { s })
          .collect(Collectors.toList());
    } else {
        return (List) results;
    }
}

Затем мы можем использовать этот метод для выполнения нашего запроса без получения исключения:

@Test
public void givenExecutorWithCastCheck_whenQueryReturnsOneColumn_thenNoClassCastThrown() {
    List results = QueryExecutor.executeNativeQueryWithCastCheck("select text from message", em);
    assertEquals("text", results.get(0)[0]);
}

Это не идеальное решение, так как мы должны преобразовать результат в массив, если запрос возвращает только один столбец.

4. Исправление сопоставления сущностей JPA

Другой способ исправить эту ошибку-сопоставить результирующий набор с сущностью. Таким образом, мы можем заранее решить, как сопоставить результаты наших запросов и избежать ненужных отливок.

Давайте добавим еще один метод к нашему исполнителю для поддержки использования пользовательских сопоставлений сущностей:

public static  List executeNativeQueryGeneric(String statement, String mapping, EntityManager em) {
    Query query = em.createNativeQuery(statement, mapping);
    return query.getResultList();
}

После этого давайте создадим пользовательское SqlResultSetMapping для сопоставления результирующего набора нашего предыдущего запроса с Сообщением сущностью:

@SqlResultSetMapping(
  name="textQueryMapping",
  classes={
    @ConstructorResult(
      targetClass=Message.class,
      columns={
        @ColumnResult(name="text")
      }
    )
  }
)
@Entity
public class Message {
    // ...
}

В этом случае мы также должны добавить конструктор, соответствующий нашему недавно созданному SqlResultSetMapping :

public class Message {

    // ... fields and default constructor

    public Message(String text) {
        this.text = text;
    }

    // ... getters and setters

}

Наконец, мы можем использовать наш новый метод исполнителя для выполнения тестового запроса и получения списка Сообщений :

@Test
public void givenExecutorGeneric_whenQueryReturnsOneColumn_thenNoClassCastThrown() {
    List results = QueryExecutor.executeNativeQueryGeneric(
      "select text from message", "textQueryMapping", em);
    assertEquals("text", results.get(0).getText());
}

Это решение намного чище, так как мы делегируем отображение результирующего набора в JPA.

5. Заключение

В этой статье мы показали, что собственные запросы являются общим местом для получения этого ClassCastException . Мы также рассмотрели возможность выполнения проверки типа самостоятельно, а также ее решения путем сопоставления результатов запроса с транспортным объектом.

Как всегда, полный исходный код примеров доступен на GitHub .

Если вы используете Hibernate в течение длительного времени, есть большая вероятность, что вы столкнулись с MultipleBagFetchException:

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

В этой статье мы рассмотрим причины, по которым Hibernate выбрасывает MultipleBagFetchException, а также лучший способ решения этой проблемы.

Спонсор поста

Используемые версии

Java 17
Hibernate 6.1.0.Final
H2 Database 2.1.214

Доменная модель

В нашем приложении определены три сущности: Post, PostComment и Tag, которые связаны следующим образом:

Нас в основном интересует то, что сущность Post определяет двунаправленную связь @OneToMany с дочерней сущностью PostComment, а также однонаправленную связь @ManyToMany с сущностью Tag.

@OneToMany(
    mappedBy = "post",
    cascade = CascadeType.ALL,
    orphanRemoval = true
)
private List<PostComment> comments = new ArrayList<>();
 
@ManyToMany(
    cascade = {
        CascadeType.PERSIST,
        CascadeType.MERGE
    }
)
@JoinTable(
    name = "post_tag",
    joinColumns = @JoinColumn(name = "post_id"),
    inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private List<Tag> tags = new ArrayList<>();

Причина, по которой для связи @ManyToMany установлены только PERSIST и MERGE, заключается в том, что Tag не является дочерней сущностью.

Поскольку жизненный цикл Tag не связан с сущностью Post, каскадная операция REMOVE или включение механизма orphanRemoval было бы ошибкой.

Исправление для Hibernate

Для начала рассмотрим причины появления исключения в Hibernate, а также вариант исправления, который вредит производительности приложения, но который часто упоминают на различных форумах. Далее мы рассмотрим вариант, как исправить эту проблему в Spring JPA.

Если мы хотим получить объекты Post со значениями идентификатора от 1 до 50, а также все связанные с ними объекты PostComment и Tag, мы напишем запрос, подобный следующему:

List<Post> posts = entityManager.createQuery("""
    select p
    from Post p
    left join fetch p.comments
    left join fetch p.tags
    where p.id between :minId and :maxId
    """, Post.class)
.setParameter("minId", 1L)
.setParameter("maxId", 50L)
.getResultList();

Однако при выполнении приведенного выше запроса сущности Hibernate выбрасывает ошибку MultipleBagFetchException при компиляции запроса JPQL:

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [
	dev.struchkov.example.hibernate.nbfe.problem.domain.Post.comments,
	dev.struchkov.example.hibernate.nbfe.problem.domain.Post.tags
]

Таким образом, Hibernate не выполняет SQL-запрос. Причина, по которой Hibernate выбрасывает исключение, заключается в том, что могут возникать дубликаты.

Как НЕ “исправить” проблему

Если вы погуглите MultipleBagFetchException, вы увидите множество неправильных ответов, например, этот ответ на StackOverflow, который, что удивительно, имеет более 300 голосов.

Ответ обновили, и теперь он содержит предупреждение 🙂

Так просто, но так неправильно!

Использование Set вместо List

Итак, давайте изменим тип коллекции связи с List на Set:

@OneToMany(
    mappedBy = "post",
    cascade = CascadeType.ALL,
    orphanRemoval = true
)
private Set<PostComment> comments = new HashSet<>();
 
@ManyToMany(
    cascade = {
        CascadeType.PERSIST,
        CascadeType.MERGE
    }
)
@JoinTable(
    name = "post_tag",
    joinColumns = @JoinColumn(name = "post_id"),
    inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();

При повторном выполнении предыдущего запроса, исключение не возникнет. Однако вот SQL-запрос, который Hibernate выполнил:

select p1_0.id,
       c1_0.post_id,
       c1_0.id,
       c1_0.review,
       t1_0.post_id,
       t1_1.id,
       t1_1.name,
       p1_0.title
from Post p1_0
         left join PostComment c1_0
                   on p1_0.id = c1_0.post_id
         left join (post_tag t1_0 join Tag t1_1 on t1_1.id = t1_0.tag_id)
                   on p1_0.id = t1_0.post_id
where p1_0.id between 1 and 50;
Итак, что же не так в этом SQL-запросе?

Таблицы post и post_comment связаны через столбец внешнего ключа post_id, поэтому объединение дает набор результатов, содержащий все строки таблицы post со значениями первичного ключа от 1 до 50 вместе со связанными с ними строками таблицы post_comment.

Таблицы post и tag также связаны через столбцы post_id и tag_id в связующей таблице post_tag, поэтому эти два объединения дают набор результатов, содержащий все строки таблицы post со значениями от 1 до 50, а также связанные с ними строки таблицы tag.

Теперь, чтобы объединить эти два набора результатов, база данных может использовать только декартово произведение, поэтому конечный набор результатов содержит 50 строк post, умноженных на соответствующие строки таблицы post_comment и tag.

Представим, что у нас 50 постов, по 10_000 комментариев для каждого, также каждый пост имеет 10 тегов. Конечный набор результатов будет содержать 10_000_000 записей (50 x 10000 x 20), как показано в следующем тестовом примере:

List<Post> posts = entityManager.createQuery("""
    select p
    from Post p
    left join fetch p.comments
    left join fetch p.tags
    where p.id between :minId and :maxId
    """, Post.class)
.setParameter("minId", 1L)
.setParameter("maxId", 50L)
.getResultList();

Этот вариант сработает правильно, но мы заплатим за это производительностью. Сделаем импровизированный бенчмарк: перед и после выполнения этого кода получим текщее время системы в миллисекундах, а далее вычтем из большего меньшее и так получим потраченное время на выполнение. Да это не самый удачный способ замера, но даже он подойдет.

Результат бенчмарка: 24192 миллисекунд. Запомните это значение, сравним его с правильным решением.

Чтобы избежать декартова произведения, за один раз можно получить не более одной связи. Таким образом, вместо выполнения одного JPQL-запроса, который извлекает две связи сразу, мы можем выполнить два JPQL-запроса:

List<Post> posts = entityManager.createQuery("""
    select distinct p
    from Post p
    left join fetch p.comments
    where p.id between :minId and :maxId""", Post.class)
.setParameter("minId", 1L)
.setParameter("maxId", 50L)
.getResultList();
 
posts = entityManager.createQuery("""
    select distinct p
    from Post p
    left join fetch p.tags t
    where p in :posts""", Post.class)
.setParameter("posts", posts)
.setHint(QueryHints.PASS_DISTINCT_THROUGH, false)
.getResultList();

Первый запрос JPQL определяет основные критерии фильтрации и извлекает объекты Post вместе с соответствующими записями PostComment.

Теперь нам нужно получить сущности Post вместе с Tag. Благодаря Persistence Context, Hibernate установит коллекцию tags для ранее полученных сущностей Post.

Здесь применяем тот же бенчмарк, и получаем 3300 миллисекунд, что примерно в 7 раз быстрее.

Решение для Spring Jpa

Вряд ли вы где-нибудь увидите работу с чистым Hibernate, скорее всего это будет Spring JPA. Давайте посмотрим, как и для него решить эту проблему.

Для начала создаем проблему:

package dev.struchkov.example.spring.nbfe.repository; import dev.struchkov.example.spring.nbfe.domain.Post; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List;

public interface PostRepository extends JpaRepository<Post, Long> { @Query(""" select distinct p from Post p left join fetch p.comments left join fetch p.tags where p.id between :minId and :maxId """) List<Post> findAllWithCommentsAndTags( @Param("minId") long minId, @Param("maxId") long maxId ); }

Ваше приложение Spring даже не запустится, выбросив исключение MultipleBagFetchException при попытке создать JPA TypedQuery из связанной аннотации @Query.

Таким образом, хотя мы не можем получить обе коллекции с помощью одного запроса JPA, мы точно также можем использовать два запроса для получения всех необходимых нам данных.

package dev.struchkov.example.spring.nbfe.repository; import dev.struchkov.example.spring.nbfe.domain.Post; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List;

public interface PostRepository extends JpaRepository<Post, Long> { @Query(""" select distinct p from Post p left join fetch p.comments where p.id between :minId and :maxId """) List<Post> findAllWithComments( @Param("minId") long minId, @Param("maxId") long maxId ); @Query(""" select distinct p from Post p left join fetch p.tags where p.id between :minId and :maxId """) List<Post> findAllWithTags( @Param("minId") long minId, @Param("maxId") long maxId ); }

Запрос findAllWithComments() будет собирать нужные сущности Post вместе со связью PostComment, а запрос findAllWithTags() будет собирать сущности Post вместе со связью Tag.

Выполнение двух запросов позволит нам избежать декартова произведения, но необходимо объединить результаты так, чтобы вернуть одну коллекцию записей Post, содержащую инициализированные коллекции комментариев и тегов.

И именно здесь нам снова поможет кэш первого уровня Hibernate. Создадим PostService и определим метод findAllWithCommentsAndTagsmethod(), который реализуется следующим образом:

package dev.struchkov.example.spring.nbfe.service; import dev.struchkov.example.spring.nbfe.domain.Post; import dev.struchkov.example.spring.nbfe.repository.PostRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List;

@Service @RequiredArgsConstructor public class PostService { private final PostRepository postRepository; @Transactional(readOnly = true) public List<Post> findAllWithCommentsAndTags(long minId, long maxId) { final List<Post> posts = postRepository.findAllWithComments( minId, maxId ); return !posts.isEmpty() ? postRepository.findAllWithTags( minId, maxId ) : posts; } }

Из-за аннотации @Transactional метод будет выполняться в транзакционном контексте, что означает, что оба вызова метода PostRepository будут происходить в контексте одного и того же Persistence Context.

По этой причине методы репозитория вернут два объекта List, содержащие одинаковые ссылки на объекты Post, поскольку вы можете иметь не более одной ссылки на объект, управляемой данным Persistence Context.

В то время как первый метод будет извлекать объекты Post и хранить их в Persistence Context, второй метод, просто объединит существующие объекты со ссылками, извлеченными из БД, которые теперь содержат инициализированные коллекции тегов.

Таким образом, и комментарии, и коллекции тегов будут извлечены до возврата списка объектов Post обратно вызывающему метод сервису.

Заключение

Существует так много решений в блогах, видео, книгах и ответов на форумах, предлагающих неправильное решение проблемы MultipleBagFetchException Hibernate. Все эти ресурсы говорят вам, что использование Set вместо List является правильным способом избежать этого исключения.

Однако исключение MultipleBagFetchException говорит вам о том, что может быть получено декартово произведение, а это в большинстве случаев нежелательно при выборке сущностей, поскольку может привести к ужасным проблемам с производительностью.

Выполняя выборку не более одной коллекции на запрос, вы не только предотвратите эту проблему, но и избежите декартова произведения SQL, которое может возникнуть при выполнении одного SQL-запроса, объединяющего несколько несвязанных ассоциаций “один-ко-многим”.

Знание того, как бороться с исключением MultipleBagFetchException, очень важно при использовании Spring Data JPA, поскольку в конечном итоге вы столкнетесь с этой проблемой.

On the one hand, JPA and Hibernate provide an abstraction to do the CRUD operations against various databases, enable us to develop database applications quickly

On the other hand, JPA and Hibernate also create some performance issues that we need to be taken care of

This article will explore some common problems you may encounter when working with JPA and Hibernate in Spring Boot and Spring Data applications

Let’s get started!

Problem 1: EAGER FetchType

There are two types of fetching strategy in JPA and Hibernate to load data for the associated (child) entities, EAGER and LAZY. While LAZY only fetches data when they are needed (at the first time accessed of the associated entities), EAGER always fetches child entities along with its parent

While LAZY can cause LazyInitializationException, EAGER causes Hibernate to generate and execute unnecessary SELECT SQLs to the database and so increasing the memory consumption for unnecessary data

Let’s take a look at the following example

@Entity
class Company {  
    @OneToMany(fetch = FetchType.EAGER)
    private Set<Employee> employees;

    @OneToOne(fetch = FetchType.EAGER)
    private Address address;

    //...
}

interface CompanyRepository extends JpaRepository<Company, Integer> {  
    List<Company> findFirst10ByOrderByNameAsc();
}

@Service
class CompanyService {  
    //...

    public List<String> findCompanyNames() {
        List<Company> companies = companyRepository.findFirst10ByOrderByNameAsc();
        return companies.stream()
            .map(Company::getName)
            .collect(Collectors.toList());
    }
}

The above findCompanyNames() method only needs company names. However, besides fetching Company data, Hibernate also eagerly fetches data in the background for other EAGER FetchType associations including Set<Employee> and Address. The problem would become more significant when you have more EAGER associations or more entities in each

Apart from consuming memory for unnecessary data, EAGER FetchType can also cause the N+1 SQL problem which you can find more details in the latter part of this article

Approach 1: LAZY FetchType

  • Avoid using EAGER fetch in JPA and Hibernate. EAGER can fix the LazyInitializationException caused by the LAZY but it creates a performance pitfall especially when you have multiple EAGER associations in the entity

  • EAGER FetchType is the default in JPA ToOne mappings (@ManyToOne, @OneToOne), make sure you change them to LAZY explicitly

@ManyToOne(fetch = FetchType.LAZY)
private BookCategory bookCategory;

@OneToOne(fetch = FetchType.LAZY)
private BookDetail bookDetail;

LazyInitializationException is caused by uninitialized LAZY FetchType associations when they are accessed outside the transactional context

@Entity
public class Quote {  
    @ManyToOne(fetch = FetchType.LAZY)
    private Author author;

    //...
}

interface QuoteRepository extends JpaRepository<Quote, Integer>{  
    List<Quote> findFirst10ByOrderByName();
}

@Service
class QuoteService  
    //...

    public List<QuoteWithAuthor> findQuotesWithAuthor() {
        return of(quoteRepository.findFirst10ByOrderByName());
    }

    List<QuoteWithAuthor> of(List<Quote> quotes) {
        return quotes.stream().map(q -> {
            Author a = q.getAuthor();
            AuthorIdName ain = new AuthorIdName(a.getId(), a.getName());
            return new QuoteWithAuthor(q.getId(), q.getName(), ain);
        }).collect(Collectors.toList());
    }
}

In the above findQuotesWithAuthor() method, LazyInitializationException is thrown as the LAZY associated entity author is not initialized, the quoteRepository.findFirst10ByOrderByName() only fetchs the parent and EAGER associations then close the transaction

There are several ways to resolve the LazyInitializationException. However, some of them are pitfalls

Pitfall 2.1: EAGER FetchType

Use EAGER FetchType for the author association, so its data will be available along with its parent. However, this approach can create a performance pitfall as we learned in the previous section. It also can create the N+1 problem

Pitfall 2.2: @Transactional

Add @Transacional annotation to findQuotesWithAuthor() method can keep the associations fetching executed in database transactional context (so resolving the LazyInitializationException) but it can create the N+1 problem. You can learn more about this problem in the latter part of this tutorial

Pitfall 2.3: Hibernate.initialize(Object)

Hibernate.initialize(Object) forces initialization of a proxy or persistent collection. It helps initialize LAZY FetchType associations manually but also brings some drawbacks

  • It generates and executes an additional SQL against the database each time

  • It does not initialize child entities of the input object

Try to fix the symptoms and do outside the repository (data access layer) are the common theme of pitfall approaches

On the opposites, the right approaches will try to do inside the repository and fix the root cause by hinting JPA and Hibernate to generate and execute only 1 SQL to fetch the necessary association’s data as there are no throwing LazyInitializationException errors if all the necessary data are initialized before using

Approach 2.1: Projection

Spring Data can help retrieve partial view of a JPA @Entity with interface-based or class-based projection (DTO classes)

interface QuoteProjection {  
    Integer getId();
    String getName();
    AuthorProjection getAuthor();
}

interface AuthorProjection {  
    Integer getId();
    String getName();
}

interface QuoteRepository extends JpaRepository<Quote, Integer> {  
    List<QuoteProjection> findFirst10ByOrderByNameDesc();
}

QuoteProjection and AuthorProjection are the partial views of 2 JPA entities, Quote and Author respectively

In findFirst10ByOrderByNameDesc() method of QuoteRepository, Spring Data JPA fetchs and converts the return data to List<QuoteProjection>

Approach 2.2: @EntityGraph

@EntityGraph annotation is used on the repository methods, providing a declarative way to fetch associations in a single query

interface QuoteRepository extends JpaRepository<Quote, Integer> {  
    @EntityGraph(attributePaths = "author")
    List<Quote> findFirst10ByOrderByName();
}

Approach 2.3: JPQL JOIN FETCH

JPQL supports JOIN FETCH to fetch associations data in a single query

interface QuoteRepository extends JpaRepository<Quote, Integer> {  
    @Query(value="FROM Quote q LEFT JOIN FETCH q.author")
    List<Quote> findWithAuthorAndJoinFetch(Pageable pageable);
}

Problem 3: N+1 SQL statements

The N+1 SQL problem occurs on the associated (child) entities when JPA and Hibernate have to execute N additional SQLs to do the operation

The issue can happen on both EAGER and LAZY FetchType and on reading or deleting operation against a database

Problem 3.1: N+1 on SELECT

Let’s take a look at the following example

@Entity
class Company {  
    @OneToMany(fetch = LAZY, mappedBy = "company", cascade = ALL)
    private Set<Employee> employees;

    @OneToMany(fetch = EAGER, mappedBy = "company", cascade = ALL)
    private Set<Department> departments;

    //...
}

interface CompanyRepository extends JpaRepository<Company, Integer> {  
    List<Company> findFirst10ByOrderByNameAsc();
}

@Service
class CompanyService {  
    //...

    public List<String> findCompanyNames() {
        List<Company> companies = companyRepository.findFirst10ByOrderByNameAsc();
        return companies.stream()
            .map(Company::getName)
            .collect(Collectors.toList());
    }

    @Transactional
    public List<CompanyIdName> findCompanyIdNames() {
        return companyRepository.findFirst10ByOrderByNameAsc().stream()
            .map(CompanyIdName::of)
            .collect(Collectors.toList());
    }
}

Suppose you have 10 Companies and each Company has some Employees in the database. With JPA show-sql and Hibernate generate_statistics settings are enabled in your Spring Boot application properties

spring.jpa.show-sql=true  
spring.jpa.properties.hibernate.generate_statistics=true

When running the findCompanyNames() method, you may end up with the following output in the console

... nanoseconds spent preparing 11 JDBC statements;
... nanoseconds spent executing 11 JDBC statements;
  • The first SQL statement is executed to load the list of 10 companies List<Company>

  • The 10 consecutive SQLs, corresponding to the number of elements returned from the result of the first SQL statement, are executed to fetch the Set<Department> EAGER associations

Separately, when running the findCompanyIdNames() method, you may end up with the following output in the console

... nanoseconds spent preparing 21 JDBC statements;
... nanoseconds spent executing 21 JDBC statements;
  • The first SQL statement is executed to load the list of 10 companies List<Company>

  • The 20 consecutive SQLs, corresponding to the number of elements returned from the result of the first SQL statement, are executed to fetch the EAGER Set<Department> and LAZY Set<Employee> associations

Approach 3.1.1: @EntityGraph and Projection

@EntityGraph annotation is used on the repository methods, providing a declarative way to fetch associations in a single query

Spring Data can help to retrieve partial view of a JPA @Entity with interface-based or class-based projection (DTO classes)

interface CompanyProjection {  
    int getId();
    String getName();
    List<EmployeeProjection> getEmployees();
}

interface EmployeeProjection {  
    int getId();
    String getName();
}

@EntityGraph(attributePaths = "employees")
List<CompanyProjection> findFirst10ByOrderByIdDesc();

CompanyProjection and EmployeeProjection are the partial views of 2 JPA entities, Company and Employee respectively

In findFirst10ByOrderByIdDesc() method, Spring Data JPA fetchs and converts the return data to List<CompanyProjection>

Approach 3.1.2: JOIN FETCH

JPQL supports JOIN FETCH to fetch associations data in a single query

@Query(value="FROM Company c LEFT JOIN FETCH c.employees")
List<Company> findWithEmployeesAndJoinFetch(Pageable pageable);

Try to hint JPA and Hibernate to generate and execute only 1 SQL is the common pattern of the above approaches. Now
when you trigger the findFirst10ByOrderByIdDesc() or findWithEmployeesAndJoinFetch() method, you would observe the expected output

... nanoseconds spent preparing 1 JDBC statements;
... nanoseconds spent executing 1 JDBC statements;

Problem 3.2: N+1 on DELETE

N+1 may also occur on Delete operations. Suppose you’d like to build a repository method to delete employees by a company id

interface EmployeeRepository extends JpaRepository<Employee, Integer> {  
    @Transactional
    void deleteByCompanyId(int companyId);
}

When triggering deleteByCompanyId() method and looking into the output console, you would find out that Hibernate deletes one by one employee. It generates N+1 queries to do the operation

The first query selects a list of employees of companies with id as companyId

The second query selects company data with id as companyId

Then N consecutive queries are generated and executed to delete one by one employee, N is the size of the result list of the first query

Approach 3.2: Define the DELETE query explicitly

In the repository, define the DELETE query explicitly

interface EmployeeRepository extends JpaRepository<Employee, Integer> {  
    @Modifying
    @Transactional
    @Query("DELETE FROM Employee e WHERE e.company.id = ?1")
    void deleteInBulkByCompanyId(int companyId);

    @Transactional
    void deleteByCompanyId(int categoryId);
}

With the deleteInBulkByCompanyId() method, only 1 DELETE query is executed to delete employes in bulk instead of one by one

Conclusion

In this article, we explored some of the common problems when using JPA and Hibernate in Spring Boot and Spring Data applications. We also learned about the pitfalls and the right approaches to resolving the issues

Время на прочтение
7 мин

Количество просмотров 73K

В преддверии курса “Highload Architect” приглашаем вас посетить открытый урок по теме “Паттерны горизонтального масштабирования хранилищ”.

А пока делимся традиционным переводом полезной статьи.


Введение

В этой статье я расскажу, в чем состоит проблема N + 1 запросов при использовании JPA и Hibernate, и как ее лучше всего исправить. 

Проблема N + 1 не специфична для JPA и Hibernate, с ней вы можете столкнуться и при использовании других технологий доступа к данным.

Что такое проблема N + 1

Проблема N + 1 возникает, когда фреймворк доступа к данным выполняет N дополнительных SQL-запросов для получения тех же данных, которые можно получить при выполнении одного SQL-запроса.

Чем больше значение N, тем больше запросов будет выполнено и тем больше влияние на производительность. И хотя лог медленных запросов может вам помочь найти медленные запросы, но проблему N + 1 он не обнаружит, так как каждый отдельный дополнительный запрос выполняется достаточно быстро. 

Проблема заключается в выполнении множества дополнительных запросов, которые в сумме выполняются уже существенное время, влияющее на быстродействие.

Рассмотрим следующие таблицы БД: post (посты) и post_comments (комментарии к постам), которые связаны отношением “один-ко-многим”:

Вставим в таблицу post четыре строки:

INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 1', 1)
  
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 2', 2)
  
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 3', 3)
  
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 4', 4)

А в таблицу post_comment четыре дочерние записи:

INSERT INTO post_comment (post_id, review, id)
VALUES (1, 'Excellent book to understand Java Persistence', 1)
  
INSERT INTO post_comment (post_id, review, id)
VALUES (2, 'Must-read for Java developers', 2)
  
INSERT INTO post_comment (post_id, review, id)
VALUES (3, 'Five Stars', 3)
  
INSERT INTO post_comment (post_id, review, id)
VALUES (4, 'A great reference book', 4)

Проблема N+1 с простым SQL

Как уже говорилось, проблема N + 1 может возникнуть при использовании любой технологии доступа к данным, даже при прямом использовании SQL.

Если вы выберете post_comments с помощью следующего SQL-запроса:

List<Tuple> comments = entityManager.createNativeQuery("""
    SELECT
        pc.id AS id,
        pc.review AS review,
        pc.post_id AS postId
    FROM post_comment pc
    """, Tuple.class)
.getResultList();

А позже решите получить заголовок (title) связанного поста (post) для каждого комментария (post_comment):

for (Tuple comment : comments) {
    String review = (String) comment.get("review");
    Long postId = ((Number) comment.get("postId")).longValue();
 
    String postTitle = (String) entityManager.createNativeQuery("""
        SELECT
            p.title
        FROM post p
        WHERE p.id = :postId
        """)
    .setParameter("postId", postId)
    .getSingleResult();
 
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        postTitle,
        review
    );
}

Вы получите проблему N + 1, потому что вместо одного SQL-запроса вы выполнили пять (1 + 4):

SELECT
    pc.id AS id,
    pc.review AS review,
    pc.post_id AS postId
FROM post_comment pc
 
SELECT p.title FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
    
SELECT p.title FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
     
SELECT p.title FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
     
SELECT p.title FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'

Исправить эту проблему с N + 1 запросом очень просто. Все, что нужно сделать, это извлечь все необходимые данные одним SQL-запросом, например, так:

List<Tuple> comments = entityManager.createNativeQuery("""
    SELECT
        pc.id AS id,
        pc.review AS review,
        p.title AS postTitle
    FROM post_comment pc
    JOIN post p ON pc.post_id = p.id
    """, Tuple.class)
.getResultList();
 
for (Tuple comment : comments) {
    String review = (String) comment.get("review");
    String postTitle = (String) comment.get("postTitle");
 
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        postTitle,
        review
    );
}

На этот раз выполняется только один SQL-запрос и возвращаются все данные, которые мы хотим использовать в дальнейшем.

Проблема N + 1 с JPA и Hibernate

При использовании JPA и Hibernate есть несколько способов получить проблему N + 1, поэтому очень важно знать, как избежать таких ситуаций.

Рассмотрим следующие классы, которые мапятся на таблицы post и post_comments:

JPA-маппинг выглядят следующим образом:

@Entity(name = "Post")
@Table(name = "post")
public class Post {
 
    @Id
    private Long id;
 
    private String title;
 
    //Getters and setters omitted for brevity
}
 
@Entity(name = "PostComment")
@Table(name = "post_comment")
public class PostComment {
 
    @Id
    private Long id;
 
    @ManyToOne
    private Post post;
 
    private String review;
 
    //Getters and setters omitted for brevity
}

FetchType.EAGER

Использование явного или неявного FetchType.EAGER для JPA-ассоциаций — плохая идея, потому что будет загружаться гораздо больше данных, чем вам нужно. Более того, стратегия FetchType.EAGER также подвержена проблемам N + 1. 

К сожалению, ассоциации @ManyToOne и @OneToOne по умолчанию используют FetchType.EAGER, поэтому, если ваши маппинги выглядят следующим образом:

@ManyToOne
private Post post;

У вас используется FetchType.EAGER и каждый раз, когда вы забываете указать JOIN FETCH при загрузке сущностей PostComment с помощью JPQL-запроса или Criteria API:

List<PostComment> comments = entityManager
.createQuery("""
    select pc
    from PostComment pc
    """, PostComment.class)
.getResultList();

Вы сталкиваетесь с проблемой N + 1:

SELECT
    pc.id AS id1_1_,
    pc.post_id AS post_id3_1_,
    pc.review AS review2_1_
FROM
    post_comment pc
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4

Обратите внимание на дополнительные запросы SELECT, которые появились, потому что перед возвращением списка сущностей PostComment необходимо извлечь ассоциацию с post.

В отличие от значений по умолчанию, используемых в методе find из EntityManager, в JPQL-запросах и Criteria API явно указывается план выборки (fetch plan), который Hibernate не может изменить, автоматически применив JOIN FETCH. Таким образом, вам это нужно делать вручную.

Если вам совсем не нужна ассоциация с post, то не повезло: с использованием FetchType.EAGER нет способа избежать ее получения. Поэтому по умолчанию лучше использовать FetchType.LAZY.

Но если вы хотите использовать ассоциацию с post, то можно использовать JOIN FETCH, чтобы избежать проблемы с N + 1:

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    join fetch pc.post p
    """, PostComment.class)
.getResultList();
 
for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        comment.getPost().getTitle(),
        comment.getReview()
    );
}

На этот раз Hibernate выполнит один SQL-запрос:

SELECT
    pc.id as id1_1_0_,
    pc.post_id as post_id3_1_0_,
    pc.review as review2_1_0_,
    p.id as id1_0_1_,
    p.title as title2_0_1_
FROM
    post_comment pc
INNER JOIN
    post p ON pc.post_id = p.id
     
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
 
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
 
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
 
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'

Подробнее о том, почему следует избегать стратегии FetchType.EAGER, читайте в этой статье.

FetchType.LAZY

Даже если вы явно перейдете на использование FetchType.LAZY для всех ассоциаций, то вы все равно можете столкнуться с проблемой N + 1.

На этот раз ассоциация с post мапится следующим образом:

@ManyToOne(fetch = FetchType.LAZY)
private Post post;

Теперь, когда вы запросите PostComment:

List<PostComment> comments = entityManager
.createQuery("""
    select pc
    from PostComment pc
    """, PostComment.class)
.getResultList();

Hibernate выполнит один SQL-запрос:

SELECT
    pc.id AS id1_1_,
    pc.post_id AS post_id3_1_,
    pc.review AS review2_1_
FROM
    post_comment pc

Но если позже вы обратитесь к этой lazy-load ассоциации с post:

for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        comment.getPost().getTitle(),
        comment.getReview()
    );
}

Вы получите проблему с N + 1 запросом:

SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
 
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'

Поскольку ассоциация с post загружается лениво, при доступе к этой ассоциации будет выполняться дополнительный SQL-запрос для получения нужных данных.

Опять же, решение заключается в добавлении JOIN FETCH к запросу JPQL:

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    join fetch pc.post p
    """, PostComment.class)
.getResultList();
 
for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'",
        comment.getPost().getTitle(),
        comment.getReview()
    );
}

И как и в примере с FetchType.EAGER, этот JPQL-запрос будет генерировать один SQL-запрос.

Даже если вы используете FetchType.LAZY и не ссылаетесь на дочерние ассоциации двунаправленного отношения @OneToOne, вы все равно можете получить N + 1.

Подробнее о том, как преодолеть проблему N+1 c @OneToOne-ассоциациями, читайте в этой статье.

Кэш второго уровня

Проблема N + 1 также может возникать при использовании кэша второго уровня для обработки коллекций или результатов запроса.

Например, если выполните следующий JPQL-запрос, использующий кэш запросов:

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    order by pc.post.id desc
    """, PostComment.class)
.setMaxResults(10)
.setHint(QueryHints.HINT_CACHEABLE, true)
.getResultList();

Если PostComment не находится в кэше второго уровня, то будет выполнено N запросов для получения каждого отдельного PostComment:

-- Checking cached query results in region: org.hibernate.cache.internal.StandardQueryCache
-- Checking query spaces are up-to-date: [post_comment]
-- [post_comment] last update timestamp: 6244574473195524, result set timestamp: 6244574473207808
-- Returning cached query results
  
SELECT pc.id AS id1_1_0_,
       pc.post_id AS post_id3_1_0_,
       pc.review AS review2_1_0_
FROM post_comment pc
WHERE pc.id = 3
  
SELECT pc.id AS id1_1_0_,
       pc.post_id AS post_id3_1_0_,
       pc.review AS review2_1_0_
FROM post_comment pc
WHERE pc.id = 2
  
SELECT pc.id AS id1_1_0_,
       pc.post_id AS post_id3_1_0_,
       pc.review AS review2_1_0_
FROM post_comment pc
WHERE pc.id = 1

В кэше запросов хранятся только идентификаторы сущностей PostComment. Таким образом, если сущности PostComment не находятся в кэше, они будут извлечены из базы данных и вы получите N дополнительных SQL-запросов.


Подробнее о курсе “Highload Architect”.

I’m experiencing some difficulties with generating tables in the database through JPA and Hibernate annotations.

When the below code is executed it generates the tables with the following EER diagram.

Generated EER Diagram of Person, Student and Teacher

This is not how I want it to generate the tables.
First of all the relations between the tables are wrong, they need to be OneToOne and not OneToMany.
Secondly, i don’t want email to be the primary key in student and teacher.

In Student the ovNumber should be primary key and in Teacher the employeeNumber
I have tried doing it with the @Id annotation but that gives me the following error:

org.hibernate.mapping.JoinedSubclass cannot be cast to org.hibernate.mapping.RootClass

When i try to use @MappedSuperClass the table person does not generate, even when using @Inheritance(strategy=InheritanceType.TABLE_PER_CLASS).

Now my question,

How do I make another variable in subclasses the primary key for the corrosponding table whilst keeping the superclass primary key as foreign key?

How do I fix the relationship between the tables to be an OneToOne relation rather than a OneToMany relation?

Here is an EER diagram of how it should be.

EER Diagram of Person, Student and Teacher the way it should be

Below are the model classes that are used to generate the tables.

Person.java

@Entity
@Polymorphism(type=PolymorphismType.IMPLICIT)
@Inheritance(strategy=InheritanceType.JOINED)
public class Person implements Comparable<Person>, Serializable {

private static final long serialVersionUID = 1L;

@Id
@Column(name="email", length=64, nullable=false)
private String email;

@Column(name="firstName", length=255)
private String firstName;

@Column(name="insertion", length=255)
private String insertion;

@Column(name="lastName", length=255)
private String lastName;

public Person() {}

/**
 * constructor with only email.
 * 
 * @param email
 */
public Person(String email) {
    this.email = email;
}

/**
 * @param email
 * @param firstName
 * @param insertion
 * @param lastName
 */
public Person(String email, String firstName, String insertion, String lastName){
    this.setEmail(email);
    this.setFirstName(firstName);
    this.setInsertion(insertion);
    this.setLastName(lastName);
}

//getters and setters
public String getEmail() {
    return email;
}

public void setEmail(String email) {
    this.email = email;
}

public String getFirstName() {
    return firstName;
}

public void setFirstName(String firstName) {
    this.firstName = firstName;
}

public String getInsertion() {
    return insertion;
}

public void setInsertion(String insertion) {
    this.insertion = insertion;
}

public String getLastName() {
    return lastName;
}

public void setLastName(String lastName) {
    this.lastName = lastName;
}

@Override
public int compareTo(Person o) {
    return email.compareTo(o.getEmail());
}
}

Teacher.java

@Entity
@Table(name="teacher")
@PrimaryKeyJoinColumn(name="email", referencedColumnName="email")
public class Teacher extends Person {

private static final long serialVersionUID = 1L;

//this needs to be the pk of teacher table
//@Id
@Column(name="employeeNumber", length=6, nullable=false)
private int employeeNumber;

@Column(name="abbreviation", length=6)
private String abbreviation;

public Teacher(){}

/**
 * @param employeeNumber
 * @param email
 * @param firstName
 * @param insertion
 * @param lastName
 */
public Teacher(int employeeNumber, String email, String firstName, String insertion, String lastName){
    super(email, firstName, insertion, lastName);
    this.employeeNumber = employeeNumber;
    setAbbreviation();
}

public String getAbbreviation() {
    return abbreviation;
}

public void setAbbreviation() {
    this.abbreviation = getLastName().substring(0, 4).toUpperCase() + getFirstName().substring(0, 2).toUpperCase();
}

public void setAbbreviation(String abbreviation){
    this.abbreviation = abbreviation;
}

public int getEmployeeNumber() {
    return employeeNumber;
}

public void setEmployeeNumber(int employeeNumber) {
    this.employeeNumber = employeeNumber;
}

@Override
public String toString() {
    return "Teacher [abbreviation=" + abbreviation + ", employeeNumber=" + employeeNumber + "]";
}
}

Student.java

@Entity
@Table(name="student")
@PrimaryKeyJoinColumn(name="email", referencedColumnName="email")
public class Student extends Person {

private static final long serialVersionUID = 1L;

@Column(name="cohort")
private int cohort;

//FIXME this needs to be the pk of student table
//@Id
@Column(name="ovNumber", nullable=false)
private int studentOV;


public Student(){}

public Student(int studentOV, int cohort, String email, String firstName,
        String insertion, String lastName) {
    super(email, firstName, insertion, lastName);
    this.studentOV = studentOV;
    this.cohort = cohort;
}

public int getCohort() {
    return cohort;
}

public void setCohort(int cohort) {
    this.cohort = cohort;
}

public int getStudentOV() {
    return studentOV;
}

public void setStudentOV(int studentOV) {
    this.studentOV = studentOV;
}

@Override
public int compareTo(Person o) {
    return getEmail().compareTo(o.getEmail());
}

@Override
public String toString() {
    return "Student [firstName=" + getFirstName() + ", insertion=" + getInsertion() + ", lastName=" + getLastName() + ", email="
            + getEmail() + ", cohort=" + getCohort() + ", studentOV=" + getStudentOV() + "]";
}
}

Добавить комментарий