Почему следует избегать использования JPA/Hibernate в продакшене

April 3, 2021

Дисклеймер - я люто ненавижу JPA/Hibernate

Мои отношения с Hibernate (JPA тогда ещё не было) не сложились с самого начала - в далёком то ли 2005, то ли 2007, на собеседовании у меня спросили как замапить отношение 1-N в Hibernate. А я ответил "Я не знаю, что такое Hibernate".

Затем в чуть менее далёком 2008 году я устроился в Софтэйдж на какой-то проект на Swing и Hibernate. Коммерческого опыта ни с тем ни с другим у меня на тот момент не было, поэтому мне казалось, что работал я плохо. Я сильно парился на эту тему недели две-три, а потом уволился.

Однако, избежать JPA Java-разработчику практически не реально, поэтому и мне пришлось испить сию чашу до дна. Я раскопал и починил гору багов и проблем с производительностью, порождённых стилем программирования, насаждаемым JPA, и всё-таки разобрался как она работает в общих чертах. Но часы моей жизни, потраченные на эти раскопки уже не вернуть. Вот почему я ненавижу JPA.

В этом посте я изложил факты и свой опыт, а как их интерпретировать - решайте сами.

Философия JPA

Моя вольная интерпретация философии JPA: "Забудьте про базу данных - просто объявите свою объектную модель. Работайте с ней, как будто она вся в памяти. Мы позаботимся о сохранении объектов в БД".

Возможно истинная философия JPA какая-то другая (не могу нагуглить), но эта - точно самая распространенная "в народе".

Упрощённая модель работы Hibernate

jpa model
Figure 1. Упрощённая модель работы Hibernate

Для обеспечения обещания "работайте как будто у вас все объекты в памяти" Hibernate работает примерно так:

  1. Приложение начинает транзакцию через entityManager.getTransaction().begin() (транзакции бывают и в памяти и это не противоречит философии JPA);
  2. приложение загружает данные через entityManager:
    1. entityManager формирует запросы и получает строки таблиц через JDBC-Driver;
    2. ORM на основе строк формирует прокси объектов сущностей;
    3. перед тем как отдать приложению, entityManager сохраняет все объекты в Persistence Context;
  3. приложение каким-то образом изменяет объекты через сеттеры;
    1. но т.к. это прокси, то сеттеры заодно помечают объекты "грязными";
  4. приложение коммитит транзакцию через entityManager.getTransaction().commit();
    1. в этот момент entityManager просматривает Persistence Context, сохраняет/обновляет в БД все "грязные" и новые объекты;

Казалось бы, всё прекрасно - "Смотри, мам! Никакого шаблонного кода работы с БД!". Всё настолько легко и просто, что можно дать задачу свеже испечённому выпускнику курсов "стань Java-разработчиком за три месяца" и он сразу начнёт давать результат. Но это только пока вы делаете первую версию простой системы, которую одновременно используете вы и тестировщик. А когда система попадает под нагрузку и начинает меняться, этот дизайн начинает показывать своё дьявольское рыло недостатки.

Достоинства JPA

У всего есть свои плюсы и минусы. Надо признать, что плюсы есть даже у JPA.

Это безусловно самая распространённая технология работы с БД на платформе Java. Из этого вытекает ещё три достоинства:

  • По JPA огромное количество материалов всех видов и на любой вкус. Если использовать JPA идиоматично, то любые проблемы и решения гуглятся моментально;
  • нанять разработчика, знающего JPA не проблема - берёте с любого рынка и с вероятностью 99% он имеет хоть какой-то опыт работы с JPA;
  • JPA поддержано везде, где его можно поддержать. В Котлине, например, сделали специальный плагин для совместимости с JPA.

Кроме того, JPA предлагает знакомую всем модель программирования в императивном ООП-стиле. Берёте любого программиста с рынка и он знает как работать с JPA. Эта модель действительно легкая в использовании и практически всегда предлагает вариант решения задачи с нулевым сопротивлением.

