Привет! Меня зовут Артём Гордиенко, я работаю Java/Kotlin-разработчиком в Росбанке и занимаюсь разработкой микросервисов, необходимых для внешнеэкономической деятельности интернет-клиент-банка юридических лиц. Реализация этого доклада стала возможной благодаря одному-единственному сообщению, обнаруженному в логах одного нового микросервиса. Как оказалось, причина сообщения серьезно влияет на производительность приложения. Мне это показалось довольно интересным, и захотелось поделиться информацией с другими разработчиками. 

Многие сталкиваются с реализацией вывода списочных данных с пагинацией из БД. В Spring Data и в самом Hibernate есть стандартные решения, которые позволяют достаточно просто решить эту задачу. Но у предлагаемых решений есть подводные камни, и со временем или даже сразу они могут серьезно повлиять на производительность. В худшем случае — и вовсе привести к падению приложения.

В этом посте, основанном на моем докладе с JPoint 2023, я расскажу, при использовании каких стандартных решений вы столкнетесь с падением производительности и какие есть эффективные варианты реализации запросов с пагинацией. Также обсудим баги в Hibernate 6, которые я обнаружил.

Реализация приложения с пагинацией

Представим, что у вас есть задача реализовать админку для сотрудников банка, где будет отображаться обобщенная информация о каждом клиенте: счета, депозиты и т. д. У сотрудника банка должна быть возможность выбора размера страницы и перехода по страницам (то есть пагинация).

Приступим к реализации. Сразу отмечу, что все примеры будут на языке Kotlin с вариантами решений реализованных с помощью Spring Data и только Hibernate. 

Для начала посмотрим на модель данных. По условиям задачи у нас есть клиент с аккаунтами и депозитами. Основная таблица с клиентами связана с дополнительными таблицами по id клиента.

 

Для работы с данными таблицами объявим класс @Entity, где с помощью отношения @OneToMany свяжем таблицу клиента с таблицами аккаунтов и депозитов:

@Entity
@Table(name = "CLIENT")
class ClientEntity(

   @Id
   @Column(name = "ID")
   var id: Long? = null,

   @OneToMany(mappedBy = "client")
   var accounts: Set<AccountEntity> = mutableSetOf(),

   @OneToMany(mappedBy = "client")
   var deposits: Set<DepositEntity> = mutableSetOf()
)

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

С фронта приходит запрос с номером страницы и её размером. Далее запрос попадает в приложение и затем в репозиторий, где должен выполниться определенный SQL-запрос с пагинацией.

Реализуем этот SQL-запрос с помощью Spring Data. Для начала создадим репозиторий клиента, который наследуется от JpaRepository.

@Repository
interface ClientRepository : JpaRepository<ClientEntity, Long>

Далее воспользуемся стандартным методом JpaRepository с пагинацией, который принимает PageRequest, где мы указываем номер и размер страницы.

@Autowired
private lateinit var clientRepository: ClientRepository

clientRepository.findAll(PageRequest.of(pageNumber, pageSize))

Аналогичная реализация с помощью Hibernate будет выглядеть вот так:

val count = entityManager.createQuery("select count(c) from ClientEntity c", Long::class.java)
  .singleResult

val result = entityManager.createQuery("select c from ClientEntity c", ClientEntity::class.java)
  .setFirstResult(firstResult)
  .setMaxResults(maxResult)
  .resultList

В первом запросе вы получаете количество клиентов. Это необходимо для отображения общего количества страниц с клиентами в админке и перемещения по определенным страницам. Во втором запросе получаем самих клиентов на определенной странице. В случае Spring Data при вызове метода findAll эти два запроса формируются под капотом. 

Реализация готова, выполним запрос с помощью Spring Data, вызвав метод findAll:

clientRepository.findAll(PageRequest.of(pageNumber, pageSize))

Посмотрим логи. В логах все замечательно, видим запрос страницы с клиентами и запрос их количества:

1. Hibernate: select * from client c limit 50 offset 50
2. Hibernate: select count(c.id) from client c
* Для упрощения в логах я использую limit 50 offset 50 вместо offset 50 rows fetch first 50 rows only

Согласно задаче, необходимо посчитать количество аккаунтов и депозитов клиента, а также суммы на аккаунтах и депозитах каждого клиента.

Так как все расчеты обычно происходят в слое бизнес-логики приложения, необходимо преобразовать Entity-объекты клиентов в Domain-сущности, которые будут использоваться для расчетов. Для преобразования можно воспользоваться MapStruct: достаточно объявить интерфейс и функцию преобразования:

import org.mapstruct.Mapper

@Mapper
interface ClientEntityMapper {


  fun map(entity: ClientEntity): Client
}

Снова выполним запрос findAll с пагинацией и преобразованием Entity-объектов в Domain-сущности:

@Autowired
private lateinit var clientRepository: ClientRepository
@Autowired
private lateinit var mapper: ClientEntityMapper


clientLazyRepository.findAll(pageable).map { mapper.map(it) }

Заглянем в логи:

1. Hibernate: select * from client c limit 50 offset
2. Hibernate: select count(c.id) from client c
3. Hibernate: select * from account a where a.client_id=51
4. Hibernate: select * deposit d from deposit where d.client_id=51
5. Hibernate: select * from account a where a.client_id=52
6. Hibernate: select * deposit d from deposit where d.client_id=52
7. ...

Скорость выполнения такого запроса неудовлетворительна. Я думаю, пользователи приложения будут не очень довольны, если каждая страница со списком клиентов будет загружаться по 10 секунд.

Видно, что к основным запросам добавились запросы к таблицам аккаунтов и депозитов, которые повторяются с разными id клиентов. Из логов очевидно, что это проблема N+1.

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

Проблема N+1 и причина возникновения

Если посмотреть на реализацию того, что сгенерировал MapStruct при преобразовании объектов, то можно увидеть следующее:

Set<Account> set = accountEntityLazySetToAccountSet(entity.getAccounts());

В данном случае для заполнения коллекции аккаунтов Domain-модели клиента запрашивается коллекция аккаунтов Entity объекта клиента. В момент запроса коллекции Entity-объекта происходит обращение к базе данных с указанным id клиента:

Hibernate: select * from account a where a.client_id=?

Эти запросы повторяются для каждого клиента, и так возникает проблема N+1. Это на самом деле неудивительно, потому что в связи @OneToMany по умолчанию стратегия загрузки fetch установлена в FetchType.LAZY. 

@OneToMany(mappedBy = "client")
var accounts: Set<AccountEntity> = mutableSetOf()

Если заглянуть в аннотацию @OneToMany, можно увидеть:

/** (Optional) Whether the association should be lazily loaded or
* must be eagerly fetched. The EAGER strategy is a requirement on
* the persistence provider runtime that the associated entities
* must be eagerly fetched.  The LAZY strategy is a hint to the
* persistence provider runtime.
*/
FetchType fetch() default LAZY;

FetchType.LAZY по умолчанию означает, что дополнительный SQL-запрос будет сформирован в момент обращении к коллекции объекта Entity. Это мы и увидели в примере.

Возникает очевидный вопрос: а что если вместо FetchType.LAZY использовать FetchType.EAGER?

@OneToMany(mappedBy = "client", fetch = FetchType.EAGER)
var accounts: Set<AccountEntityEager> = mutableSetOf()

Установим в ассоциациях @OneToMany fetch = FetchType.EAGER для коллекций аккаунтов и депозитов, выполним запрос findAll и заглянем в логи:

1. Hibernate: select * from client c limit 50 offset 50
2. Hibernate: select count(c.id) from client c
3. Hibernate: select * from account a where a.client_id=51
4. Hibernate: select * deposit d from deposit where d.client_id=51
5. Hibernate: select * from account a where a.client_id=52
6. Hibernate: select * deposit d from deposit where d.client_id=52
7. …

Та же самая проблема N+1. Просто в предыдущем случае запросы к БД происходили при обращении к коллекции Entity, а здесь Hibernate самостоятельно выполняет запросы для каждого клиента до обращения к коллекции Entity, то есть до маппинга Entity в Domain-модель. 

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

Типичные решения проблемы N+1

Если проблема в лоб не решается заменой FetchType.LAZY на FetchType.EAGER, можно пойти на StackOverflow и поискать самый популярный вопрос по теме N+1. Вот, например, есть ответ, у которого целых 1444 голоса, и наверняка там эту проблему уже решили:

Три наиболее популярных решения предлагаемых на StackOverflow:

  • @EntityGraph,

  • join fetch,

  • нативный запрос с несколькими JOIN.

Все перечисленные решения используют JOIN для объединения данных таблиц клиентов, аккаунтов и депозитов в результате одного SQL-запроса. Таким образом предлагается решить проблему N+1. Я предлагаю воспользоваться @EntityGraph и join fetch, а потом посмотреть, что из этого получится.

Решение с помощью @EntityGraph

