Всем привет! На связи Михаил Поливаха, технический лидер проекта Axelix.

На известном в узких кругах блоге Hibernate (он же in-relation-to) пару месяцев назад вышла статья, которая, как мне кажется, будет полезной для большого количества разработчиков, кто пишет Java backend системы с использованием Hibernate.

На основании своего эмпирического опыта могу сказать, что многие в той или иной степени работают с Json или подобными структурами в БД вместе с Hibernate. Если вы работали или работаете с такими системами, то та проблема, с которой столкнулись парни будет стоит вашего внмиания.

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

Приятного чтения!


Когда мы профилировали конвейер импорта данных нашего приложения, то ожидали, что узкое место окажется в бизнес-логике: JQ-фильтрах, выполнении JavaScript или рекурсивных SQL-запросах. Но вместо этого внутренняя обработка JSON в Hibernate потребляла более 50% процессорного времени и приводила к выделению 20 ГБ памяти под ненужные объекты во время обычного импорта всего лишь 5 записей.

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

Приложение

h5m представляет собой легковесную переработанную версию Horreum, системы обнаружения регрессий производительности. Она принимает результаты бенчмарков в формате JSON, преобразует их в DAG (направленный ациклический граф, то есть конвейер зависимых вычислительных шагов, в котором результат каждого узла поступает следующим узлам) и обнаруживает регрессии статистическими методами. Основная сущность хранит результаты вычислений в JSONB поле в БД:

@Entity
public class ValueEntity extends PanacheEntity {

    @Column(columnDefinition = "JSONB")
    @JdbcTypeCode(SqlTypes.JSON)
    @Basic(fetch = FetchType.LAZY)
    public JsonNode data;

    // ... other fields
}

При каждой загрузке отчета производительности, очередь задач создаёт десятки экземпляров ValueEntity: результаты JQ-фильтров, отпечатки и результаты обнаружения изменений. Обычная операция импорта обрабатывает тысячи таких сущностей в минуту.

Профилирование: куда уходит время?

Мы профилировали импорт 5 записей из production-датасета с помощью CPU-семплирования в async-profiler:

java -agentpath:libasyncProfiler.so=start,event=cpu,file=profile.jfr,jfr \
     -jar app.jar import-data ...

Результаты нас удивили:

Категория

CPU-семплы

%

Dirty checking в Hibernate

6 087

34%

GC

4 397

25%

deepCopy в Hibernate

2 361

13%

JIT-компиляция

2 084

12%

Непосредственно вычисления

1 187

7%

Сериализация JSON в Hibernate

507

3%

47% CPU уходило на внутренний dirty checking JSON в Hibernate и управление снапшотами. Не на логику нашего приложения и не на запросы к базе данных, а на сравнение JSON-колонок, с помощью которого Hibernate определял, изменились ли они.

Проблема: FormatMapperBasedJavaType.deepCopy

Когда Hibernate управляет сущностью с JSON-колонкой (полем JsonNode, это тип из библиотеки Jackson, с аннотацией @JdbcTypeCode(SqlTypes.JSON)), ему нужно обнаруживать изменения для dirty checking. При каждом вызове em.flush() Hibernate:

  1. Выполняет глубокое копирование JSON-значения, сериализуя его в строку и десериализуя обратно (FormatMapperBasedJavaType.deepCopy). Так создаётся снапшот.

  2. Сравнивает текущее значение со снапшотом с помощью ObjectNode.equals(). Это рекурсивное сравнение деревьев.

В случае больших JSON-документов каждый flush запускает полный цикл сериализации, десериализации и сравнения для каждой managed-сущности с JSON-колонкой. В нашем случае в persistence context находились сотни экземпляров ValueEntity, поэтому каждый flush() выполнял сотни таких циклов.

Профиль аллокаций это подтвердил:

# async-profiler allocation profiling
java -agentpath:libasyncProfiler.so=start,event=alloc,file=alloc.jfr,jfr ...