С изолированной задачей сохранения и загрузки графов объектов в БД JPA справляется без каких-либо нареканий.

Ещё одна задача которую решает JPA - это сокрытие разницы в диалектах SQL, в случае если проект должен поддерживать несколько различных СУБД. Однако это работает только до тех пор, пока вам удаётся обойтись "наибольшим общим делителем" возможностей SQL-диалектов ваших СУБД. Но самые полезные для производительности возможности обычно скрываются в уникальных частях диалектов.

Наконец, именно с точки зрения реализации, это надёжное решение - я не помню чтобы сталкивался с багами в реализации Hibernate.

Но у всего есть и свои минусы и в JPA их тоже хватает.

Проблемы JPA

Корень проблем JPA лежит не в технической, а парадигмальной плоскости. JPA пытается создать иллюзию отсутствия базы данных, в частности спрятать от программиста необходимость отражения изменений в БД. Поэтому, в силу природы баз данных (управление изменяемым состоянием), у JPA нет другого выбора, кроме как использовать императивную модель программирования. Это единственный способ отдать программе "POJO", а потом отследить изменения его состояния. И в погоне за этой химерой JPA исключает более эргономичную декларативную модель программирования.

JPA наносит удар по двум фронтам - дизайн и производительность. Сначала рассмотрим, как JPA подрывает дизайн программ.

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

JPA требует, чтобы классы сущностей были открытыми для наследования:

The entity class must not be final

JSR 338: JavaTM Persistence API; Version 2.2; "2.1 The Entity Class"

А классы должны быть либо спроектированы и задокументированы для наследования, либо запрещать его. Тут сошлюсь на классику: Effective Java, глава "Item 19: Design and document for inheritance or else prohibit it".

При том проектирование класса для наследования требует намного больших усилий, чем определение структуры данных с несколькими полями и геттерами и сеттерами для них. Я ни разу не видел JPA Entity, спроектированную для наследования.

Хотя возможность наследования сущностей JPA создаёт потенциал для проблем, на практике я с ними не сталкивался.

Конструктор по умолчанию

JPA требует включения в классы сущностей конструкторов по умолчанию:

The entity class must have a no-arg constructor.

JSR 338: JavaTM Persistence API; Version 2.2; "2.1 The Entity Class", https://github.com/javaee/jpa-spec/blob/master/jsr338-MR/JavaPersistence.pdf

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

Эту проблему можно частично обойти, сделав конструктор по умолчанию package private и пометив его @Deprecated. Правда я не видел, чтобы кто-то кроме меня следовал этой практике.

Объекты должны быть изменяемыми

JPA не может работать с неизменяемым объектами "By Design", и мутабельность так же зашита в спецификацию:

An update to the state of an entity includes both the assignment of a new value to a persistent property or field of the entity as well as the modification of a mutable value of a persistent property or field

JSR 338: JavaTM Persistence API; Version 2.2; "3.2.4 Synchronization to the Database"

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

Для того чтобы минимизировать протечки своей абстракции, JPA необходимо обеспечить строгое соответствие одного объекта в памяти одной строке в БД. Поэтому, если вы вместо мутации объекта создадите новый экземпляр с обновлённым состоянием, для JPA это будет новый объект, соответствующий новой строке БД. И при попытке сохранить новый экземпляр, JPA его попытается вставить и получит ошибку нарушения уникальности первичного ключа.

Это можно частично обойти, сделав сущности неизменяемыми, и выполняя обновления через UPDATE-запросы. Но это будет хорошо работать, только пока вам надо обновить один объект. Если же вы работаете с графом неизменяемых объектов, то придётся руками написать запросы для всех типов и руками же обойти этот граф чтобы UPDATE-ы.

Плохой процедурный стиль программирования