Если вы используете Spring Data, то с @EntityGraph будет проще простого. В репозитории достаточно определить кастомный метод, который принимает Pageable-параметр и возвращает страницу Page. Над методом необходимо определить аннотацию @EntityGraph, где в attributePaths указать название коллекций с ассоциацией @OneToMany, которые необходимо загрузить с помощью JOIN:

@Repository
interface ClientRepository : JpaRepository<ClientEntity, Long> {

   @EntityGraph(attributePaths = ["accounts", "deposits"])
   fun getAllBy(pageable: Pageable): Page<ClientEntity>
}

Если вы используете только Hibernate, над классом Entity вам необходимо определить @NamedEntityGraph, где вы также указываете коллекции, которые необходимо загрузить:

@NamedEntityGraph(
   name = "ClientEntityGraph",
   attributeNodes = [
     NamedAttributeNode("accounts"),
     NamedAttributeNode("deposits")
   ]
)
@Entity
@Table(name = "CLIENT")
class ClientEntity(

   @Id
   @Column(name = "ID")
   var id: Long? = null,

   @OneToMany(mappedBy = "client")
   var accounts: Set<AccountEntity> = mutableSetOf(),

   @OneToMany(mappedBy = "client")
   var deposits: Set<DepositEntity> = mutableSetOf()
)

Далее в запросе для Hibernate вы просто устанавливаете hint c указанным выше @EntityGraph:

val count = entityManager.createQuery("select count(c) from ClientEntity c", Long::class.java)
  .singleResult

val result = entityManager.createQuery("select c from ClientEntity c", ClientEntity::class.java)
  .setHint("jakarta.persistence.fetchgraph", entityManager.createEntityGraph("ClientEntityGraph"))
  .setFirstResult(firstResult)  
  .setMaxResults(maxResult)
  .resultList

Решение с помощью Fetch

Если вы используете Spring Data и Criteria API, то можете воспользоваться стандартным методом поиска findAll из JpaSpecificationExecutor, который принимает параметр Specification и Pageable. При реализации спецификации не забудьте указать JoinType:

clientRepository.findAll(getSpecification(), PageRequest.of(pageNumber, pageSize))

fun getSpecification(): Specification<ClientEntity> {
   return Specification<ClientEntity> { root, cq, cb ->       
     if (cq.resultType == ClientEntity::class.java) {
           root.fetch("accounts", JoinType.LEFT)
          root.fetch("deposits", JoinType.LEFT)
       }
       cb.and()
   }
}

Для Hibernate у нас реализация аналогична Spring Data, если вы решили использовать fetch:

val criteriaQueryEntity = criteriaBuilder.createQuery(ClientEntity::class.java)
val clientRoot = criteriaQueryEntity.from(ClientEntity::class.java)

clientRoot.fetch<AccountEntity, ClientEntity>("accounts", JoinType.LEFT)
clientRoot.fetch<DepositEntity, ClientEntity>("deposits", JoinType.LEFT)

val result = entityManager.createQuery("select c from ClientEntity c",
    ClientEntity::class.java)
   .setFirstResult(firstResult)
   .setMaxResults(maxResult)
   .resultList

Представим, что вы выбрали одно из решений, предложенных на StackOverflow. Запустили свои интеграционные тесты. Все тесты прошли. Вы заливаете код на прод и идете пить кофе. Но через некоторое время к вам прибегает ваш продакт-оунер и говорит: «Все запросы админки выполняются по 30 секунд, какие-то ошибки непонятные у пользователей, что происходит?!» 

Пагинация в памяти не лучшее решение

Разве одно из предложенных решений проблемы N+1 со StackOverflow не должно было ускорить отображение списков с пагинацией? Давайте попробуем разобраться в ситуации.

Для начала заглянем в логи:

Hibernate 5:
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Hibernate 6:
WARN: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

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

SQL-запрос также выглядит странно, будто чего-то не хватает:

select * from client c
left outer join account a on a.client_id = c.id
left outer join deposit d on d.client_id = c.id

Действительно, из запроса исчезли limit и offset. Если мы поищем информацию об этом warning, то узнаем, что Hibernate в данном случае с помощью одного запроса с JOIN-ами полностью выгружает все данные из таблиц клиентов, аккаунтов и депозитов — и только после этого производит пагинацию в памяти приложения. Очевидно, что выгрузка данных из всех таблиц с последующей пагинацией в памяти не может быть эффективным решением. Отсюда и медленная загрузка страниц, и большое потребление памяти, которое может приводить к OutOfMemoryError в худшем случае.

