Pull to refresh
80.03

Spring Data JPA и Hibernate: ориентируемся на производительность. Часть 1

Level of difficultyEasy
Reading time14 min
Views3.3K
Original author: Maciej Walkowiak

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

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


Почему наши приложения такие медленные?

Прежде всего следует упомянуть, что доклад посвящен способам использования Spring Data и Hibernate, ориентированным на производительность, так что речь в нем идет о старых добрых технологиях, которые используются уже примерно 15 лет. И главный вопрос, который он затрагивает: «Как сделать медленные приложения быстрее?».

Прежде всего, необходимо уяснить, что мы вкладываем в понятие «медленное приложение». Обычно это выглядит так: кто‑то приходит и говорит, «Приложение нельзя использовать, оно медленное, загрузка занимает много времени, клиенты жалуются!» А разработчики, в свою очередь, прекрасно умеют находить оправдания практически для любой проблемы. 20 лет назад было принято говорить, что просто Java как таковая — это медленная среда разработки, потому что все, кто когда‑либо писал на Java, использовал ее на десктопе с Open Office или Eclipse, и при таком способе использования Java действительно была медленной.

Сейчас это утверждение больше не является верным. Современная Java — довольно быстрая среда разработки, и мы больше не можем винить за то, что наше приложение медленно работает. Так что наш новый подозреваемый — это база данных. Поскольку у нас такой большой трафик, так много данных, очень легко сослаться на то, что базе данных недостает производительности. И это очень легко доказать, если у вас есть какой‑то способ мониторинга. Вы можете сказать, что ваша база данных достигла предела по CPU или I/O, а значит, надо просто раздобыть базу данных, которая больше удовлетворяет вашим требованиям. И дальше мы просто тратим еще несколько тысяч долларов на такую базу данных. В эру облачных технологий это не представляет никакой проблемы.

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

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

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

Первое правило при исправлении любых проблем с производительностью состоит в том, чтобы не гадать на кофейной гуще, а использовать инструменты для мониторинга, чтобы наверняка понять, на какой стороне находится проблема с производительностью. Иначе может оказаться, что на тестовом стенде приложение покажет серьезное улучшение по производительности, а в продакшен будет работать медленнее, чем до всех исправлений. Существующие в наше время инструменты мониторинга позволяют достаточно точно понять, что именно происходит в приложении. При этом можно пользоваться распределенным отслеживанием  (distributed tracing) или отслеживать только один сервис. 

Выше приводится скриншот из Datadoc APM для одного из приложений, уже задеплоенных  в продакшен и показывающих проблемы с производительностью. Каждый из фиолетовых прямоугольников соответствует одному запросу к базе данных. А все, что изображено на картинке соответствует одному выполнению одного единственного HTTP запроса, длящегося больше 7 секунд. Здесь довольно много обращений к базе данных, но дальше все становится еще хуже. Если вы увеличите масштаб для каждого из этих прямоугольников, вы увидите, что он тоже состоит из нескольких запросов к базе. Ничего удивительного в том, что выполнение данного HTTP запроса заняло так много времени, но главная проблема состоит в том, что код, который инициирует все эти запросы, выглядит весьма достойно и не вызывает очевидных вопросов. Когда вы смотрите на это со стороны кода, он имеет смысл, он правильно передает бизнес-логику, он работает с сущностями, содержит какие-то репозитории, и если бы мы не воспользовались инструментом для трассировки, мы никогда бы не догадались, что происходит на самом деле. 

Итак, один из способов поиска проблем в продакшене — это действительно посмотреть на трассировку. Но было бы неправильно откладывать расследование проблем с производительностью до момента выхода в продакшен, потенциальные проблемы следовало бы искать и находить как можно быстрее. Один из способов этого добиться — это просто включить SQL-логирование, и тогда при прогоне теста мы увидим, какие запросы к базе данных выполняются и соответствует ли они нашим ожиданиям. Один из инструментов, позволяющих это сделать, называется Digma. Это, по сути, плагин для IntelliJ, который указывает на потенциальные проблемы с производительностью уже в коде в виде неких дополнительных аннотаций на потенциально вредных методах.