Предыдущие два пункта и ещё ряд более мелких ограничений, которые JPA накладывает на сущности ведут к деградации подхода к разработке до процедурного. Есть структуры данных без поведения (JPA сущности) и императивные процедуры для манипуляции ими (сервисы). Добро пожаловать в 1981 год.

Ещё в 70 годах класски, например Ларри Константин в Структурном дизайне, вывели универсальную структуру поддерживаемых программ:

good module structure

Эта структура и сейчас по большому счёту актуальна в виде Чистой архитектуры и Функционального ядра/императивной оболочки.

Однако JPA превращаёт её в такую структуру:

bad module structure

Этот секретный ввод и вывод очень сложно анализировать, что ведёт к большим проблемам с производительностью - попробуйте понять сколько запросов будет выполнено в процессе обработки запроса, и регрессиям - попробуйте понять сколько, каких и как строк измениться в БД. Плюс такая структура требует использования моков при тестировании бизенс-правил (трансформации). А тесты с моками проверяют не контракт метода, а реализацию и "ломаются" при любом маломальском рефакторинге.

Непосредственно к JPA это не относится, но на моей практике программисты считают, что пишут в ОО-стиле и не изучают "старьё" вроде структурного программирования и дизайна. Порождая в итоге плохой процедурный код с низкой связностью (cohesion), высокой связанностью (coupling), выходом областей действия решений за рамки области контроля (см. 9.4 Scope of effect/scope of control) и т.п.

Добро пожаловать в 1971 год. Рекомендую воздержаться от использования оператора Go To.

Весь код становится кодом с побочными эффектами

При использовании JPA буквально весь код становится кодом с побочными эффектами.

Каждый геттер может привести к выполнению запроса. Или завтра начать приводить к выполнению запроса. Каждый вызов функции может мутировать ваш объект. И добавить новый UPDATE в транзакцию.

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


Теперь рассмотрим проблемы с производительностью, которые несёт использование JPA

Ленивая загрузка

JPA активно продвигает ленивую загрузку. Это вариант по умолчанию для отношений OneToMany и ManyToMany и ленивая загрузка считается "лучшей практикой" в мире JPA.

Я не удивлюсь, если ленивая загрузка ответственна за 1% мирового потребления электроэнергии. Ленивая загрузка была причиной 90% проблем с производительностью, которые мне приходилось решать в проектах с JPA.

Я много раз (например здесь) на порядки увеличивал производительность частей системы, использующих JPA, по следующему алгоритму:

  1. посчитать количество запросов, выполняемых кодом;
  2. пригладить волосы, вставшие дыбом от сотен запросов вместо несколько штук;
  3. выкинуть старый код, написать несколько запросов руками, написать на этой базе новый код;
  4. готово.

Тут я могу только предполагать, но думаю рецепт создания подобных проблем такой:

  1. разработчику нужно срочно реализовать новую функциональность;
  2. в месте, куда разработчик собирается добавлять новую функциональность, у него уже есть объект с геттером, возвращающим список с нужными данными;
  3. разработчик вызывает этот геттер и пробегается по нему циклом;
  4. примерно в 60% случаев, разработчик не осознаёт, что вызвав геттер он добавляет новый запрос. А пробежавшись по нему циклом - ещё N.

    Ещё в 30% осознаёт, но решает что "преждевременная оптимизация - корень всех зол".

    Ещё в 7% случаев добавляет задачу на кладбище техдолга.

    И наконец только в 3% случаях, берёт на себя ответственность, двигает сроки и решает задачу эффективно.

    По моим наблюдениям у меня в проектах с JPA процентовка примерно такая же, в лучшем случае - 60, 0, 30, 10 соотвественно.

  5. разработчик повторяет шаг 3 несколько раз, лучше сделать 2-3 вложенных цикла с ленивой загрузкой, чтобы получить экспоненциальный рост количества запросов;
  6. разработчик тестирует на демо-данных с двумя строками в таблице и не видит никаких проблем;
  7. готово, можно нанимать меня для решения проблем с производительностью.