Почему исчезли limit и offset?

Может быть, проблема в том, что из запроса исчезли limit и offset? На самом деле нет. Более того, если в SQL-запросе оставить limit и offset, то мы будем получать неверное количество клиентов в результате SQL-запроса. Это подтверждает баг, который я обнаружил в Hibernate 6:

Описание бага в пространстве задач Hibernate 6. Ссылки на обсуждение бага и тестовое приложение для воспроизведения
Описание бага в пространстве задач Hibernate 6. Ссылки на обсуждение бага и тестовое приложение для воспроизведения

Я предлагаю воспроизвести баг, чтобы понять, почему исчезли limit и offset и происходит неэффективная пагинация в памяти приложения. Для реализации возьмем версию Hibernate 6.1.6.Final, где этот баг воспроизводится. Представим, что у нашего клиента есть два аккаунта и два депозита:

Всего у нас 4 клиента. Выполним вот такой запрос в тесте и попробуем получить четырех клиентов:

root.fetch<AccountEntity, ClientEntity>("accounts", JoinType.LEFT)
root.fetch<DepositEntity, ClientEntity>("deposits", JoinType.LEFT)
val result = entityManager.createQuery(select)
   .setFirstResult(0)
   .setMaxResults(4)
   .resultList
assertEquals(4, result.size) // Ошибка, ожидалось 4 клиента, но в результате 1

В логах будет следующий SQL-запрос:

select * from client c
left outer join account a on a.client_id = c.id
left outer join deposit d on d.client_id = c.id
limit 4 offset 0

Аккаунты и депозиты перемножились друг с другом, и получилось декартово произведение.

Когда в результате SQL запроса с limit и offset четыре строки попали в Hibernate, произошло преобразование в один объект клиента. Там, где мы ожидали получить четырех клиентов в тесте, мы получили одного, и поэтому тест не прошёл.

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

Получается, что, используя стандартные решения Spring Data для запросов с пагинацией, мы можем столкнуться сразу с двумя проблемами — N+1 и проблемой декартова произведения. Возможно есть решения, которые одновременно решают обе проблемы?

Старый добрый @BatchSize

Одно из таких решений — это всем известный @BatchSize.

@Target({TYPE, METHOD, FIELD})
@Retention(RUNTIME)
public @interface BatchSize {
  /**
   * Strictly positive integer.
   */
  int size();
}

Если заглянуть в аннотацию, мы увидим, что можно задать только один-единственный параметр — размер size. Который, кстати, является обязательным.

Чтобы воспользоваться @BatchSize, достаточно определить данную аннотацию над коллекцией с @OneToMany, которую необходимо нам загрузить.

@Entity
@Table(name = "CLIENT")
class ClientEntity(

   @Id
   @Column(name = "ID")
   var id: Long? = null,

   @OneToMany(mappedBy = "client")
   @BatchSize(size = 50)
   var accounts: Set<AccountEntity> = mutableSetOf(),

   @OneToMany(mappedBy = "client")
   @BatchSize(size = 50)
   var deposits: Set<DepositEntity> = mutableSetOf()
)

Выполним запрос:

@Autowired
private lateinit var clientRepository: ClientRepository
@Autowired
private lateinit var mapper: ClientEntityMapper


clientLazyRepository.findAll(pageable).map { mapper.map(it) }

Заглянем в логи:

1. Hibernate: select * from client c limit 50 offset 50
2. Hibernate: select count(c.id) from client c
3. Hibernate: select * from account a where a.client_id in (51, 52, ...)
4. Hibernate: select * from deposit d where d.client_id in (51, 52, ...)

Количество запросов существенно уменьшилось — на размер size, который мы задали. Это значит, что проблема N+1 решена. Также исчезли JOIN из запросов, а значит, и проблема декартова произведения тоже позади. Теперь не нужно получать данные из всех таблиц и производить пагинацию в памяти приложения. @BatchSize справился.

Особенности использования @BatchSize

Но возникает резонный вопрос: а какой оптимальный размер size необходимо задавать в аннотации @BatchSize? Если вы откроете официальную документацию, то увидите, что оптимальный размер size там не указывается. Лишь описано, что size должен быть небольшого размера.

Понятно, что, установив size, равный единице, мы снова получим проблему N+1. Если он будет размером в несколько тысяч, то можно столкнуться с  ограничением в базе данных в размере выражения in и получить ошибку. Также могут возникнуть проблемы с производительностью при увеличении размера in. Так что размер size стоит подбирать опытным путем, соблюдая баланс, не делая размер слишком большим или маленьким.