Как правильно управлять подключениями

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

Итак, приложение идет к источнику данных, затем оно идет к JDBC драйверу, и JDBC драйвер на самом деле осуществляет сетевое соединение с базой данных. Мы знаем, что сеть обычно работает медленно. Она намного медленнее, чем обычный вызов внутри процесса.Плюс в данном случае это не просто сетевой вызов, должна производиться еще и аутентификация.

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

В прочем, такие проблемы уже отошли в прошлое, они были актуальны до появления Spring Boot и стандартных решений касательно соединения с базой данных. В наши дни мы используем пулы подключений. Как это работает? При старте приложения создается пул физических подключений, так что дорогостоящий вызов по сети происходит в самом начале; по умолчанию создается 10 подключений. Затем каждый раз, когда приложению необходимо подключение, вместо того, чтобы идти через сеть в базу данных, оно просто берет подключение из пула. Так что в этом случае взаимодействовать с сетью нет необходимости, а получение подключения становится очень быстрым.

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

Но как определить, когда подключение получено и когда оно освобождено? Обычно вы даже не видите этого в распределенной трассировке, если только не примените некоторые трюки, но по умолчанию это просто скрытая информация. И Spring Boot из коробки не дает вам никаких инструментов, чтобы это понять. 

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

Flexy Pool не так просто использовать со Spring Boot, поэтому воспользуемся также другим проектом, выполненным Артуром Хавлюковским, это Spring Boot Datasource Decorator. Это проект, который позволяет вам интегрировать различные типы трейсеров, логирования, оберток вокруг источников данных, чтобы дать вам некоторую дополнительную информацию. Один из таких интегрированных проектов — это как раз Flexy Pool, но в этом списке присутствуют также P6 Spy и Datasource Proxy. Теперь давайте перейдем к коду, в котором существуют эти проблемы с управлением подключениями. Это просто пример, сделанный исключительно в демонстрационных целях.

package com.example;

import ...;

@SpringBootApplication
public class App {

	public static void main(String[] args) {
    		SpringApplication.run(App.class, args);
	}
}

Это типичное Spring Boot приложение с дополнительными зависимостями, чтобы можно было включить логирование:

<dependency>
	<groupId>com.github.gavlyukovskiy</groupId>
	<artifactId>datasource-proxy-spring-boot-starter</artifactId>
	<version>1.9.1</version>
</dependency>
<dependency>
	<groupId>com.github.gavlyukovskiy</groupId>
	<artifactId>flexy-pool-spring-boot-starter</artifactId>
	<version>1.9.1</version>
</dependency>

В качестве источника данных используется PostgreSQL, схема базы данных для удобства создается автоматически, логирование включено.

spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true

decorator.datasource.flexy-pool.threshold.connection.acquire=1
decorator.datasource.flexy-pool.threshold.connection.lease=0

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

public class SampleService {
	private final AnotherService anotherService;
	private final PersonRepository personRepository;
	private final ExternalService externalService;

	public SampleService(AnotherService anotherService,
 					PersonRepository personRepository,
                     		ExternalService externalService) {
    		this.anotherService = anotherService;
    		this.personRepository = personRepository;
    		this.externalService = externalService;
	}

	@Transactional
	public void hello() {
    		System.out.println(personRepository.findAll());
	}

	@Transactional
	public void withExternalServiceCall() {
    		externalService.externalCall();
    		System.out.println(personRepository.findAll());
	}

	@Transactional
	public void withExternalServiceCallAfter() {
    		System.out.println(personRepository.findAll());
    		externalService.externalCall();
	}
}

Имеется также контроллер, который вызывает эти методы.

public class SampleController {
	private final SampleService sampleService;
	private final ExternalService externalService;

	public SampleController(SampleService sampleService, ExternalService externalService) {
    		this.sampleService = sampleService;
    		this.externalService = externalService;
	}

	@GetMapping("/hello")
	void hello() {
    		sampleService.hello();
    		externalService.externalCall();
	}

	@GetMapping("/external")
	void external() {
    		sampleService.withExternalServiceCall();
	}