С ленивой загрузкой надо быть постоянно начеку. Каждый раз, написав что-то в духе entity.getXXXs, задумываться - не случится ли здесь N+1 запрос. Лично мне не хватает дисциплины на это.

В результате получается что-то вроде этого (дойдите до твита сами, если адблокер блокирует изображение):

Так же, говоря о ленивой загрузке, невозможно обойти печально известный LazyInitializationException. Я всё ещё продолжаю встречать его в продакшене с завидной регулярностью.

Наконец, специфичной для JPA проблемой является то, что оно не предоставляет удобных средств для динамического управления ленивой загрузкой. Где-то можно использовать NamedEntityGraph, но из-за его многословности слишком высок соблазн откатиться к ленивой загрузке.

Дополнительный запрос для обновления сущности

Та же проблема, что и с неизменяемыми объектами , возникает, если вы хотите обновить сущность на основании DTO, полученном извне (в HTTP-запросе, например). В JPA есть два способа сделать это:

  1. Идиоматичный - выполнить дополнительный SELECT для того чтобы поместить объект в PersistenceContext, и обновить его;
  2. Эффективный - снова воспользоваться UPDATE-ом.

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

Теоретически есть ещё вариант хранить сущности в HTTP сессии, но в эпоху горизонтального масштабирования это вариант исключительно теоретический.

Дополнительный запрос для вставки ссылки

Третья проблема из той же серии - вставка новой сущности, которая ссылается на существующую с известным ИДом. И снова есть всё те же два варианта: либо делать дополнительный запрос, жертвуя производительностью, или бороться с JPA.

Кэширование

Кэшировать JPA сущности нельзя.

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

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

Наконец, если у сущности есть ленивые поля, то рано или поздно стрельнет LazyInitializationException.


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

Получается, что теоретически JPA можно использовать, не жертвуя качеством дизайна и производительностью. Однако придётся пожертвовать идиоматичностью использования JPA. А вслед за ней уходят и все остальные достоинства JPA - материалов по такому подходу уже практически нет, в поддержке проявляются острые углы, разработчики этот подход не знают и т.п.

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

Альтернативы JPA

Все вышеперечисленные проблемы не присущи объектно-реляционному маппингу как таковому. Это проблемы одного конкретного подхода к ОРМу, вызванные его стремлением сэмулировать работу с объектами в памяти. Поэтому существуют другие решения, даже похожие на JPA, в которых идиоматичное использование не вынуждает жертвовать дизайном и производительностью.

Spring Data Jdbc/R2dbc

docs.spring.io/spring-data/jdbc

Сейчас я предпочитаю работать с БД по средствам Spring Data Jdbc/R2dbc (далее - SDJ).

Эта технология обладает частью достоинств, которые считают уникальными для JPA:

  1. программисты знакомые со Spring Data JPA уже знают большую часть SDJ;
  2. это всё та же всеми любимая технология Spring Data, которая "автомагически" генерирует реализации методов вида findByName(name: String);
  3. это "надёжное решение от проверенного вендора" - его намного легче "продать" заказчику или СТО, чем другие альтернативы.

При всём при этом SDJ имеет эргономичную философию:

Spring Data JDBC aims to be much simpler conceptually, by embracing the following design decisions:

  • If you load an entity, SQL statements get run. Once this is done, you have a completely loaded entity. No lazy loading or caching is done.
  • If you save an entity, it gets saved. If you do not, it does not. There is no dirty tracking and no session.
  • There is a simple model of how to map entities to tables. It probably only works for rather simple cases. If you do not like that, you should code your own strategy. Spring Data JDBC offers only very limited support for customizing the strategy with annotations.
Spring Data JDBC Reference Documentation, https://docs.spring.io/spring-data/jdbc/docs/2.1.7/reference/html/#jdbc.why