Также, чтобы не определять @BatchSize над каждой коллекцией с @OneToMany, его можно задать в application.yaml вашего Spring Boot приложения:

spring:
 jpa:
   properties:
     hibernate:
       default_batch_fetch_size: 50

В таком случае данная настройка будет применяться ко всем коллекциям с ассоциацией @OneToMany.

Важно отметить, что @BatchSize имеет стратегию выборки batch_fetch_style: LEGACY по умолчанию. В Hibernate 5 это может приводить к более высокому потреблению памяти. При использовании LEGACY-алгоритма Hibernate создает prepared statement запросы возможных вариаций in и хранит их в памяти приложения. Соответственно, чем больше размер @BatchSize, тем больше потребление памяти. Более подробно об этом можно почитать в обсуждении.

Для меньшего потребления памяти лучше заменить стандартный алгоритм LEGACY на PADDED или DYNAMIC. Для DYNAMIC SQL-запрос будет формироваться динамически без использования prepared statement запросов.

Из обсуждения по ссылке выше следует, что до Hibernate 5.3.0.Final потребление у LEGACY алгоритма было в несколько раз больше, чем у DYNAMIC. В версии 5.3.0 это частично пофиксили: потребление теперь «всего» в два раза больше:

Как работа данных алгоритмов выглядит на практике? Что если, например, размер @BatchSize равен 50, но мы хотим получить аккаунты у 39 клиентов? Это зависит от алгоритма выборки. В LEGACY выполнится несколько запросов prepared statement:

// LEGACY
Hibernate: select * from account a where a.client_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: select * from account a where a.client_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: select * from account a where a.client_id in (?, ?)

С DYNAMIC алгоритмом сформируется один запрос с id 39 клиентов в in:

// DYNAMIC
Hibernate: select * from account a where a.client_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

В Hibernate 6 в работе @BatchSize произошли изменения. Настройка алгоритма выборки batch_fetch_style помечена как @Deprecated и будет проигнорирована, если она установлена. В Hibernate 6 по умолчанию используется алгоритм DYNAMIC. Он работает точно так же, как в Hibernate 5.

Когда я тестировал работу @BatchSize в Hibernate 6.1.6.Final при подготовке поста, то обнаружил баг: при использовании @BatchSize генерировались лишние запросы и увеличивалось время выполнения запросов. К счастью, это баг нашли до меня, и в версии 6.1.7 Final (Spring-Boot 3.0.5) он уже был исправлен. Так что будьте осторожны при выборе версии Hibernate.

Если оценивать решение с использованием @BatchSize, можно выделить следующие преимущества:

  • Это стандартная аннотация.

  • Достаточно добавить несколько аннотаций, чтобы у вас все заработало.

  • Решается проблема декартова произведения и N+1.

Какие можно выделить недостатки:

  • Необходимо указывать размер @BatchSize.

  • Размер @BatchSize задается на этапе компиляции; в процессе работы приложения его поменять уже нельзя.

Аннотация @BatchSize появилась в Hibernate аж в 2008 году. Возможно, придумали более современные альтернативы для решения проблемы декартова произведения и N+1?

Альтернатива @BatchSize — разделение запроса на две части с использованием @EntityGraph

Альтернатива @BatchSize есть, но её необходимо реализовывать вручную. При таком решении в первой части запроса мы запрашиваем id наших клиентов с определенной страницы. А во второй части запроса с помощью @EntityGraph и JOIN мы получаем всех клиентов с необходимыми данными из аккаунтов и депозитов:

@Repository
interface ClientRepository : JpaRepository<ClientEntity, Long> {

   @Query("select c.id from ClientEntity c")
   fun getAllIds(pageable: Pageable): Page<Long>

   @EntityGraph(attributePaths = ["accounts", "deposits"])
   fun getAllByIdIn(clientIds: List<Long>): List<ClientEntity>
}

val clientIds = clientRepository.getAllIds(PageRequest.of(pageNumber, pageSize))
val result = clientRepository.getAllByIdIn(clientIds.content)

Если заглянем в логи, то увидим, что сначала запрашиваются id клиентов с необходимой нам страницы. Затем производится JOIN клиентов с аккаунтами и депозитами на данной странице:

1. Hibernate: select c.id from client c limit 50 offset 50
2. Hibernate: select count(c.id) from client c
3. Hibernate: select * from client c left outer join deposit d on
   c.id=d.client_id left outer join account a on c.id=a.client_id where c.id in (51, 52, ...)

