Блог

Полезные статьи и новости о жизни WaveAccess

Hibernate/GORM: решение проблемы N+1

Многие программисты, работающие с Hibernate или любым подобным ORM-фреймворком, рано или поздно сталкиваются с так называемой проблемой N+1.

Столкнулась с ней и наша команда, когда реализовывала проект на Grails (популярный веб-фреймворк для Groovy). Для ORM Grails использует GORM, “под капотом” у которого все тот же старый добрый Hibernate.

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

Screenshot 1 

Есть “Новость” и у нее может быть много “Комментариев”.

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

Оптимальным окажется вариант, при котором база находится на той же машине или хотя бы в той же локальной сети, а количество новостей ограничено десятью. Но, к сожалению, в реальности база будет располагаться на выделенном сервере, количество новостей на странице будет порядка 50 и подобное решение может привести к серьезным проблемам с производительностью.

При помощи Hibernate можно найти несколько решений этой проблемы. Рассмотрим их вкратце.

FetchMode.JOIN

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

Рассмотрим следующий запрос:

Первая же проблема, которая бросается в глаза - limit 10 не отработает так, как нам нужно. Вместо того, чтобы вернуть первые десять новостей, этот запрос вернет первые десять записей. Количество новостей в этих десяти записях будет зависеть от количества комментариев. Если у первой новости 10+ комментариев, то мы получим только ее в результате выборки.

Все это вынуждает Hibernate отказаться от нативных средств базы данных для ограничения (limit) и смещения (offset) выборки и обрабатывать результат запроса уже на стороне сервера приложения.

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

Для того, чтобы это исправить, нужно выставить Result Transformer для критерия:

criteria.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY);

Даже если отбросить все эти недостатки, то у этого метода есть и более серьезные ограничения: например, он не справляется с задачей “помимо комментариев получить еще и авторов статьи”

FetchMode.SUBSELECT

Другой альтернативой мог бы стать SUBSELECT. Вместо того, чтобы делать JOIN, он выполняет дополнительный запрос для связанных сущностей, используя оригинальный запрос как SUBSELECT.

В итоге мы получаем вместо одиннадцати запросов всего два. Один основной запрос и по одному запросу на каждую ассоциацию.

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

Во-первых, использовать его возможно только на этапе описания маппинга с помощью аннотации @Fetch(FetchMode.SUBSELECT)

Во-вторых, мы никак не можем контролировать использование этого режима (в отличии от того же JOIN) в момент выполнения запроса. Таким образом, мы не будем знать, реально ли используется этот режим или нет. Если другой разработчик поменяет маппинг, то всё может “посыпаться”: например, оптимизация просто перестанет работать и будет использоваться первоначальный вариант с 11-ю запросами. При этом, данная связь будет неявной для того, кто вносит изменение.

В-третьих, что для нас стало решающим фактором, этот режим не поддерживается в GORM - Grails фрэймворке для работы с базой данных, построенном поверх Hibernate.

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

С учетом вышесказанного, нам ничего не оставалось кроме как вооружиться IDEA, запасом свободного времени и погрузиться в недра Hibernate. Результатом стало…

Ультимативное решение

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

Например, так:

 
Query q = session.createQuery(“from News order by newDate“)

q.setMaxResults(10)

List news = q.list()

BatchCollectionLoader.preloadCollections(session, news, “comments”)

От фантазий к делу. Результатом изысканий стал следующий код на Groovy (при необходимости его легко можно преобразовать в Java код):

package cv.hibernate

 

import groovy.transform.CompileStatic

import org.grails.datastore.gorm.GormEnhancer

import org.hibernate.HibernateException

import org.hibernate.MappingException

import org.hibernate.QueryException

import org.hibernate.engine.spi.LoadQueryInfluencers

import org.hibernate.engine.spi.SessionFactoryImplementor

import org.hibernate.engine.spi.SessionImplementor

import org.hibernate.loader.collection.BasicCollectionLoader

import org.hibernate.loader.collection.OneToManyLoader

import org.hibernate.persister.collection.QueryableCollection

import org.hibernate.persister.entity.EntityPersister

import org.hibernate.type.CollectionType

import org.hibernate.type.Type

 

/**

* Date: 08/03/2017

* Time: 15:52

*/

@CompileStatic

class BatchCollectionLoader {

   protected static QueryableCollection getQueryableCollection(

       Class entityClass,

       String propertyName,

       SessionFactoryImplementor factory) throws HibernateException {

       String entityName = entityClass.name

       final EntityPersister entityPersister = factory.getEntityPersister(entityName)

       final Type type = entityPersister.getPropertyType(propertyName)

       if (!type.isCollectionType()) {

           throw new MappingException(

               "Property path [" + entityName + "." + propertyName + "] does not reference a collection"

           )

       }

 

       final String role = ((CollectionType) type).getRole()

       try {

           return (QueryableCollection) factory.getCollectionPersister(role)

       }

       catch (ClassCastException cce) {

           throw new QueryException("collection role is not queryable: " + role, cce)

       }

       catch (Exception e) {

           throw new QueryException("collection role not found: " + role, e)

       }

   }

 

   private

   static void preloadCollectionsInternal(SessionImplementor session, Class entityClass, List entities, String collectionName) {

       def sf = session.factory

       def collectionPersister = getQueryableCollection(entityClass, collectionName, sf)

       def entityIds = new Serializable[entities.size()]

       int i = 0

       for (def entity : entities) {

           if (entity != null) {

               entityIds[i++] = (Serializable) entity["id"]

           }

       }

       if (i != entities.size()) {

           entityIds = Arrays.copyOf(entityIds, i)

       }

       def loader = collectionPersister.isOneToMany() ?

           new OneToManyLoader(collectionPersister, entityIds.size(), sf, LoadQueryInfluencers.NONE) :

           new BasicCollectionLoader(collectionPersister, entityIds.size(), sf, LoadQueryInfluencers.NONE)

       loader.loadCollectionBatch(session, entityIds, collectionPersister.keyType)

   }

 

   private static Class getEntityClass(List entities) {

       for (def entity : entities) {

           if (entity != null) {

               return entity.getClass()

           }

       }

       return null

   }

 

   static void preloadCollections(List entities, String collectionName) {

       Class entityClass = getEntityClass(entities)

       if (entityClass == null) {

           return

       }

       GormEnhancer.findStaticApi(entityClass).withSession { SessionImplementor session ->

           preloadCollectionsInternal(session, entityClass, entities, collectionName)

       }

   }

 

   static void preloadCollections(SessionImplementor session, List entities, String collectionName) {

       Class entityClass = getEntityClass(entities)

       if (entityClass == null) {

           return

       }

       preloadCollectionsInternal(session, entityClass, entities, collectionName)

   }

}

В этом классе есть два перегруженных метода preloadCollections, один из которых будет работать только для GORM (без session), а второй подойдет в обоих случаях.

Надеюсь, что данная статья будет Вам полезна и поможет писать лучший код.

P.S.: Ссылка на GIST

Если у вас есть необходимость создать проект на Gorm/Grails или возникли вопросы и комментарии, мы с удовольствием ответим! Свяжитесь с нами: 

Заказать звонок

Удобное время:

Отменить

Пишите!

Присоединить
Файл не больше 30 Мб.
Отменить