	@GetMapping("/external-after")
	void externalAfter() {
    		sampleService.withExternalServiceCallAfter();
	}

	@GetMapping("/nested")
	void nested() {
    		sampleService.withNestedTransaction();
	}
}

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

Flexy Pool информирует нас, что подключение было занято на 2 миллисекунды. Это всегда происходит супер быстро, пока пул не исчерпается. Но что происходит, когда он исчерпан? В нашем примере происходит простой вызов базы данных, чтобы загрузить данные из таблицы Person. И такая простая операция приводит к удержанию подключения на 273 миллисекунды, слишком долго для выполнения одного простого запроса.

Мы пометили его как @Transactional, то есть, мы хотим, чтобы Spring создал транзакцию. И затем используем подключение к базе данных.

@Transactional
public void hello() {
	System.out.println(personRepository.findAll());
}

Мы ожидаем, что это отработает относительно быстро, но этого не происходит. Почему? 

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

@GetMapping("/hello")
void hello() {
	sampleService.hello();
	externalService.externalCall();
}

В нашем случае внешний сервис просто вызывает команду sleep

public class ExternalService {

	public void externalCall() {
    		Sleep.sleep(200);
	}
}

Именно это и происходит. Когда вы запускаете Spring Boot приложение, вы увидите предупреждение о spring.jpa.open-in-view. И оно говорит, что “запросы к базе данных могут быть выполнены во время рендеринга представления”:

Это вроде бы никак не относится к тому, чем мы здесь занимаемся, потому что мы не рендерим никакого представления. Но побочным эффектом этого является то, что подключение к базе данных остается открытым до тех пор, пока HTTP запрос не выполнится полностью. Что означает, что, когда мы входим в этот метод, он открывает подключение к базе данных, затем транзакция коммитится, но подключение остается открытым, и затем метод externalCall() выполняется, занимая на 200 миллисекунд подключение к базе данных без всяких на то причин.

Поэтому первое, что мы должны сделать практически в каждом Spring Boot приложении — это отключить open-in-view.

spring.jpa.open-in-view=false

И как только это сделано, блок @Transactional будет контролировать, когда подключение занимается каким-то запросом и когда оно им отпускается. Теперь, при повторном запуске теста мы увидим, что подключение отпускается через 61 миллисекунду вместо прежних 273-х

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

Давайте посмотрим на другой пример. В этом случае метод просто вызывает пример сервиса с вызовом внешнего сервиса.

@Transactional
public void withExternalServiceCall() {
	externalService.externalCall();
	System.out.println(personRepository.findAll());
}

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

Очевидно, что, когда мы входим в этот метод, он заберет подключение, так что опять-таки мы занимаем подключение без всяких на то причин. И в данном случае нам еще очень повезло, потому что вызов к внешнему сервису выполняется самым первым. Это позволяет нам выполнить другой трюк. Мы можем пойти в application.properties и сказать источнику данных, чтобы он отключил автокоммит.

spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.open-in-view=false
spring.datasource.hikari.auto-commit=false

decorator.datasource.flexy-pool.threshold.connection.acquire=-1
decorator.datasource.flexy-pool.threshold.connection.lease=0

Режим автокоммита, который включен по умолчанию, означает, что JDBC драйвер осуществляет коммиты в базу данных автоматически.Если мы пишем серьезное приложение, мы такого не хотим, мы хотим контролировать транзакции и контролировать, когда произойдет коммит. Так что, если мы это отключим, побочным эффектом этого будет то, что приложение будет занимать подключение не тогда, когда войдет в транзакционный метод, а при первом взаимодействии с базой данных. Теперь, если посмотреть в логи теста, мы увидим, что подключение удерживалось всего на 12 миллисекунд. Так что externalCall() никак не повлиял на эту цифру.

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

spring.jpa.open-in-view=false
spring.datasource.hikari.auto-commit=false

Здесь мы находимся в весьма комфортной ситуации, когда внешний вызов совершается до того, как мы в первый раз взаимодействуем с базой данных. Но гораздо чаще бывает так, что сначала мы должны загрузить что-то из базы данных, чтобы построить некую рабочую нагрузку (payload) и передать ее во внешний сервис, и этот трюк, к сожалению, больше не работает, потому что здесь Spring просто не знает, собираемся ли мы в дальнейшем взаимодействовать с базой данных или нет.