Выглядит это решение довольно эффективно. По крайней мере, количество запросов в сравнении с использованием @BatchSize стало меньше. В данном случае их всегда три. По причине меньшего количества запросов я часто встречал мнение, что лучше использовать JOIN вместо @BatchSize.

Плюсы разделенного запроса:

  • Всегда три запроса.

  • Решается проблема N+1.

  • Решается проблема декартова произведения.

Недостатки:

  • Проблема декартова произведения решается частично, JOIN все-таки остаются в запросе.

  • Ручная реализация, более сложная по сравнению с @BatchSize.

Какая реализация работает быстрее?

Я сравнил скорость работы различных реализаций: сделал тестовое приложение на вывод 50/100/300/1000 клиентов на одной странице и выполнил запросы:

Производительность LAZY и EAGER примерно одинаковая, и она падает с увеличением размера страницы. Реализация с fetch, которая была взята со StackOverflow и приводит к появлению WARNING с пагинацией в памяти приложения, также представлена на графике. Так как выгружаются данные из всех таблиц, а потом происходит пагинация в памяти приложения, увеличение количества запрашиваемых клиентов не влияет на время выполнения запроса.

Последние два варианта — @BatchSize и разделенный запрос. Видно, что это самые эффективные решения. Они показывают примерно одинаковую производительность, можно сказать, идут нос к носу. Но хотелось бы все-таки понять, какое решение наиболее эффективно. Чтобы разобраться, проведем следующий эксперимент:

  • Создадим Entity Client с 20 ассоциациями @OneToMany для Entity Account.

  • В каждой коллекции будет по 2 аккаунта.

  • Выполним запросы с загрузкой 5, 10, 15 и 20 коллекций с помощью @BatchSize и разделенного запроса с @EntityGraph.

  • Оценим результаты работы на графике.

Entity Client для разделенного запроса:

@Entity
@Table(name = "CLIENT_TEST")
class ClientEntityTest(
   @Id
   @Column(name = "ID")
   var id: Long? = null,
   @OneToMany(mappedBy = "client")
   var accounts1: Set<Account1> = mutableSetOf(),
   @OneToMany(mappedBy = "client")
   var accounts2: Set<Account2> = mutableSetOf(),
   @OneToMany(mappedBy = "client")
   var accounts3: Set<Account3> = mutableSetOf(),
   @OneToMany(mappedBy = "client")
   var accounts4: Set<Account4> = mutableSetOf(),
   // ...
)

Entity Account для разделенного запроса:

@Entity
@Table(name = "ACCOUNT_1")
class Account1(
   @Id
   @Column(name = "ID")
   var id: Long? = null,
   @Column(name = "NUMBER")
   var number: String? = null,
   @ManyToOne
   @JoinColumn(name = "CLIENT_ID")
   var client: ClientEntityTest? = null
)

Реализация для Entity Client @BatchSize. Единственное отличие — для OneToMany добавилась аннотация BatchSize:

@Entity
@Table(name = "CLIENT_TEST")
class ClientEntityBatchTest(
   @Id
   @Column(name = "ID")
   var id: Long? = null,
   @OneToMany(mappedBy = "client")
   @BatchSize(size = 50)
   var accounts1: Set<Account1> = mutableSetOf(),
   @OneToMany(mappedBy = "client")
   @BatchSize(size = 50)
   var accounts2: Set<Account2> = mutableSetOf(),
   @OneToMany(mappedBy = "client")
   @BatchSize(size = 50)
   var accounts3: Set<Account3> = mutableSetOf(),
   // ...
)

Entity Account выглядит так же, как и для разделенного запроса:

@Entity
@Table(name = "ACCOUNT_1")
class Account1(
   @Id
   @Column(name = "ID")
   var id: Long? = null,
   @Column(name = "NUMBER")
   var number: String? = null,
   @ManyToOne
   @JoinColumn(name = "CLIENT_ID")
   var client: ClientEntityBatchTest? = null
)

Выполним запросы и посмотрим на результаты:

При пяти загружаемых коллекциях производительность разделенного запроса с @EntityGraph хуже в 2 раза. При десяти — в 45 раз. А при загрузке 15 коллекций — в 1401 раз! На графике нет данных по разделенному запросу для 20 коллекций, так как я просто-напросто получил ошибку OutOfMemoryError.