Источник аллокаций

Байты

JacksonJsonFormatMapper.toString (сериализация для снапшота)

10,7 ГБ

JacksonJsonFormatMapper.fromString (десериализация для снапшота)

7,6 ГБ

Только на dirty checking JSON-колонок за одну минуту импорта пришлось 18,3 ГБ аллокаций.

Решение 1: @Mutability(Immutability.class)

Ключевой момент состоял в том, что наше приложение никогда не изменяет объекты JsonNode “in-place”. Когда значение меняется, мы всегда заменяем ссылку, т.е. полностью реснапшотим:

// Мы всегда делаем так (заменяем ссылку):
entity.data = newJsonNode;

// Мы никогда не делаем так (изменяем объект на месте):
entity.data.put("key", "value");

Это означает, что Hibernate не нужно глубоко копировать JSON для сравнения со снапшотом. Вместо этого он может сравнить ссылки (==). Аннотация @Mutability сообщает Hibernate именно это:

@Column(columnDefinition = "JSONB")
@JdbcTypeCode(SqlTypes.JSON)
@Basic(fetch = FetchType.LAZY)
@Mutability(Immutability.class)  // <-- всего одна аннотация
public JsonNode data;
Небольшое примечание

Автор всё говорит правильно, я лишь хотел вклиниться, чтобы развеять один момент.

Большинство пользователей Hibernate наверняка либо имеют опыт, либо слышали про @Immutable. У таких людей возникает вопрос на тему того, чем @Mutability(Immutability.class) отличается от @Immutable. В кейсе автора - разницы нет совершенно никакой, т.е. эти вещи взаимозаменяемы (по сути, @Immutable - это просто шорткат для @Mutability(Immutability.class)). Тем не менее, для ассоциаций там поведение немного другое, но это сейчас не важно.

org.hibernate.type.descriptor.java.Immutability сообщает Hibernate: «Это значение никогда не изменяется на месте, используй исходную ссылку в качестве снапшота». Вместо полной сериализации и сравнения деревьев dirty checking сводится к сравнению ссылок (==).

Результаты

Метрика

До

После

Улучшение

CPU-семплы dirty checking

3 946

2

в 1 973 раза меньше (-99,97%)

CPU-семплы deepCopy

2 169

0

устранены полностью (-100%)

Реальное время выполнения

1 мин 36 с

1 мин 20 с

в 1,2 раза быстрее (-17%)

Пользовательское процессорное время

2 мин 02 с

59 с

в 2 раза быстрее (-51%)

Одна аннотация полностью устранила и deepCopy, и накладные расходы на dirty checking. Все существующие тесты прошли без изменений.

Когда это можно использовать?

@Mutability(Immutability.class) безопасна при следующих условиях:

  • Вы никогда не изменяете значение поля на месте, то есть не вызываете jsonNode.put(), arrayNode.add() и тому подобное.

Небольшое примечание

Автор немного драматизирует.

Изменять конечно можно (имеется в виду не жене, а immutable поля), но скалярные типы Hibernate не проксирует (это, кстати, в частности одна из причин, почему @Basic(fetch = FetchType.LAZY) просто так не работает - для таких трюков нужна инструментация байткода). И как следствие, Hibernate для immutable полей просто не может отследить dirty checking как явление, поэтому, dirty check проверки будут попросту игнорироваться (опять же, если мы говорим про скалярные поля).

  • Все изменения выполняются через замену ссылки (entity.data = newValue).

  • Тип поля неизменяем по своей природе или вы по договорённости используете его как неизменяемый.

Это применимо к JsonNode, String и любому пользовательскому типу, для которого вы придерживаетесь подхода «заменять, а не изменять». Если вы не уверены, поищите в кодовой базе изменения на месте: grep -r "\.data\.put\|\.data\.add\|\.data\.remove".

