Команда Spring АйО перевела и адаптировала доклад Мацея Валковяка “Performance oriented Spring Data JPA & Hibernate”, в котором на наглядных примерах рассказывается, как существенно нарастить производительность приложения, оптимизировав его взаимодействие с БД. 

Доклад будет опубликован тремя частями. В первой части было рассказано об оптимизации управления соединениями с базой данных. Во второй части мы посмотрели на то, как оптимизировать код и настройки Spring Data и Hibernate, чтобы минимизировать количество отправляемых в базу данных SQL запросов. Третья часть посвящена обзору инструментов, которые могут помочь разработчику правильно организовать работу по регулярной оптимизации производительности приложений, в том числе при работе командой.


Немного об инструментах

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

Существуют инструменты, помогающие нам отслеживать такого рода ситуации, чтобы вовремя принять меры. Один из них разработал Влад, и этот инструмент называется Hypersistence Optimizer. Это очень удобная в использовании библиотека. Она добавляется как JAR, и затем вы создаете специальный тест. Во время теста Hypersistence Optimizer смотрит на все маппинги, которые у вас есть, на все конфигурации источника данных и находит потенциальные источники проблем. То есть, Hypersistence Optimizer сказал бы вам:

  • Вы должны установить spring.jpa.open-in-view в false 

  • Вам надо отключить автокоммит

  • Вы должны поменять FetchType для аннотации @ManyToOne на LAZY 

  • И многие другие вещи

Это коммерческий инструмент, так что за него надо платить. Если вы работаете в большой компании и с покупкой возникают затруднения, есть и другие, бесплатные, инструменты. Один из них - это QuickPerf, созданный Жаном Бисутти, о котором мы сейчас поговорим. Он поможет вам проследить за тем, чтобы созданное вами приложение не было сломано в дальнейшем.  

В качестве примера посмотрим на код теста. QuickPerf содержит очень простые и удобные аннотации. С их помощью вы указываете, что по вашим ожиданиям во время исполнения метода в тесте будет послано два запроса типа SELECT, один запрос типа INSERT и ни одного DELETE.

@Test
@ExpectSelect(2)
@ExpectInsert(1)
@ExpectDelete(0)
void execute() {
	useCase.execute(accountId: "sender-id", new PhoneNumber(number: "00334455", PhoneNumber.Type.HOME));
}

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

К сожалению, QuickPerf больше не поддерживается его создателем, так что вам понадобятся определенные трюки, чтобы заставить его работать со Spring Boot. Поэтому если есть желающие войти в Open Source и помочь поддерживать жизнь этой библиотеки, будет действительно здорово, потому что она очень удобная. 

JPA ентити и что с ними не так

Идем дальше и поговорим о JPA ентити и связанных с ними проблемах, которых немало. В частности, они довольно тяжеловесны. Вы зачастую оказываетесь в ситуациях, когда, работая с JPA ентити, вы загружаете больше данных, чем вам необходимо. Даже если у вас установлен FetchType.LAZY.

В приведенном ранее примере со сценарием SettleUseCase мы хотим обновить одно поле, но по факту мы загружаем намного больше. Мы храним ентити внутри в JPA. Hibernate хранит их в PersistentContext, который занимает память. Затем он отслеживает, поменялось это или нет, что забирает лишние ресурсы CPU. И еще появляется риск N+1. Так что же на самом деле хорошего в ентити?

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

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

В настоящее время проекции не настолько популярны, насколько они того заслуживают, даже несмотря на то, что в Spring Data имеется очень приятная поддержка для них.

@Entity
public class BankTransfer {

	@Id
	private String id;

	private String reference;

	@ManyToOne(fetch = FetchType.LAZY)
	private Account sender;

	@ManyToOne(fetch = FetchType.LAZY)
	private Account receiver;

	@Embedded
	private Amount amount;

	@Enumerated(EnumType.STRING)
	private State state;
}

Ожидаемый ответ:

{"id": "an-id","amount": { "value": "25", "currency": "EUR" } ,"state": "SETTLED"}

Рассмотрим типичный сценарий: у нас есть ентити, например, приведенный выше BankTransfer, но мы хотим вернуть в ответе не все ее поля, а лишь некоторое подмножество. Это подмножество должно стать рабочей нагрузкой ответа. Мы могли бы загрузить ентити и затем выполнить ее маппинг на DTO любым фреймворком для маппинга, но это не имеет смысла, потому что мы можем вместо этого сделать нечто другое: мы можем создать класс или интерфейс проекции или, еще лучше, record, и в нем задать, какие поля нас интересуют:

@Entity
public class BankTransfer {

	@Id
	private String id;

	private String reference;

	@ManyToOne(fetch = FetchType.LAZY)
	private Account sender;

	@ManyToOne(fetch = FetchType.LAZY)
	private Account receiver;

	@Embedded
	private Amount amount;

	@Enumerated(EnumType.STRING)
	private State state;
}   

Ожидаемый ответ:

{"id": "an-id","amount" : { "value": "25", "currency": "EUR" } ,"state": "SETTLED"}