Если посмотреть, какая часть запроса привела к OutOfMemoryError (так сказать, «взорвалась»), то это именно та часть, где происходят JOIN c помощью @EntityGraph:

 @Repository
interface ClientRepository : JpaRepository<ClientEntity, Long> {

   // ...

   @EntityGraph(attributePaths = ["account1", "account2", ...])
   fun getAllByIdIn(clientIds: List<Long>): List<ClientEntity>
}

val result = clientRepository.getAllByIdIn(clientIds.content)

Если посмотреть на результирующий SQL-запрос, то можно сказать, что от JOIN в глазах рябит. Чем больше коллекций необходимо загрузить, тем больше JOIN будет в SQL-запросе и тем больше будет декартово произведение в результате:

Hibernate: select * from client c 
left outer join account_1 a_1 on c.id=a_1.client_id 
left outer join account_2 a_2 on c.id=a_2.client_id 
left outer join account_3 a_3 on c.id=a_3.client_id 
left outer join account_4 a_4 on c.id=a_4.client_id 
left outer join account_5 a_5 on c.id=a_5.client_id 
where c.id in (?, ?, …)

Для наглядности я рассчитал количество строк в результате запроса для одного клиента в зависимости от количества @OneToMany с двумя аккаунтами в каждой коллекции:

  • 2^5 = 32

  • 2^10 = 1024

  • 2^15 = 32768

  • 2^20 = 1048576

Чтобы получить одного клиента при работе с 20 коллекциями, в результате SQL-запроса мы получаем 1 048 576 строк. КПД для одного клиента — 0,000095%!

С @BatchSize же никаких JOIN не происходит, и при добавлении дополнительной коллекции с ассоциацией @OneToMany просто добавляются дополнительные select для этой коллекции:

1. Hibernate: select * from client c limit ? offset ?
2. Hibernate: select count(c.id) from client c
3. Hibernate: select * from account_1 a_1 where a_1.client_id in (?, ?, ...)
4. Hibernate: select * from account_2 a_2 where a_2.client_id in (?, ?, ...)
5. Hibernate: select * from account_3 a_3 where a_3.client_id in (?, ?, ...)
6. Hibernate: select * from account_4 a_4 where a_4.client_id in (?, ?, ...)
7. Hibernate: select * from account_5 a_5 where a_5.client_id in (?, ?, ...)
8. Hibernate: select * from account_6 a_6 where a_6.client_id in (?, ?, ...)
9. ...

Это большое преимущество Hibernate. Без использования JOIN Hibernate выполняет необходимые запросы и получает нужный результат, не перегружая базу данных и приложение.

По итогам теста к минусам разделенного запроса с @EntityGraph добавляется еще два пункта:

  • Он всегда медленнее @BatchSize (разумеется, если вы поставите корректный size, не единицу);

  • Он может приводить к ошибке OutOfMemoryError.

Так что на самом деле я не вижу причин использовать для запросов с пагинацией разделенный запрос с @EntityGraph.

Хотя @BatchSize вышел из дуэли победителем, но к его реализации после написания данного поста у меня остались вопросы. Например, казалось бы, почему не используется очевидное решение — передача массива id в in как одного элемента? По крайней мере, это помогло бы снять ограничение в размере in. Я создал задачу с описанием, как это можно было бы реализовать, и, к моей радости, использование массива было внедрено в версии Hibernate 6.2.2. Теперь запросы с @BatchSize выглядят следующим образом:

Hibernate: select * from account a where a.client_id = any(?)

Необходимые id передаются в виде массива в SQL запрос как один элемент.

Итоги

Во время работы над постом я нашел баг в Hibernate 6, который исправили в версии 6.2.0.CR3. 

На GitHub есть тестовое приложение (ссылка на API), с помощью которого сможете попробовать на практике различные стратегии из поста и сравнить их производительность.

В качестве результата хочу отметить: 

  • Не всё, что имеет на StackOverflow высокий рейтинг, подходит для решения вашей задачи. 

  • Обращайте внимание на сообщения «firstResult/maxResults specified with collection fetch; applying in memory». 

  • Используйте @BatchSize — данная аннотация хоть и была внедрена 15 лет назад, но прекрасно справляется со своими задачами и сейчас.

Комментарии (13)


  1. mrfloony
    23.06.2023 12:32

    А почему бы не сделать VIEW, в которую заложить расчёт и работать уже с этими данными как с единой таблицей?


    1. oracle_schwerpunkte
      23.06.2023 12:32
      +1

      <sarcasm> потому что это перенос бизнес логики в базу, код ревью не пройдет </sarcasm>
      Вообще жесть какая-то, что для простейшей в общем-то задачи надо разбираться в кишках hibernate.


      1. breninsul
        23.06.2023 12:32
        +1

        дырявые абстракции, что поделать


  1. breninsul
    23.06.2023 12:32

    а можно просто использовать cte и inner select, получая списки сразу как массив с нужной паджинацией как основной таблицы, так и вложенных списков.


    1. breninsul
      23.06.2023 12:32
      +1

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


    1. Gmugra
      23.06.2023 12:32

      подозреваю, что это будет работать бысрее всего предложенного. Но это нативный запрос.


  1. syrompe
    23.06.2023 12:32

    Что-то аж загрустил...

    На дворе 23 год, а я такую же проблему решал еще в далеком 2008. И толком решения нормального так и нет ни у кого.

    Наоборот, стало только хуже. Теперь по канонам у вас должны быть отдельные микросервисы для клиентов и для счетов. У каждого микросервиса своя база и SELECT N+1 превращается в HTTP GET+SELECT N+1.


  1. Norgorn
    23.06.2023 12:32

    Действительно, как отметили в комментариях - нативный запрос в статье упомянут, но не сказано, почему это решение не подошло. Но в целом очень круто и интересно, спасибо!


  1. vasyakolobok77
    23.06.2023 12:32
    +1

    Как показала практика отношения на уровне сущностей это хорошо для понимания модели данных, но какой-то изврат для выборки данных из таблиц. В своих проектах во избежание n+1 и загрузки ненужных данных используем native запросы с json_agg вложенных сущностей. Также динамические запросы собираем и выполняем через entity-manager обертку. Может быть такой подход покажется "грубым", но он хорош по производительности и более понятен разработчикам.


    1. breninsul
      23.06.2023 12:32
      +2

      Мне кажется, EM излишен.

      JdbcTemplate гораздо легче и проще. + можно навесить удобное логгирование


  1. SimSonic
    23.06.2023 12:32
    +1

    Мы на проекте, чтобы избежать всего этого, включили sping-hibernate-query-utils в режиме exception, чтобы никаких N+1 не прошло. Сейчас надежда на тесты и QA, чтобы исключение не дошло до боя.

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

    Пока наше решение это одновременно и включённые батчи в пропертях глобально в режиме PADDING, и 2х запросы.


  1. romaerzhuk
    23.06.2023 12:32

    В указанном примере с Entity graph проблема декартова произведения не ушла, а только переместилась в запрос getAllByIdIn. Из-за этого и OutOfMemoryError.

    Есть более эффективное решение с Entity graph/Join fetch:

    1. Запрос с пагинацией должен возвращать сущность, с непроинициализированными дочерними коллекциями.

    2. Для каждой дочерней коллекции выполняется отдельный запрос.

    @Repository
    interface ClientRepository : JpaRepository<ClientEntity, Long> {
    
        fun getAll(pageable: Pageable): Page<ClientEntity>
    
        @EntityGraph(attributePaths = ["accounts"])
        fun getWithAccountsByIdIn(clientIds: List<Long>): List<ClientEntity>
    
        @EntityGraph(attributePaths = ["deposits"])
        fun getWithDepositsByIdIn(clientIds: List<Long>): List<ClientEntity>
    }
    

    В результате всегда будет вызываться ровно 4 запроса, вне зависимости от числа извлекаемых записей:

    1. select * from client c limit 50 offset 50
    2. select count(c.id) from client c
    3. select * from client c left outer join account a on c.id=a.client_id where c.id in (51, 52, ...)
    4. select * from client c left outer join deposit d on c.id=d.client_id where c.id in (51, 52, ...)
    

    В большинстве случаев этот подход эффективнее, чем использование @BatchSize и нагляднее, чем использование Native SQL, особенно когда пейджинг выполняется с критериями.

    Подход тоже не идеален:

    • Запросы приходится вызывать явно.

    • Лишний раз извлекаются родительские сущности вместе с дочками.

    • Код не очевидный. Результаты 3-го и 4-го запросов используются неявно. На самом деле, в родительских сущностях наполняются Lazy-коллекции.

    Извлекать только дочки без родительких сущностей можно, но тогда не будут наполняться Lazy-коллекции, код будет значительно сложнее.




    1. breninsul
      23.06.2023 12:32

      а чем native sql не нагляден? В особенности при использовании to_jsonb /строительством своего jsonb и маппинг через jackson?