Этот подход не ограничивается JSON-колонками. @Mutability(Immutability.class) работает с любым полем @Basic, для которого Hibernate использует deepCopy при сравнении со снапшотом: с пользовательскими маппингами @Type, большими полями byte[] и любыми JavaType с дорогостоящей реализацией копирования. JSON-колонки являются самым распространённым и болезненным случаем, потому что для deepCopy требуется полный цикл сериализации и десериализации в Jackson. Однако аннотация устраняет накладные расходы на снапшоты для любого поля, которое по принятому соглашению считается неизменяемым.

Решение 2: удалить CascadeType.MERGE из связей с join table

Профиль аллокаций также выявил неожиданную горячую точку:

10 689 МБ  JacksonJsonFormatMapper.toString  (сериализация JSON)
  +-- 9 442 МБ из EntityUpdateAction (выражения UPDATE)
  +-- 1 247 МБ из EntityInsertAction (выражения INSERT)

9,4 ГБ сериализации JSON приходилось на UPDATE, а не на INSERT. Мы создавали только новые сущности. Откуда же взялись обновления?

Ответом оказался CascadeType.MERGE у связи с join table.

@Entity
public class Work extends PanacheEntity {

    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
    @JoinTable(name = "work_values", ...)
    public List<ValueEntity> sourceValues;
}

Когда мы вызывали em.merge(work), чтобы сохранить новую сущность Work, Hibernate каскадировал merge на каждую ValueEntity в sourceValues. Каждая из них уже была managed- и неизменённой сущностью в persistence context, но каскадный merge заставлял Hibernate повторно присоединять их, как если бы они были detached-экземплярами. Это запускало второй цикл dirty checking в дополнение к обычному flush сессии. Для каждой повторно присоединённой сущности Hibernate сериализовал JSONB-колонку data, чтобы создать новый снапшот для сравнения.

Здесь эффекты двух оптимизаций складываются: CascadeType.MERGE вызывал избыточный dirty checking, а deepCopy для JSON делал каждую такую проверку дорогостоящей. Удаление каскада устраняет избыточные циклы, а @Mutability(Immutability.class) удешевляет оставшиеся необходимые проверки.

Решение состоит в том, чтобы удалить CascadeType.MERGE и оставить только CascadeType.PERSIST:

@ManyToMany(cascade = {CascadeType.PERSIST}, fetch = FetchType.LAZY)
@JoinTable(name = "work_values", ...)
public List<ValueEntity> sourceValues;

Это безопасно, потому что к моменту вызова em.merge() все сущности в этой связи уже являются managed в persistence context. Каскадный merge не делал ничего полезного, зато приводил к дорогостоящим побочным эффектам.

Результаты

Метрика

До

После

Улучшение

Сериализация JSON (toString)

10 689 МБ

1 237 МБ

в 8,6 раза меньше (-88%)

Сериализация для UPDATE

9 442 МБ

0 МБ

устранена полностью (-100%)

Общий объём аллокаций

31,6 ГБ

20,8 ГБ

в 1,5 раза меньше (-34%)

Реальное время выполнения

1 мин 24 с

1 мин 14 с

в 1,1 раза быстрее (-12%)

Когда можно удалить CascadeType.MERGE?

CascadeType.MERGE можно безопасно удалить при следующих условиях:

  • Связанные сущности в момент merge родительской сущности всегда являются managed, то есть загружены из БД или созданы в той же транзакции.

  • Вы никогда не передаёте через эту связь изменённые detached-сущности в расчёте на то, что каскад сохранит их изменения.

Небольшое примечание

Автор опять немного драматизирует.

Я не стал менять оригинальную статью, но опять же, даже если сущности в коллекции detached, то ничего противозаконного нет, но по перформансу ударит, т.к. Hibernate придётся фетчить актуальное состояние сущности из БД.

Я об этом уже писал вот тут: https://habr.com/ru/companies/spring_aio/articles/1020426/

  • Связь представляет собой ссылку (например, «эта Work использует эти Values»), а не владение (например, «этот Order владеет этими LineItems»).