Класс проекции:

record BankTransferDto(String id, Amount amount, State state) {}

Затем  мы могли бы создать метод репозитория, который возвращает тип класса проекции.

Репозиторий:

public interface BankTransferRepositoryRepository extends JpaRepository<BankTransfer, String> {
	BankTransferDto findDtoById(String id);
}

Отсюда Spring Data поймет, что необходимо сделать.

Spring Data посмотрит в структуру DTO, на имя метода, определит содержимое параметра WHERE. Напомним, как выглядит наш DTO:

record BankTransferDto(String id, Amount amount, State state) {}

Затем будет создан запрос, который содержит только те данные, который нам нужны.

select
	btl_0.id,
	btl_0.value,
	btl_0.currency_code,
	btl_0.state
from bank_transfer btl_0
where btl_0.id = ?

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

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

@Query("""
	select new com.example.domain.BankTransferDto(b.id, b.amount, b.state)
	from BankTransfer b
	where b.id = :id""")
BankTransferDto findDtoById(String id);

Вы даже можете написать нативный SQL запрос, если захотите. У такого подхода есть только один негативный побочный эффект — вы больше не сможете использовать record-ы. Это должен быть интерфейс. И если у вас в приложении присутствуют менее тривиальные DTO, работать с ними таким образом может оказаться несколько более сложно. 

В самом крайнем случае, вы всегда можете реализовать полностью кастомизированный метод на репозитории, написать SQL самостоятельно, выполнить этот запрос самостоятельно. Это все еще лучше, чем загружать лишние данные при помощи ентити. 

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

На такой случай Spring Data содержит еще одну возможность, называемую Dynamic Projections. Здесь мы можем сделать всего один метод в репозитории и затем передать тип, который мы хотим загрузить, в рантайме. В этом случае, если мы хотим загрузить по id, но мы не знаем, какие именно данные у нас загрузятся по id, с точки зрения репозитория мы просто оставляем вот этот один метод:

public interface BankTransferRepository extends JpaRepository<BankTransfer, String> {
	<T> T findById(String id, Class<T> clazz);
}Но единственной проблемой здесь становится то, что теперь мы должны полагаться на Spring Data, чтобы сгенерировать часть SQL запроса, которая идет после ключевого слова WHERE, исходя из имени метода. Мы больше не сможем писать наши кастомизированные JPQL запросы. 
Альтернативный вариант — это вообще не использовать Spring Data и Hibernate. С одной стороны они очень удобны, и огромным преимуществом Spring Data и Hibernate является их объектно-ориентированная парадигма, имеющая прямое отношение к Domain Driven Design, как и сама философия Spring Data. С другой стороны, если вы используете JPA просто как имитацию классов данных, возможно, дополнительная нагрузка станет такой, что оно того не стоит. Возможно, вы должны будете либо обходиться Spring Data JDBC, либо JOOQ и просто писать SQL самостоятельно. Потому что SQL тоже очень удобен. Никто на самом деле не заставляет нас использовать JPA. 

Но единственной проблемой здесь становится то, что теперь мы должны полагаться на Spring Data, чтобы сгенерировать часть SQL запроса, которая идет после ключевого слова WHERE, исходя из имени метода. Мы больше не сможем писать наши кастомизированные JPQL запросы. 

Комментарий от команды Spring АйО

Кастомизированные запросы с dynamic projections писать всё-таки можно. Вот тут можно увидеть пример идентичный тому, что приведён в статье https://github.com/mipo256/spring-data-jpa-playground/blob/master/src/main/java/io/mpolivakha/model/dynamic_proj/DynamicProjectsionsRepo.java .

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

Альтернативный вариант — это вообще не использовать Spring Data и Hibernate. С одной стороны они очень удобны, и огромным преимуществом Spring Data и Hibernate является их объектно-ориентированная парадигма, имеющая прямое отношение к Domain Driven Design, как и сама философия Spring Data. С другой стороны, если вы используете JPA просто как имитацию классов данных, возможно, дополнительная нагрузка станет такой, что оно того не стоит. Возможно, вы должны будете либо обходиться Spring Data JDBC, либо JOOQ и просто писать SQL самостоятельно. Потому что SQL тоже очень удобен. Никто на самом деле не заставляет нас использовать JPA.

Суммируя сказанное

Выводы, которые можно сделать из рассказанного выше, состоят в том, что JPA и Hibernate не так просты. Как, впрочем, и Spring Data. Они могут показаться простыми на первый взгляд, но есть множество слоев абстракции и сложности под капотом, и вы не должны слепо верить тому, что видите, потому что то, что вы видите — это не то, что вы на самом деле получаете в конце. 

Необходимо научиться правильно управлять соединениями, использовать логирование или Digma, ну или любой инструмент, который поможет вам во время разработки избежать потенциальных проблем с продакшен. Так что подумайте о покупке Hibernate Optimiser у Влада или внесите свой вклад в разработку QuickPerf, чтобы починить версию, предназначенную для Spring Boot. 

И конечно же, используйте проекции для чтения данных.


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

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