Что делать в таком случае? Одна из менее известных возможностей Spring фреймворка — это программное управление транзакциями. Мы всегда используем аннотацию @Transactional, это очень удобно, очень приятная возможность, но есть также другой способ создавать транзакции. Он называется TransactionTemplate

При использовании TransactionTemplate в нашем распоряжении появятся два метода: один из них execute(), а второй называется executeWithoutResult(). Если он должен что-то вернуть, мы вызываем execute(), а если нет, тогда executeWithoutResult(). В нашем случае мы просто сделаем executeWithoutResult() и затем мы передадим все, что надо поместить в транзакцию, в лямбду.

//@Transactional
public void withExternalServiceCallAfter() {
	transactionTemplate.executeWithoutResult(transactionStatus -> {
    		System.out.println(personRepository.findAll());
	});

	externalService.externalCall();
}

И это будет вести себя именно так, как мы ожидаем. Транзакция будет создана вот здесь, коммит произойдет позже, и уже затем осуществится вызов внешнего сервиса. Это очень важный момент, поскольку часто случается, что у нас есть какой-то бизнес алгоритм, где мы хотим иметь одну транзакцию, выполнить одно действие, потом другое, затем сделать другую транзакцию и структурировать все это при помощи аннотации @Transactional, что обычно вынуждает нас создавать какие-то искусственные сервисы. Появляется один слой сервисов, вызывающий другой слой сервисов, и в конце концов программист уже и сам не может во всем этом разобраться.

Используя TransactionTemplate, вы можете создавать эти блоки внутри метода. И это намного удобнее, чем частое использование аннотации @Transactional. Посмотрим на еще один пример из реальной жизни: проект очень много использует @Transactional с пропагацией типа REQUIRES_NEW. Что это значит? Посмотрим на приведенный ниже метод: когда он выполняется, он откроет подключение к базе данных, начнет транзакцию, выполнит какие-то действия:  

@Transactional
public void withNestedTransaction() {
	System.out.println(personRepository.findAll());
	anotherService.runsInNewTransaction();
}

Но когда он будет выполнять вот этот метод:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void runsInNewTransaction() {
	System.out.println(personRepository.findAll());
	Sleep.sleep(400);
}

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

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

Это означает, что внешнее подключение без всякой причины должно было ждать, пока внутреннее подключение закончит работу. Так что мы понапрасну заняли два подключения к базе данных. И если вы будете ставить Propagation.REQUIRES_NEW в более-менее случайных местах, у вас будут разные слои сервисов, вы можете даже в конце концов получить три слоя, где три подключения будут заняты одновременно. И помните, что по умолчанию в пуле подключений находится всего 10 подключений. Так их надолго не хватит.

Простого решения для этой проблемы не существует. Единственный выход — структурировать код по-другому. Например, снова воспользоваться TransactionTemplate и выполню эту часть в отдельной транзакции, при этом метод runsInNewTransaction() будет выполнен в другой транзакции, потому что мы пометим его как @Transactional.

public class SampleService {

	public void withNestedTransaction() {
    		transactionTemplate.executeWithoutResult(transactionStatus -> {
        			System.out.println(personRepository.findAll());
    		});

    	anotherService.runsInNewTransaction();
	}
}

Код вложенного метода:

@Transactiofal
public void runsInNewTransaction() {
System.out.println(personRepository.findAll()) ;
Sleep.sleep(400);
}

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

Чтобы наши приложения были быстры:

  • Помним о том, чтобы отключать spring.jpa.open-in-view и auto-commit

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

  • Избегаем @Transactional(propagation = REQUIRES_NEW).

  • Используем TransactionTemplate всякий раз, когда он нам нужен.  

На этом заканчивается первая часть серии, где рассказывается, как правильно управлять подключениями и транзакциями. 

Продолжение следует.


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

Tags:
Hubs:
+13
Comments4

Articles

Information

Website
t.me
Registered
Employees
11–30 employees