Проверьте места вызова em.merge(): если связанные сущности загружаются запросами или через em.find() в той же транзакции, каскадный merge избыточен.

Обратите внимание, что эта оптимизация никак не связана конкретно с JSON. CascadeType.MERGE заставляет Hibernate каскадировать операцию merge, а следовательно, и dirty checking каждой связанной сущности при вызове em.merge(), независимо от типов её колонок. В нашем случае из-за JSON-колонки data цена стала особенно заметна, потому что каждый dirty checking включал дорогостоящую сериализацию JSON. Но даже при наличии только простых скалярных колонок ненужные каскадные merge добавляют накладные расходы при flush, пропорциональные числу связанных сущностей: каждая из них проходит полный цикл dirty checking.

Совокупный эффект

Ниже приведены результаты обоих исправлений, измеренные на нашем production-конвейере импорта (5 записей, 4 рабочих потока, PostgreSQL):

Метрика

Исходный вариант

+ @Mutability

+ без MERGE

Общее улучшение

Общий объём аллокаций

31,8 ГБ

31,6 ГБ

20,6 ГБ

в 1,5 раза меньше (-35%)

Сериализация JSON

10 717 МБ

10 689 МБ

1 237 МБ

в 8,7 раза меньше (-88%)

CPU-семплы dirty checking

6 087

2

2

в 3 044 раза меньше (-99,97%)

Реальное время выполнения

5 мин 38 с

1 мин 20 с

1 мин 12 с

в 4,7 раза быстрее (-79%)

Сокращение реального времени выполнения с 5 мин 38 с до 1 мин 12 с также включает другие оптимизации, в том числе параллельную работу воркеров и улучшения запросов. Однако только изменения аннотаций Hibernate дали ускорение реального времени выполнения в 1,4 раза (-29%) и сокращение объёма аллокаций в 1,5 раза (-35%).

Как найти эту проблему в своём приложении

  1. Профилируйте с помощью async-profiler. Ищите deepCopy, isDirty, findDirty и performDirtyCheck на CPU-флеймграфах. В случае JSON-колонок горячими точками будут FormatMapperBasedJavaType.deepCopy и JacksonJsonFormatMapper.toString/fromString, но там же проявится любая дорогостоящая реализация deepCopy.

  2. Проверьте поля с дорогостоящим сравнением со снапшотом. Любое поле с @JdbcTypeCode(SqlTypes.JSON), пользовательским @Type или большим бинарным значением является кандидатом для @Mutability(Immutability.class), если вы никогда не изменяете его “in-place”. JSON-колонки являются самым распространённым случаем, но аннотация работает с любым полем @Basic.

  3. Проверьте связи с CascadeType.MERGE. Если при merge родительской сущности связанные сущности всегда уже являются managed, этот каскад создаёт только накладные расходы, независимо от типов колонок этих сущностей.

  4. Используйте --alloc --total в async-profiler. Профилирование аллокаций показывает истинную цену. Ищите случаи, когда в стеке сериализации EntityUpdateAction заметно преобладает над EntityInsertAction. Это весомый признак того, что каскадные merge порождают ненужный dirty checking для UPDATE, независимо от типов колонок.

Основные выводы

  • Dirty checking в Hibernate использует deepCopy, чтобы создавать снапшоты значений полей для последующего сравнения. Для JSON-колонок это означает полный цикл сериализации и десериализации, но за каждое поле с дорогостоящей реализацией копирования приходится платить при каждом flush.

  • @Mutability(Immutability.class) устраняет накладные расходы на снапшоты для любого поля, которое вы заменяете целиком, а не изменяете на месте. Наибольший эффект это даёт для JSON-колонок, но аннотация работает с любым полем @Basic.

  • CascadeType.MERGE у связей вызывает повторный каскадный merge и dirty checking всех связанных сущностей независимо от типов их колонок. Удалите его, если связанные сущности всегда уже являются managed.

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

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