блог разработчика о насущном

Эффективная загрузка OneToMany

Disclaimer: при подготовке статьи поведение Hibernate при тех или иных настройках тестировалось на версии 5.4.31.Final, автор не ручается за актуальность изложенного в статье для других версий!
Что мы имеем
Есть у нас в проекте одна сущность с несколько нетривиальной структурой – чек-лист (некоторые связанные сущности для компактности опущены):
логирование java-разработчик баги rocket science
То есть чек-лист заполняется ответами на вопросы, организованные в несколько блоков по определенному шаблону, а еще он содержит список успешных практик и навыков, которые надо улучшить.

Все это выражено в ОО-модели с помощью JPA и Hibernate:

public abstract class BaseJpaEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false, unique = true)
    private Long id;
}

@Entity
@Table(name = "check_lists")
public class CheckListEntity extends BaseJpaEntity {

    @Column(name = "created", nullable = false)
    private Instant created;

    @OneToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "creator_id", nullable = false)
    private ProfileEntity creator;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "template_id", nullable = false)
    private TemplateEntity template;

    @OneToMany(mappedBy = "checkList", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<AnswerEntity> answers;

    @OneToMany(mappedBy = "checkList", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<GoodPracticeEntity> goodPractices;

    @OneToMany(mappedBy = "checkList", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<SkillToImproveEntity> skillsToImprove;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "previous_plan_fact_id", referencedColumnName = "id")
    private CheckListPlanFactEntity previousPlanFact;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "current_plan_fact_id", referencedColumnName = "id")
    private CheckListPlanFactEntity currentPlanFact;
}

@Entity
@Table(name = "check_list_good_practices")
public class GoodPracticeEntity extends BaseJpaEntity {

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "list_id", nullable = false)
    private CheckListEntity checkList;

    @Column(name = "text", nullable = false, columnDefinition = "TEXT")
    private String text;
}

@Entity
@Table(name = "check_list_skills_to_improve")
public class SkillToImproveEntity extends BaseJpaEntity {

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "list_id", nullable = false)
    private CheckListEntity checkList;

    @Column(name = "text", nullable = false, columnDefinition = "TEXT")
    private String text;
}

@Entity
@Table(name = "check_list_answers")
public class AnswerEntity extends BaseJpaEntity {

    @Column(name = "affirmative", nullable = false)
    private Boolean affirmative;

    @Column(name = "reason", columnDefinition = "TEXT")
    private String reason;

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "check_list_id", nullable = false)
    private CheckListEntity checkList;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "question_id", nullable = false)
    private QuestionEntity question;
}

@Entity
@Table(name = "check_list_templates")
public class TemplateEntity extends BaseJpaEntity {

    @Column(name = "created", nullable = false)
    private Instant created;

    @Column(name = "description", nullable = false, columnDefinition = "TEXT")
    private String description;

    @OneToMany(mappedBy = "template", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<BlockPositionEntity> blocks;

    @OneToMany(mappedBy = "template", cascade = CascadeType.ALL)
    private Set<CheckListEntity> checkLists;
}

@Entity
@Table(name = "check_list_templates_blocks")
public class BlockPositionEntity extends BaseJpaEntity {

    @Column(name = "position", nullable = false)
    private Integer position;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "template_id", nullable = false)
    private TemplateEntity template;

    @ManyToOne
    @JoinColumn(name = "block_id", nullable = false)
    private BlockEntity block;
}

@Entity
@Table(name = "check_list_blocks")
public class BlockEntity extends BaseJpaEntity {

    @Column(name = "title", nullable = false)
    private String title;

    @OneToMany(mappedBy = "block", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    private Set<QuestionPositionEntity> questions;
}

@Entity
@Table(name = "check_list_blocks_questions")
public class QuestionPositionEntity extends BaseJpaEntity {

    @Column(name = "position", nullable = false)
    private Integer position;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "block_id", nullable = false)
    private BlockEntity block;

    @ManyToOne
    @JoinColumn(name = "question_id", nullable = false)
    private QuestionEntity question;
}

@Entity
@Table(name = "check_list_questions")
public class QuestionEntity extends BaseJpaEntity {

    @Column(name = "text", nullable = false, columnDefinition = "TEXT")
    private String text;
}
Журнал с такими сущностями периодически выгружается в CSV файл. И одним тёплым майским днём запрос на выгрузку журнала с боя стал отваливаться по timeout (что, кстати, стоило одному из наших разработчиков 8 часов страданий с выгрузкой вручную, потому как информация была нужна «ещё вчера»). Потому как 800 таких записей выгружались 3.5 (sic!) минуты.