И чуть ниже:

  • Try to stick to immutable objects — Immutable objects are straightforward to create as materializing an object is then a matter of calling its constructor only. Also, this avoids your domain objects to be littered with setter methods that allow client code to manipulate the objects state. If you need those, prefer to make them package protected so that they can only be invoked by a limited amount of co-located types. Constructor-only materialization is up to 30% faster than properties population.
  • Provide an all-args constructor — Even if you cannot or don’t want to model your entities as immutable values, there’s still value in providing a constructor that takes all properties of the entity as arguments, including the mutable ones, as this allows the object mapping to skip the property population for optimal performance.
Spring Data JDBC Reference Documentation, https://docs.spring.io/spring-data/jdbc/docs/2.1.7/reference/html/#mapping.general-recommendations

Более того, хотя

All Spring Data modules are inspired by the concepts of “repository”, “aggregate”, and “aggregate root” from Domain Driven Design.

Все проекты на Spring Data JPA, с которыми я сталкивался на практике, игнорируют DDD, создают по репозиторию на таблицу и строят полносвязный двунаправленный граф всех сущностей. Кажется, с этим согласны и авторы SDJ:

These are possibly even more important for Spring Data JDBC, because they are, to some extent, contrary to normal practice when working with relational databases.

Spring Data JDBC Reference Documentation

Эта так называемая "normal practice" - просто кошмар с точки зрения дизайна, поддержки и производительности, хотя и позволяет быстро решить задачу в моменте.

С SDJ "normal practice" не пройдёт из-за отсутствия ленивой загрузки, и команде всё-таки придётся озадачиться дизайном модели данных и разбиением её на агрегаты.

Пока что я попробовал эти технологии (JDBC и R2DBC) только в двух небольших проектах, но результатами очень доволен.

jooq

jooq.org

jooq - первая альтернативная технология, с которой у меня есть успешный коммерческий опыт.

В основе jooq-а лежит Java DSL для написания SQL запросов. Но автор так же сделал мощную инфраструктуру исполнения запросов и генерации DAO для CRUD операций.

Основных недостатка два - генерация исходного кода отдельным шагом и платная лицензия для работы с платными базами данных.

Ebean

ebean.io

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

Эта технология наиболее близка к JPA и является полноценным ОРМом. Но в отличие от JPA, Ebean не накладывает таких ограничений на дизайн и по умолчанию намного более производительная.

Однако по Ebean мало информации помимо официальной документации, а некоторые особенности в поведении всё-таки встречались. Плюс Ebean использует препроцессор аннотаций, который заметно тормозит сборку и не всегда корректно работает в Идее.

Тем не менее проект сдан, сдан в срок и седых волос прибавилось не больше, чем обычно.

MyBatis

mybatis.org

MyBatis я сам в коммерческих проектах не трогал, но насколько мне известно, это тоже популярная альтернатива JPA.

Что делать, если JPA невозможно избежать

Зачастую JPA избежать невозможно. Кому-то достаётся огромный легаси, который надо поддерживать. Кому-то - новый проект, где технологии диктуются "Архитектором" или заказчиком.

Уже после публикации своего поста, я наткнулся на этот пост. И там автор описывает все правила (и ещё чуть-чуть), которые я использую для минимизации вреда JPA, в проектах где его не удалось избежать. В частности:

  1. Stop having public default constructor and setters
  2. Keep JPA DAOs outside of the domain as much as you can
  3. Stop adding multi-directional association
  4. Stop adding entity mappings whenever its possible

Заключение

По моему мнению, применение JPA уместно, когда важно сделать быстро, дёшево и плохо. То есть применение JPA уместно в двух случаях:

  1. быстрое прототипирование;
  2. разработка небольших внутренних информационных систем на пару десятков таблиц и столько же пользователей.

И в этих случаях, вариант с сохранением сущностей в HTTP сессии становится уже вполне практическим.

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

Ссылки

Ещё ссылки с критикой JPA и костылями для обхода её проблем: