2013-11-04

Решение проблемы N+1 в Hibernate

Введение

В нашем проекте для большинства сущностей их количество не превышает сотни, а там где начинаются проблемы, хватает индексов - поэтому можно не заморачиваться. Но тут внезапно появилась дичайшая просадка производительности - клиент отваливался по таймауту на старте при попытке запросить один справочник. Быстро выяснилось, что справочник содержал 1000+ сущностей, на каждую из которых приходилось по пятку дочерних на трех уровнях (да, это всё нужно было), в итоге это давало несколько тысяч запросов при попытке сериализовать все это добро. Спойлер: в общем проблему за несколько часов удалось решить сократив количество запросов меньше чем до десяти.

Если специально не заниматься оптимизацией, то ORM зачастую приводит к проблеме, называющейся "N+1": у нас имеются две связанные сущности (не важно 1-*, *-1 или 1-1) и дочерняя загружается лениво, то, при итерации по родительским сущностям и обращении к дочерним, будет в сумме N+1 обращений к базе, чтобы инициализировать ленивые прокси. Главное препятствие для лобового решения (вытянуть всё в один запрос) - то, что из-за пустых коллекций и null-ов некоторые или все джойны должны быть внешними, а разложить после этого результат по сущностям не получится. При маппинге мы разово указываем поведение коллекции LAZY или EAGER и тонко тюнинговать не получается. Разухабистая глава в документации Hibernate про оптимизацию не очень помогает. Вообще, проблема крайне распространенная, может еще еще одно проявление impendace mismatch. Давно уже писал про ORM, ничего не поменялось, хотя уже почти не напрягает, за исключением отдельных случаев типа N+1.

В бой

Итак, какое главное правило оптимизации? - Правильно, оптимизировать только боттлнеки, иметь метрику, которую можно измерить (масло масляное), и модель с воспроизводимым поведением. Первое налицо, метрикой будет - количество запросов.

Как будем мерить?

В Hibernate имеется Statistics API. Включается он добавлением <property name="hibernate.generate_statistics" value="true"/> в persistence.xml. Главной проблемой при использовании в JPA будет добраться до сессии из EntityManager: ограничиться javax.persistence не получится и придется кастовать к гибернейтовским классам, хотя мы уже используем внутренний нестандартный механизм поэтому пофиг. ((org.hibernate.Session) entityManager.getDelegate()).getSessionFactory().getStatistics(). Еще одним минусом здесь будет глобальная статистика, хотя у нас будет модель и никто нам статистику внезапным желанием запросить насколько тысяч сущностей не подпортит.

Модель

Модель лежит у меня в песочнице, в пакете com.blazer.npo.*, запускабельный класс - NPOApp. Изврат с Guice можно проигнорировать: экспериментирую, как это удобно задействовать DI в нашем продукте - пока не очень удобно, видимо придется танцевать с дочерними модулями, AOP и кастомными Scope, поскольку стандарные плохо укладываются в философию Vaadin.

Приложение стартует, заполняет тестовыми данными 5 сущностей на трех уровнях вложенности с различными взаимосвязями. Слой доступа к данным с запросами находится в одноименном классе. Тут хочется упомянуть давнюю проблему джавовых ORM-ов - именованные запросы находятся в сущностях, хотя в анемичном подходе запросы имеет смысл вынести туда, где они используются - в DAO слой. Есть простой хак: аннотировать DAO классы как @MappedSuperclass, еще есть тикет 2005 года, who cares?

Задача простая: вытянуть Parent и обратиться ко всем дочерним объектам. Для этого используется @ToString от Lombok, после этого вызов toString() у родителя эффективно инициализирует всю иерархию.

Точка отсчета

Получаем всех родителей и итерируем

for (Parent p : dao.parentsFindAll()) {
    log.trace(p.toString());
}

Процесс таков:
1. Запрашиваются Parent: select parent0_.id as id1_6_ from parent parent0_

Дальше в цикле идет:

2. Запрашивается один ChildMtM: select childmtm0_.parent_id as parent_i1_6_1_, childmtm0_.child_id as child_id2_7_1_, childmtm1_.id as id1_2_0_, childmtm1_.value as value2_2_0_ from parent_child childmtm0_ inner join child_mtm childmtm1_ on childmtm0_.child_id=childmtm1_.id where childmtm0_.parent_id=?
3. Запрашивается один ChildOtMNested: select nested0_.parent_id as parent_i3_2_1_, nested0_.id as id1_4_1_, nested0_.id as id1_4_0_, nested0_.parent_id as parent_i3_4_0_, nested0_.value as value2_4_0_ from child_otm_nested nested0_ where nested0_.parent_id=?
4. Запрашивается один ChildOtM: select childotm0_.parent_id as parent_i3_6_1_, childotm0_.id as id1_3_1_, childotm0_.id as id1_3_0_, childotm0_.parent_id as parent_i3_3_0_, childotm0_.value as value2_3_0_ from child_otm childotm0_ where childotm0_.parent_id=?
5. Заполняется ChildOtO для каждого элемента коллекции ChildOtM: select childoto0_.id as id1_5_0_, childoto0_.parent_id as parent_i3_5_0_, childoto0_.value as value2_5_0_ from child_oto childoto0_ where childoto0_.parent_id=?

Total queries: 131

Наивный подход

Первое, что я сделал, так это заранее выбрал все элементы.

dao.childMtMFindAll();
dao.childOtMFindAll();
dao.childOtOFindAll();
dao.childOtMNestedFindAll();
for (Parent p : dao.parentsFindAll()) {
    log.trace(p.toString());
}

Но, ожидаемого прироста не произошло:
1. ChildMtM: select childmtm0_.id as id1_2_, childmtm0_.value as value2_2_ from child_mtm childmtm0_
2. ChildOtM: select childotm0_.id as id1_3_, childotm0_.parent_id as parent_i3_3_, childotm0_.value as value2_3_ from child_otm childotm0_
3. Инициализируется каждый ChildOtO: select childoto0_.id as id1_5_0_, childoto0_.parent_id as parent_i3_5_0_, childoto0_.value as value2_5_0_ from child_oto childoto0_ where childoto0_.parent_id=?

4. ChildOtO: select childoto0_.id as id1_5_, childoto0_.parent_id as parent_i3_5_, childoto0_.value as value2_5_ from child_oto childoto0_
5. ChildOtMNested: select childotmne0_.id as id1_4_, childotmne0_.parent_id as parent_i3_4_, childotmne0_.value as value2_4_ from child_otm_nested childotmne0_
6. Parent: select parent0_.id as id1_6_ from parent parent0_
Предзагрузка кончилась, пошли итерации
7. select childmtm0_.parent_id as parent_i1_6_1_, childmtm0_.child_id as child_id2_7_1_, childmtm1_.id as id1_2_0_, childmtm1_.value as value2_2_0_ from parent_child childmtm0_ inner join child_mtm childmtm1_ on childmtm0_.child_id=childmtm1_.id where childmtm0_.parent_id=?
8. select nested0_.parent_id as parent_i3_2_1_, nested0_.id as id1_4_1_, nested0_.id as id1_4_0_, nested0_.parent_id as parent_i3_4_0_, nested0_.value as value2_4_0_ from child_otm_nested nested0_ where nested0_.parent_id=?
9. select childotm0_.parent_id as parent_i3_6_1_, childotm0_.id as id1_3_1_, childotm0_.id as id1_3_0_, childotm0_.parent_id as parent_i3_3_0_, childotm0_.value as value2_3_0_ from child_otm childotm0_ where childotm0_.parent_id=?

Total queries 145

Всё было плохо и стало еще хуже, вдумчивое курение сырцов и пошаговый дебаг выявил то, что коллекции и сущности кешируются в EntityManager отдельно, хотя, почему нельзя сначала выбрать первичные ключи и проверить PersistenceContext на предмет наличия в них сущностей мне непонятно. Можно было бы хинтом указать такое поведение, а потом пусть бы батч-фетчил недостающее если попадание больше 90% или запросил заново.

Правильный путь

Здесь мне уже надоело и стало скучно, в исходниках имеется еще два варианта на 105 и 15 запросов, кому интересно - посмотрят. Всю малину портит ChildOtO, который, как очевидно из названия, замаплен @OneToOne. На StackOverflow есть несколько тем посвященных этому больному вопросу. Важно его правильно замапить и вытягивать с заджойненно с родительской сущностью, а по-возможности избегать.

Запросы виде HQL выглядят так:
1. Select p from Parent p LEFT JOIN FETCH p.childMtM c
2. Select c from ChildMtM c LEFT JOIN FETCH c.nested n
3. SELECT p FROM Parent p LEFT JOIN FETCH p.childOtM c JOIN FETCH c.nested n
4. from Parent

Total queries 4. Flawless vitory.