После нехитрых размышлений мы пришли к заключению, что всё дело в чёртовой проблеме N + 1 на lazy-коллекциях, которая при выгрузке такого количества связанных сущностей раздувается до угрожающих масштабов (если грубо – то N * (3 + N) + 1, для краткости не буду приводить листинги SQL, соответствующие запросы довольно легко представить). И начали искать пути решения этой проблемы.
Акт 1. JOIN FETCH меня полностью!
Первым и очевидным решением было просто дёшево и сердито «заджоинфетчить» все связанные сущности:

@Repository
public interface CheckListRepository extends JpaRepository<CheckListEntity, Long> {
    @Query("""
            SELECT cle
            FROM CheckListEntity cle
            LEFT JOIN FETCH cle.template AS t
            LEFT JOIN FETCH t.blocks AS bp
            LEFT JOIN FETCH bp.block
            LEFT JOIN FETCH cle.creator
            LEFT JOIN FETCH cle.previousPlanFact
            LEFT JOIN FETCH cle.currentPlanFact
            WHERE (:from IS NULL OR cle.created > :from)
              AND (:to   IS NULL OR cle.created < :to)
            ORDER BY cle.employeeEmail ASC,
                     cle.created DESC
            """)
    List<CheckListEntity> findAllByPeriod(Instant from, Instant to);
}
На что я закономерно получил по загривку от Hibernate:

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

Почесывая ушибленный загривок, я отправился на просторы поисковиков. Первая же ссылка в Google предложила мне простое и надёжное решение в виде замены List на Set. Правда, уже во второй ссылке Vlad Mihalcea объяснил мне, что это довольно спорное решение, потому что на замену крайне неприятного N + 1 мы получаем не более приятное декартово произведение на несколько сущностей, что тоже не слишком оптимально как по памяти, так и по производительности. Он же предложил мне альтернативное решение в виде промежуточных запросов:

@Repository
public interface CheckListRepository extends JpaRepository<CheckListEntity, Long> {

    @Query("""
            SELECT cle
            FROM CheckListEntity cle
            LEFT JOIN FETCH cle.template AS t
            LEFT JOIN FETCH t.blocks AS bp
            LEFT JOIN FETCH bp.block
            LEFT JOIN FETCH cle.creator
            LEFT JOIN FETCH cle.previousPlanFact
            LEFT JOIN FETCH cle.currentPlanFact
            WHERE (:from IS NULL OR cle.created > :from)
                  AND (:to   IS NULL OR cle.created < :to)
            """)
    List<CheckListEntity> findAllByPeriod(Instant from, Instant to);

    @Query("""
            SELECT cle
            FROM CheckListEntity cle
            LEFT JOIN FETCH cle.goodPractices
            WHERE cle in :checkLists
            """)
    List<CheckListEntity> addGoodPractices(List<CheckListEntity> checkLists);

    @Query("""
            SELECT cle
            FROM CheckListEntity cle
            LEFT JOIN FETCH cle.skillsToImprove
            WHERE cle in :checkLists
            """)
    List<CheckListEntity> addSkillsToImprove(List<CheckListEntity> checkLists);

    @Query("""
            SELECT cle
            FROM CheckListEntity cle
            LEFT JOIN FETCH cle.answers AS a
            LEFT JOIN FETCH a.question
            WHERE cle in :checkLists
ORDER BY cle.employeeEmail ASC,
                     cle.created DESC
            """)
    List<CheckListEntity> addAnswersAndSort(List<CheckListEntity> checkLists);
}

@Repository
@RequiredArgsConstructor
public class CheckListDao {

    private final CheckListRepository checkListRepository;

    public List<CheckListEntity> findAllByPeriod(Instant from, Instant to) {
            return executeChain(checkListRepository.findAllByPeriod(from, to),
                    checkListRepository::addGoodPractices,
                    checkListRepository::addSkillsToImprove,
                    checkListRepository::addAnswersAndSort);
    }

    @SafeVarargs
    private static <T> T executeChain(T initialResult, UnaryOperator<T>... operators) {
            T result = initialResult;
            for (UnaryOperator<T> operator : operators) {
                    result = operator.apply(result);
            }
            return result;
    }
}
Результирующие запросы SQL:

select checkliste0_.`id` as id1_20_0_, templateen1_.`id` as id1_18_1_, (и все остальные поля из check_lists, check_list_templates, check_list_blocks, profiles и check_list_plan_fact_history) from `check_lists` checkliste0_ left outer join `check_list_templates` templateen1_ on checkliste0_.`template_id`=templateen1_.`id` left outer join `check_list_templates_blocks` blocks2_ on templateen1_.`id`=blocks2_.`template_id` left outer join `check_list_blocks` blockentit3_ on blocks2_.`block_id`=blockentit3_.`id` left outer join `profiles` profileent4_ on checkliste0_.`creator_id`=profileent4_.`id` left outer join `check_list_plan_fact_history` checklistp5_ on checkliste0_.`previous_plan_fact_id`=checklistp5_.`id` left outer join `check_list_plan_fact_history` checklistp6_ on checkliste0_.`current_plan_fact_id`=checklistp6_.`id` where (? is null or checkliste0_.`created`>?) and (? is null or checkliste0_.`created`<?)

select questions0_.`block_id` as block_id3_12_0_, questions0_.`id` as id1_12_0_, ... from `check_list_blocks_questions` questions0_ inner join `check_list_questions` questionen1_ on questions0_.`question_id`=questionen1_.`id` where questions0_.`block_id`=?
... (по кол-ву блоков)
select questions0_.`block_id` as block_id3_12_0_, questions0_.`id` as id1_12_0_, ... from `check_list_blocks_questions` questions0_ inner join `check_list_questions` questionen1_ on questions0_.`question_id`=questionen1_.`id` where questions0_.`block_id`=?

select checkliste0_.`id` as id1_20_0_, goodpracti1_.`id` as id1_13_1_, ... from `check_lists` checkliste0_ left outer join `check_list_good_practices` goodpracti1_ on checkliste0_.`id`=goodpracti1_.`list_id` where checkliste0_.`id` in (? , ? , ... , ? , ?)

select checkliste0_.`id` as id1_20_0_, skillstoim1_.`id` as id1_17_1_, ... from `check_lists` checkliste0_ left outer join `check_list_skills_to_improve` skillstoim1_ on checkliste0_.`id`=skillstoim1_.`list_id` where checkliste0_.`id` in (? , ? , ... , ? , ?)

select checkliste0_.`id` as id1_20_0_, answers1_.`id` as id1_10_1_, ... from `check_lists` checkliste0_ left outer join `check_list_answers` answers1_ on checkliste0_.`id`=answers1_.`check_list_id` left outer join `check_list_questions` questionen2_ on answers1_.`question_id`=questionen2_.`id` where checkliste0_.`id` in (? , ? , ... , ? , ?) order by checkliste0_.`employee_email` ASC, checkliste0_.`created` DESC
И этот вариант, в целом, работает! Мы получаем 4 явных запроса для явной выгрузки почти всех данных. Немного поразмыслив, их можно превратить в 5, вынеся загрузку блоков аналогичным образом. Это уменьшит размер выгружаемых данных, особенно если учитывать, что одни и те же шаблоны переиспользуются разными чек-листами.

Но у него, на мой взгляд, все ещё имеется 2 существенных минуса:

  1. Большое количество не слишком очевидного boilerplate-кода и необходимость создания дополнительного DAO-слоя, чтобы скрыть его от пользователя.
  2. Оставшаяся проблема N + 1 при загрузке вопросов в блоках (её можно решить аналогичным методом дополнительной подгрузки в BlockRepository, что, однако, при этом порождает ещё больше boilerplate-кода.
логирование java-разработчик баги rocket science
Поэтому было решено копать дальше.
Акт 2. Мы встроили тебе запрос в запрос…
В попытках решить проблему штатными средствами Hibernate мной был найден подходящий, на первый взгляд, вариант – аннотация @Fetch и FetchMode. Нас интересует, как разные стратегии загрузки ведут себя для HQL.

Способ загрузки в HQL по умолчанию что для EAGER, что для LAZY сущностей – FetchMode.SELECT – порождает ту самую проблему N + 1.

Режим FetchMode.JOIN порождает декартово произведение в случае использования Criteria, а для HQL он ведет себя просто как и FetchMode.SELECT, только загружает LAZY-сущности сразу как EAGER.

Остается FetchMode.SUBSELECT:

Available for collections only. When accessing a non-initialized collection, this fetch mode will trigger loading all elements of all collections of the same role for all owners associated with the persistence context using a single secondary select.

Так это же то, что нам нужно! Помечаем все связанные коллекции @Fetch(FetchMode.SUBSELECT) и смотрим, что получилось:

select checkliste0_.`id` as id1_20_0_, templateen1_.`id` as id1_18_1_, ... where (? is null or checkliste0_.`created`>?) and (? is null or checkliste0_.`created`<?) order by checkliste0_.`employee_email` ASC, checkliste0_.`created` DESC

select blocks0_.`template_id` as template4_19_2_, blocks0_.`id` as id1_19_2_, ... from `check_list_templates_blocks` blocks0_ inner join `check_list_blocks` blockentit1_ on blocks0_.`block_id`=blockentit1_.`id` where blocks0_.`template_id` in (select templateen1_.`id` from `check_lists` checkliste0_ left outer join `check_list_templates` templateen1_ on checkliste0_.`template_id`=templateen1_.`id` left outer join `profiles` profileent2_ on checkliste0_.`creator_id`=profileent2_.`id` left outer join `check_list_plan_fact_history` checklistp3_ on checkliste0_.`previous_plan_fact_id`=checklistp3_.`id` left outer join `check_list_plan_fact_history` checklistp4_ on checkliste0_.`current_plan_fact_id`=checklistp4_.`id` where (? is null or checkliste0_.`created`>?) and (? is null or checkliste0_.`created`<?) )

N+1 queries detected with eager fetching on the entity BlockEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type BlockEntity of one of the entities fetched in the query

select questions0_.`block_id` as block_id3_12_2_, questions0_.`id` as id1_12_2_, ... from `check_list_blocks_questions` questions0_
 inner join `check_list_questions` questionen1_ on questions0_.`question_id`=questionen1_.`id` 
where questions0_.`block_id` in 
(select blockentit1_.`id` from `check_list_templates_blocks` blocks0_
 inner join `check_list_blocks` blockentit1_ on blocks0_.`block_id`=blockentit1_.`id` where blocks0_.`template_id` in 
(select templateen1_.`id` from `check_lists` checkliste0_ 
left outer join `check_list_templates` templateen1_ on checkliste0_.`template_id`=templateen1_.`id`
 left outer join `profiles` profileent2_ on checkliste0_.`creator_id`=profileent2_.`id` left outer join `check_list_plan_fact_history` checklistp3_ on checkliste0_.`previous_plan_fact_id`=checklistp3_.`id` 
left outer join `check_list_plan_fact_history` checklistp4_ on checkliste0_.`current_plan_fact_id`=checklistp4_.`id` where (? is null or checkliste0_.`created`>?) and (? is null or checkliste0_.`created`<?) ))

N+1 queries detected with eager fetching on the entity QuestionEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type QuestionEntity of one of the entities fetched in the query
Что можно сказать о получившемся результате:

  1. Мы действительно добились в целом аналогичного результата простым добавлением пары аннотаций.
  2. Любопытно, что Hibernate (а точнее вот эта утилита) утверждает об N + 1 запросе на EAGER сущностях BlockEntity и QuestionEntity, тогда как в действительности никакого N + 1 уже нет.
  3. Хотя для случаев с одноуровневым расположением связей один-ко-многим этот способ вполне подходит, в нашем случае выгрузка, например, объектов QuestionEntity генерирует запрос, содержащий крайне сложный WHERE-блок с 2 вложенными SELECT и использующий данные 9 таблиц (т. е. все таблицы, которые вообще задействованы в выгрузке данных).
Поэтому я собрал пожитки и отправился на повторное рандеву к Google-у…
Акт 3. Я по-batch-ив!
Довольно быстро после экспериментов с FetchMode я наткнулся на любопытный альтернативный вариант и решил приглядеться к нему поближе. Заключается он в использовании старого-доброго FetchMode.SELECT совместно с аннотацией @BatchSize.

Суть этого подхода заключается в том, что Hibernate, загружая связанные сущности B для списка объектов A, выполняет не по 1 запросу для каждого объекта A, а запросы вида select B where B.A_id IN (?, ?, ?, …., ?), где размер блока IN – как раз и есть пресловутый batch size.

Таким образом, при загрузке 100 сущностей A c коллекцией B, помеченной @BatchSize(size = 50), сущности B будут выгружены не за 100 запросов, а за 2! Звучит как та самая BFG, которую я так долго искал… Попробуем:

@Entity
@Table(name = "check_lists")
public class CheckListEntity extends BaseJpaEntity {

    ...

    @OneToMany(mappedBy = "checkList", cascade = CascadeType.ALL, orphanRemoval = true)
    @BatchSize(size = 150)
    private Set<AnswerEntity> answers;

    @OneToMany(mappedBy = "checkList", cascade = CascadeType.ALL, orphanRemoval = true)
    @BatchSize(size = 150)
    private Set<GoodPracticeEntity> goodPractices;

    @OneToMany(mappedBy = "checkList", cascade = CascadeType.ALL, orphanRemoval = true)
    @BatchSize(size = 150)
    private Set<SkillToImproveEntity> skillsToImprove;
    
    ...
}

@Entity
@Table(name = "check_list_templates")
public class TemplateEntity extends BaseJpaEntity {

    ...

@OneToMany(mappedBy = "template", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
    @BatchSize(size = 150)
    private Set<BlockPositionEntity> blocks;

    ...
}

@Entity
@Table(name = "check_list_blocks")
public class BlockEntity extends BaseJpaEntity {

    ...

    @OneToMany(mappedBy = "block", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    @BatchSize(size = 150)
    private Set<QuestionPositionEntity> questions;

    ...
}
Собираем, выгружаем:

select checkliste0_.`id` as id1_20_0_, ... from `check_lists` checkliste0_ left outer join `check_list_templates` templateen1_ on checkliste0_.`template_id`=templateen1_.`id` left outer join `profiles` profileent2_ on checkliste0_.`creator_id`=profileent2_.`id` left outer join `check_list_plan_fact_history` checklistp3_ on checkliste0_.`previous_plan_fact_id`=checklistp3_.`id` left outer join `check_list_plan_fact_history` checklistp4_ on checkliste0_.`current_plan_fact_id`=checklistp4_.`id` where (? is null or checkliste0_.`created`>?) and (? is null or checkliste0_.`created`<?) order by checkliste0_.`employee_email` ASC, checkliste0_.`created` DESC

select blocks0_.`template_id` as template4_19_2_, blocks0_.`id` as id1_19_2_, ... from `check_list_templates_blocks` blocks0_ inner join `check_list_blocks` blockentit1_ on blocks0_.`block_id`=blockentit1_.`id` where blocks0_.`template_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)


N+1 queries detected with eager fetching on the entity BlockEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type BlockEntity of one of the entities fetched in the query

select questions0_.`block_id` as block_id3_12_2_, questions0_.`id` as id1_12_2_, ... from `check_list_blocks_questions` questions0_ inner join `check_list_questions` questionen1_ on questions0_.`question_id`=questionen1_.`id` where questions0_.`block_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

N+1 queries detected with eager fetching on the entity QuestionEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type QuestionEntity of one of the entities fetched in the query

select blocks0_.`template_id` as template4_19_2_, blocks0_.`id` as id1_19_2_, ... from `check_list_templates_blocks` blocks0_ inner join `check_list_blocks` blockentit1_ on blocks0_.`block_id`=blockentit1_.`id` where blocks0_.`template_id`=?

N+1 queries detected with eager fetching on the entity BlockPositionEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type BlockPositionEntity of one of the entities fetched in the query

select questions0_.`block_id` as block_id3_12_2_, questions0_.`id` as id1_12_2_, ... from `check_list_blocks_questions` questions0_ inner join `check_list_questions` questionen1_ on questions0_.`question_id`=questionen1_.`id` where questions0_.`block_id`=?

N+1 queries detected with eager fetching on the entity QuestionPositionEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type QuestionPositionEntity of one of the entities fetched in the query

select goodpracti0_.`list_id` as list_id3_13_1_, goodpracti0_.`id` as id1_13_1_, ... from `check_list_good_practices` goodpracti0_ where goodpracti0_.`list_id` in (?, ?, и еще 73 значения)

select skillstoim0_.`list_id` as list_id3_17_1_, skillstoim0_.`id` as id1_17_1_, ... from `check_list_skills_to_improve` skillstoim0_ where skillstoim0_.`list_id` in (?, ?, и еще 73 значения)

select answers0_.`check_list_id` as check_li4_10_1_, answers0_.`id` as id1_10_1_, ... from `check_list_answers` answers0_ where answers0_.`check_list_id` in (?, ?, и еще 73 значения)

select goodpracti0_.`list_id` as list_id3_13_1_, goodpracti0_.`id` as id1_13_1_, ... from `check_list_good_practices` goodpracti0_ where goodpracti0_.`list_id` in (?, ?, и еще 35 значений)

select skillstoim0_.`list_id` as list_id3_17_1_, skillstoim0_.`id` as id1_17_1_, ... from `check_list_skills_to_improve` skillstoim0_ where skillstoim0_.`list_id` in (?, ?, и еще 35 значений)

select answers0_.`check_list_id` as check_li4_10_1_, answers0_.`id` as id1_10_1_, ... from `check_list_answers` answers0_ where answers0_.`check_list_id` in (?, ?, и еще 35 значений)

select goodpracti0_.`list_id` as list_id3_13_1_, goodpracti0_.`id` as id1_13_1_, ... from `check_list_good_practices` goodpracti0_ where goodpracti0_.`list_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

select skillstoim0_.`list_id` as list_id3_17_1_, skillstoim0_.`id` as id1_17_1_, ... from `check_list_skills_to_improve` skillstoim0_ where skillstoim0_.`list_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

select answers0_.`check_list_id` as check_li4_10_1_, answers0_.`id` as id1_10_1_, ... from `check_list_answers` answers0_ where answers0_.`check_list_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

select goodpracti0_.`list_id` as list_id3_13_1_, goodpracti0_.`id` as id1_13_1_, ... from `check_list_good_practices` goodpracti0_ where goodpracti0_.`list_id` in (?, ?, ?, ?, ?, ?, ?, ?)

select skillstoim0_.`list_id` as list_id3_17_1_, skillstoim0_.`id` as id1_17_1_, ... from `check_list_skills_to_improve` skillstoim0_ where skillstoim0_.`list_id` in (?, ?, ?, ?, ?, ?, ?, ?)

select answers0_.`check_list_id` as check_li4_10_1_, answers0_.`id` as id1_10_1_, ... from `check_list_answers` answers0_ where answers0_.`check_list_id` in (?, ?, ?, ?, ?, ?, ?, ?)
Хммм… Почему при загрузке блоков и вопросов выполняется по 2 запроса, а для полей чек-листа вообще по 4? Ну ладно, потом разберемся…

(Spoiler: почему запросы ведут себя так странно, подробнее объясняется ниже)

Но в целом оно работает (и, кстати, всё равно ругается на N + 1)! И нет никаких монструозных подзапросов, только блоки IN! Мы перешли к ситуации N/M + 1, где M – размер батча, и, корректно выставив его, можно очень существенно уменьшить количество запросов в базу минимальными усилиями.
Акт 4. Объединяй и властвуй
Ну, раз пошла такая пьянка, почему бы не добавить batch-выгрузку для всех сущностей в проекте? Это же совсем голова не будет болеть из-за N + 1!

Путем дальнейшего нехитрого гуглинга я выяснил, что глобально такое поведение включается настройкой hibernate.default_batch_fetch_size=n, где n – желаемый размер батча. Ну всё, тут и наступает полное счастье…

Однако из равновесия меня выбила вот эта статья, автор которой упорно заклинает нас не использовать глобальный batch size.

После добавления глобальной настройки его приложение, до этого занимавшее около 350 Мб при разрешенном размере кучи в 512 Мб, упало с OOM. Увеличив heap до 1 Гб, он обнаружил, что приложение стало занимать около 800 Мб. Прирост памяти почти в три раза, Карл!

Дело в том, что Hibernate изо всех сил старается оптимизировать выполнение запросов, для чего, в частности, создает prepared statements при загрузке приложения. И при включении batch-логики Hibernate создает для запросов, кроме стандартных EntityLoader-ов, дополнительные инстансы BatchingEntityLoader.

Если, к примеру, batch size установлен на 20, то генерируется 11 BatchingEntityLoader-ов для разных размеров блока IN (20, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 соответственно). И при загрузке 19 сущностей вызываются запросы на 10 и 9 (этим, кстати, можно объяснить странное поведение запросов выше). И так для каждой сущности в проекте!

Получается примерно такое распределение:
360 дополнительных Мб, судя по всему, уходит только на хранение строк сгенерированных запросов! «Нет в жизни совершенства...» – ёкнуло сердце. «Статью дочитай» – посоветовал мозг.

Оказалось, что начиная с версии 4.2, в Hibernate появилась дополнительная настройка hibernate.batch_fetch_style, которая может принимать 3 значения: LEGACY, DYNAMIC и PADDED.

Стиль LEGACY работает ровно так, как написано выше. Суть стиля DYNAMIC, в целом, угадывается из названия — запросы генерируются динамически на этапе исполнения. А вот что по поводу PADDED?

В целом PADDED похож на LEGACY, но с одним небольшим «но»: BatchingEntityLoader генерируется на каждый запрос по степени 2 (2, 4, 8, 16, 32, ...), и при загрузке сущностей последний id повторяется несколько раз до ближайшей степени 2: IN (1, 2, 3, 4, 5) превращается в IN (1, 2, 3, 4, 5, 5, 5, 5). Именно его я и решил использовать.

Кстати, нигде в документации я не смог найти инфу о том, какой стиль используется по умолчанию, но, думаю, пример с голым @BatchSize дает довольно однозначный ответ на этот вопрос. И действительно, покопавшись в коде Hibernate, становится видно, что по умолчанию используется LEGACY:

BatchingEntityLoaderBuilder:

public static BatchingEntityLoaderBuilder getBuilder(SessionFactoryImplementor factory) {
        switch(factory.getSessionFactoryOptions().getBatchFetchStyle()) {
        case PADDED:
            return PaddedBatchingEntityLoaderBuilder.INSTANCE;
        case DYNAMIC:
            return DynamicBatchingEntityLoaderBuilder.INSTANCE;
        default:
            return LegacyBatchingEntityLoaderBuilder.INSTANCE;
        }
}
Итоговый вид сгенерированных запросов:

select checkliste0_.`id` as id1_20_0_, ... from `check_lists` checkliste0_ left outer join `check_list_templates` templateen1_ on checkliste0_.`template_id`=templateen1_.`id` left outer join `profiles` profileent2_ on checkliste0_.`creator_id`=profileent2_.`id` left outer join `check_list_plan_fact_history` checklistp3_ on checkliste0_.`previous_plan_fact_id`=checklistp3_.`id` left outer join `check_list_plan_fact_history` checklistp4_ on checkliste0_.`current_plan_fact_id`=checklistp4_.`id` where (? is null or checkliste0_.`created`>?) and (? is null or checkliste0_.`created`<?) order by checkliste0_.`employee_email` ASC, checkliste0_.`created` DESC

select blocks0_.`template_id` as template4_19_2_, blocks0_.`id` as id1_19_2_, ... from `check_list_templates_blocks` blocks0_ inner join `check_list_blocks` blockentit1_ on blocks0_.`block_id`=blockentit1_.`id` where blocks0_.`template_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
N+1 queries detected with eager fetching on the entity BlockEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type BlockEntity of one of the entities fetched in the query

select questions0_.`block_id` as block_id3_12_2_, questions0_.`id` as id1_12_2_, questions0_.`id` as id1_12_1_, ... from `check_list_blocks_questions` questions0_ inner join `check_list_questions` questionen1_ on questions0_.`question_id`=questionen1_.`id` where questions0_.`block_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
N+1 queries detected with eager fetching on the entity QuestionEntity
    at CheckListService.getLists(CheckListService.java:164)
    Hint: Missing Lazy fetching configuration on a field of type QuestionEntity of one of the entities fetched in the query

select goodpracti0_.`list_id` as list_id3_13_1_, goodpracti0_.`id` as id1_13_1_, ... from `check_list_good_practices` goodpracti0_ where goodpracti0_.`list_id` in (?, ?, и еще 62 значения)

select skillstoim0_.`list_id` as list_id3_17_1_, skillstoim0_.`id` as id1_17_1_, ... from `check_list_skills_to_improve` skillstoim0_ where skillstoim0_.`list_id` in (?, ?, и еще 62 значения)

select answers0_.`check_list_id` as check_li4_10_1_, answers0_.`id` as id1_10_1_, ... from `check_list_answers` answers0_ where answers0_.`check_list_id` in (?, ?, и еще 62 значения)

select goodpracti0_.`list_id` as list_id3_13_1_, goodpracti0_.`id` as id1_13_1_, ... from `check_list_good_practices` goodpracti0_ where goodpracti0_.`list_id` in (?, ?, и еще 62 значения)

select skillstoim0_.`list_id` as list_id3_17_1_, skillstoim0_.`id` as id1_17_1_, ... from `check_list_skills_to_improve` skillstoim0_ where skillstoim0_.`list_id` in (?, ?, и еще 62 значения)

select answers0_.`check_list_id` as check_li4_10_1_, answers0_.`id` as id1_10_1_, ... from `check_list_answers` answers0_ where answers0_.`check_list_id` in (?, ?, и еще 62 значения)

select goodpracti0_.`list_id` as list_id3_13_1_, goodpracti0_.`id` as id1_13_1_, ... from `check_list_good_practices` goodpracti0_ where goodpracti0_.`list_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

select skillstoim0_.`list_id` as list_id3_17_1_, skillstoim0_.`id` as id1_17_1_, ... where skillstoim0_.`list_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

select answers0_.`check_list_id` as check_li4_10_1_, answers0_.`id` as id1_10_1_, ... from `check_list_answers` answers0_ where answers0_.`check_list_id` in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
При этом увеличение размера кучи у нас на проекте – около 4 Мб. Для поведения по умолчанию замеры не производились, но в данном случае принципиальным было именно фактическое отсутствие сильного прироста памяти.

Однако, если присмотреться к запросам… Да, для блоков и вопросов мы получили то, что ожидали, но вот для ответов, навыков и практик последний запрос содержит IN на 10 элементов вместо ожидаемых 16…
Ещё немного полазив по внутренностям Hibernate, я выяснил следующее:

  1. Да, стратегия PADDING, равно как и LEGACY с DYNAMIC, ведёт себя так, как и ожидалось после прочтения javadocs (не буду приводить здесь листинги, желающие поинтересоваться могут почерпнуть сие знание напрямую из исходников).
  2. А вот что по поводу возможных размеров приготовленных batch-ей? Смотрим код:

PaddedBatchingEntityLoader:

public PaddedBatchingEntityLoader(...) {
        super(persister);
        this.batchSizes = ArrayHelper.getBatchSizes(maxBatchSize);
        this.loaders = new EntityLoader[this.batchSizes.length];
        Builder entityLoaderBuilder = EntityLoader.forEntity(persister).withInfluencers(loadQueryInfluencers).withLockOptions(lockOptions);
        this.loaders[0] = entityLoaderBuilder.withBatchSize(this.batchSizes[0]).byPrimaryKey();

        for(int i = 1; i < this.batchSizes.length; ++i) {
            this.loaders[i] = entityLoaderBuilder.withEntityLoaderTemplate(this.loaders[0]).withBatchSize(this.batchSizes[i]).byPrimaryKey();
        }

        this.validate(maxBatchSize);
}
LegacyBatchingEntityLoader:

protected LegacyBatchingEntityLoader(...) {
        super(persister);
        this.batchSizes = ArrayHelper.getBatchSizes(maxBatchSize);
        this.loaders = new EntityLoader[this.batchSizes.length];
        Builder entityLoaderBuilder = EntityLoader.forEntity(persister).withInfluencers(loadQueryInfluencers).withLockMode(lockMode).withLockOptions(lockOptions);
        this.loaders[0] = entityLoaderBuilder.withBatchSize(this.batchSizes[0]).byPrimaryKey();

        for(int i = 1; i < this.batchSizes.length; ++i) {
            this.loaders[i] = entityLoaderBuilder.withEntityLoaderTemplate(this.loaders[0]).withBatchSize(this.batchSizes[i]).byPrimaryKey();
        }

    }
ArrayHelper:

public static int[] getBatchSizes(int maxBatchSize) {
        int batchSize = maxBatchSize;

        int n;
        for(n = 1; batchSize > 1; ++n) {
            batchSize = getNextBatchSize(batchSize);
        }

        int[] result = new int[n];
        batchSize = maxBatchSize;

        for(int i = 0; i < n; ++i) {
            result[i] = batchSize;
            batchSize = getNextBatchSize(batchSize);
        }

        return result;
}

private static int getNextBatchSize(int batchSize) {
        if (batchSize <= 10) {
            return batchSize - 1;
        } else {
            return batchSize / 2 < 10 ? 10 : batchSize / 2;
        }
}
То есть ожидаемого светлого будущего с маленьким количеством лоадеров в случае PADDING не происходит, и по использованию памяти это не будет принципиально отличаться от LEGACY. Единственная разница состоит в разной стратегии подбора тех или иных лоадеров, которая в случае с PADDING иногда даёт немного меньшее, как показывает наш пример, количество запросов. А размеры batch-ей в обоих случаях – с 1 до 10 и далее по степеням 2.

Я решил далее не углубляться в преимущества той или иной стратегии с точки зрения памяти и производительности и остановился на PADDING, которая для нужд проекта вполне подошла. Однако какую стратегию использовать читателю (или, может быть, даже отказаться от BatchSize), стоит всё-таки проверять по месту использования.

Итого, добавлением 2 настроек в конфигурацию приложения мы получили превращение "N * N + ..." в 11 (для порядка 150 выгружаемых сущностей) запросов, а также бонус в виде отсутствия проблемы N + 1 в дальнейшем, что в нашем случае выглядит довольно неплохо.
Кульминация. Ради чего все это?
Но секундочку, а дали ли все эти потуги тот результат, ради которого затевались?

Разворачиваем приложение, делаем запрос… Тайминг почти не изменился. Как же так?!

Именно здесь я и решил сделать то, что стоило сделать в самом начале – провести замеры времени на разных участках кода. И замеры показали, что большую часть (порядка 90%) времени запроса занимала конвертация выгруженных сущностей в DTO, а не сама выгрузка!

Спустя 5 минут вдумчивого рассматривания кода выяснилось, что в методе конвертации был ещё 1 запрос к базе, который вызывался для каждой конвертируемой сущности (то есть в общем случае около 1000 раз), и, вынеся который во внешний метод, удалось сократить время выполнения выгрузки с 3.5 минут до 50 секунд.
Заключение
Это история о том, как я потратил 2 дня рабочего времени с пользой. Нет, мои изыскания не помогли мне решить задачу (которая оказалась решаемой за 10 минут). Но, во-первых, я узнал очень много нового относительно работы Hibernate со связанными коллекциями и разными стратегиями их загрузки. А, во-вторых, я очень четко усвоил, что решение проблемы нужно начинать не со своего представления о её сути, а с нахождения и локализации этой сути. Надеюсь, что эти выводы окажутся полезными и для вас.
Проверь свои силы в Java Challenge
  • 8
    минут
  • 4
    вопроса
  • 4
    куска кода
previous
Переход на Testcontainers: часть первая
Наш java-разработчик Стас Усенков рассказывает о небольшом пойманном баге на проекте бэкенда для мобильных приложений.
next
Логирование как средство от депрессии
Простые действия, когда нет времени выискивать причину бага (или вы уже задолбались и потратили все нервы). Как предотвратить ситуацию, рассказывает java-разработчик Дима Кузнецов.
2023 © Rocket Science — команда опытных Java/Kotlin финтех разработчиков. Данные о результатах специальной оценки условий труда
Инстаграм и Фейсбук — соцсети, запрещенные на территории России.