Spring Data JPA: замена нескольких запросов одним и почему это очень важно

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

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

Данная статья будет исключительно практической.

И так поехали.

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

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

Приступим к созданию нашего приложения.

Зайдем на https://start.spring.io/ и создадим проект.

Откроем его в среде разработки и немного переработаем pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.1.0</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>spring-data-jpa-multiple-single-queries</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>spring-data-jpa-multiple-single-queries</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<!--JPA-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<!--WEB-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<!--DATABASE-->
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>
		<!--LIQUIBASE-->
		<dependency>
			<groupId>org.liquibase</groupId>
			<artifactId>liquibase-core</artifactId>
		</dependency>
		<!--LOMBOK-->
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<!--TESTING-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<!--OTHERS-->
		<dependency>
			<groupId>com.github.javafaker</groupId>
			<artifactId>javafaker</artifactId>
			<version>1.0.2</version>
		</dependency>
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-lang3</artifactId>
			<version>3.9</version>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>

Базу данных буду использовать postgresql, для накатывания таблицы в ней - liquibase.

Базу данных поднимем с помощью docker-а, также в докере поднимем контейнер с pgadmin - для просмотра данных в базе.

Ссылку на код с проектом будет в конце статьи.

В корне нашего проекта напишем docker-compose.yml со следующим содержанием:

services:
  service-db:
    image: postgres:14.7-alpine
    environment:
      POSTGRES_USER: username
      POSTGRES_PASSWORD: password
    ports:
      - "15432:5432"
    volumes:
      - ./infrastructure/db/create_db.sql:/docker-entrypoint-initdb.d/create_db.sql
      - db-data:/var/lib/postgresql/data
    restart: unless-stopped

  pgadmin:
    container_name: pgadmin4_container
    image: dpage/pgadmin4:7
    restart: always
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@admin.com
      PGADMIN_DEFAULT_PASSWORD: root
    ports:
      - "5050:80"
    volumes:
      - pgadmin-data:/var/lib/pgadmin

volumes:
  db-data:
  pgadmin-data:

Базу данных accounts_database создадим с помощью скрипта, расположенного в папке infrastructure/db

create database accounts_database;

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

Создадим нашу сущность, то есть аккаунт.

@Data
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "accounts")
public class Account {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name="username",
            nullable = false)
    private String username;
    @Column(name="email",
            nullable = false)
    private String email;
    @Column(name="created_at",
            nullable = false)
    private LocalDate createdAt;

    public Account(String username, String email, LocalDate createdAt) {
        this.username = username;
        this.email = email;
        this.createdAt = createdAt;
    }
}

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

2023-06-06-1-create-table-accounts.xml

<?xml version="1.0" encoding="UTF-8" ?>
<databaseChangeLog
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

    <changeSet logicalFilePath="2023-06-06-1-create-table-accounts"
               id="2023-06-06-1-create-table-accounts" author="s.m">
        <createTable tableName="accounts">
            <column name="id" type="serial">
                <constraints nullable="false" primaryKey="true"/>
            </column>
            <column name="username" type="varchar(255)">
                <constraints nullable="false"/>
            </column>
            <column name="email" type="varchar(255)">
                <constraints nullable="false"/>
            </column>
            <column name="created_at" type="date">
                <constraints nullable="false"/>
            </column>
        </createTable>
    </changeSet>
</databaseChangeLog>

cumulative.xml

<?xml version="1.0" encoding="UTF-8" ?>
<databaseChangeLog
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

    <include file="2023-06-06-1-create-table-accounts.xml" relativeToChangelogFile="true" />
</databaseChangeLog>

db.changelog-master.xml

<?xml version="1.0" encoding="UTF-8" ?>
<databaseChangeLog
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">

    <include file="v.1.0.0/cumulative.xml" relativeToChangelogFile="true" />

</databaseChangeLog>

Далее создадим интерфейс AccountRepository для выполнения действий с нашей сущностью в базе данных.

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
}

Следующим шагом напишем файл application.yml

server:
  port: 8081
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:15432}/accounts_database
    username: username
    password: password
  liquibase:
    enabled: true
    drop-first: false
    change-log: classpath:db/changelog/db.changelog-master.xml
    default-schema: public
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

Здесь мы настраиваем подключение к нашей базе данных, настраиваем liquibase и задаем настройки, чтобы в консоль выводились sql запросы к базе данных.

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

Допусти в нашей базе данных будет 10 000 аккаунтов, а список для проверки есть ли такие email-ы будет содержать 5 000 адресов электронной почты.

Создадим класс ConstantData в папке config.

public class ConstantData {

    public final static int NUMBER_ACCOUNTS = 10_000;
    public final static int NUMBER_OF_EMAILS_TO_SEND = (NUMBER_ACCOUNTS / 2) + 1;
    public final static List<String> LIST_OF_EMAILS_TO_SEND = createListOfEmails();

    static List<String> createListOfEmails() {
        Faker faker = new Faker();
        List<String> emailsToSend = new ArrayList<>();
        for (int i = 0; i < NUMBER_OF_EMAILS_TO_SEND; i++) {
            emailsToSend.add(faker.internet().emailAddress());
        }
        return emailsToSend;
    }
}

В методе createListOfEmails() мы с помощью библиотеки javafaker генерируем 5000 адресов электронной почты.

Далее создаем еще один класс DataLoader.

@Component
public class DataLoader {

    @Bean
    public CommandLineRunner loadDataAccount(AccountRepository accountRepository) {
        return (args) -> {
            Faker faker = new Faker();
            for (int i = 1; i <= ConstantData.NUMBER_ACCOUNTS; i++) {
                String username = faker.name().username();
                String email = "";
                if (i % 2 == 0) {
                    email = ConstantData.LIST_OF_EMAILS_TO_SEND.get(i / 2);
                } else {
                    email = faker.internet().emailAddress();
                }
                Account account = Account.builder()
                        .username(username)
                        .email(email)
                        .createdAt(LocalDate.of(2022, Month.JANUARY, 1).plusDays(faker.number().numberBetween(1, 365)))
                        .build();
                accountRepository.save(account);
            }
        };
    }
}

Данный метод loadDataAccount будет создавать 10 000 аккаунтов и заполнять их данными, половина адресов в данных аккаунтов будет взята из ранее сгенерированного списка с электронными адресами.

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

Напишем интерфейс AccountService с методом getEmailExistsInDBMultipleQueries(List targetEmailList).

public interface AccountService {

    List<String> getEmailExistsInDBMultipleQueries(List<String> targetEmailList);

}

Так как мы будем проверять существует ли адрес электронной почты в аккаунте, то в интерфейсе AccountRepository напишем метод boolean existsByEmail(String email), который с помощью Spring Data JPA , создаст для нас очень просто "волшебный" метод, который пойдет в базу данных и проверит есть ли у какого-то аккаунта такой адрес и если есть - вернет true.

boolean existsByEmail(String email);

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

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AccountServiceImpl implements AccountService{

    private final AccountRepository accountRepository;

    @Override
    public List<String> getEmailExistsInDBMultipleQueries(List<String> targetEmailList) {
        return  targetEmailList.stream()
                .filter(accountRepository::existsByEmail)
                .toList();
    }

}

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

Далее напишем еще один сервис NotificationService, где будем вызывать метод getEmailExistsInDBMultipleQueries и замерять время его выполнения и выводить на экран размер списка с электронными адресами.

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NotificationService {
    private final AccountService accountService;

    public void sendEmailMultipleQueries(){
        List<String> targetEmailList = ConstantData.LIST_OF_EMAILS_TO_SEND;
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        List<String> listEmailToSend = accountService.getEmailExistsInDBMultipleQueries(targetEmailList);
        //email send code
        stopWatch.stop();
        System.out.println("Time has passed with to check if an account with this email exists with multiple queries, ms: " + stopWatch.getTime());
        System.out.println("Size of emails list: " + listEmailToSend.size());
    }

}

Последний класс, который мы напишем - это AccountController, где мы будем вызывать наш метод и его тестировать.

@Slf4j
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/accounts")
public class AccountController {
    
    private final NotificationService notificationService;

    @GetMapping("/emailSendMultipleQueries")
    @ResponseStatus(HttpStatus.OK)
    public void emailSendMultipleQueries() {
        log.info("Send emails with multiple queries");
        notificationService.sendEmailMultipleQueries();
    }

}

Приступим к тестированию нашего кода.

Вначале выполним команду docker-compose up -d в командной строке, где находится наш docker-compose.yml файл.

Контейнеры должны подняться и работать.

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

Далее идем на http://localhost:5050/ и входим в pgadmin используя данные для входа указанные в docker-compose.yml

Добавляем новый сервер.

Выполнив запрос SELECT count(*) FROM accounts; можно убедиться, что записей в базе данных 10000.

Далее с помощью postman отправляем get запрос на http://localhost:8081/api/v1/accounts/emailSendMultipleQueries

И в консоли получаем много sql запросов к базе данных и время выполнения нашего метода 9366 миллисекунд.

Повторим это еще 2 раза и выведем среднюю. Результаты такие (9366+8863+9165)/3=9131.

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

Дополним интерфейс AccountRepository следующим методом:

    @Query("select a.email from Account a where a.email IN(:emails)")
    List<String> emailExists(@Param("emails") List<String> emails);

Здесь мы использовали аннотацию @Query для обращения к базе данных, в которой мы сразу передаем весь список электронных адресов и делаем поиск адреса непосредственно на уровне базы данных.

Дополним интерфейс AccountService следующим методом:

List<String> getEmailExistsInDBSingleQueries(List<String> targetEmailList);

В классе AccountServiceImpl реализуем этот метод:

    @Override
    public List<String> getEmailExistsInDBSingleQueries(List<String> targetEmailList) {
        return accountRepository.emailExists(targetEmailList);
    }

И в классе NotificationService вызовем этот метод и посчитаем за сколько он выполнился.

    public void sendEmailSingleQueries(){
        List<String> targetEmailList = ConstantData.LIST_OF_EMAILS_TO_SEND;
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        List<String> listEmailToSend = accountService.getEmailExistsInDBSingleQueries(targetEmailList);
        //email send code
        stopWatch.stop();
        System.out.println("Time has passed with to check if an account with this email exists with single queries, ms: " + stopWatch.getTime());
        System.out.println("Size of emails list: " + listEmailToSend.size());
    }

Последнее - дополним класс AccountController get запросом по вызову этого метода.

    @GetMapping("/emailSendSingleQueries")
    @ResponseStatus(HttpStatus.OK)
    public void emailSendSingleQueries() {
        log.info("Send emails with single queries");
        notificationService.sendEmailSingleQueries();
    }

Перед перезапуском приложения зайдем в базу данных и удалим в ней все записи.

После перезапуска приложения идем в postman и отправляем get запрос на http://localhost:8081/api/v1/accounts/emailSendSingleQueries

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

Повторим еще 2 раза и выведем среднюю. Результат составит (129+48+47)/3=75.

Итак, получается, что мы с помощью небольших изменений в коде улучшили производительность работы нашего метода по обращению к базе данных в 122 раза (9131/75).

На данном примере я хотел показать, что использование "волшебных" методов, которые дает нам Spring Data JPA не всегда оправдано, необходимо искать возможность замены многократных обращений к базе данных одним sql запросом, чтобы он выполнялся на уровне базы данных, а к нам приходил в программу уже готовый результат.

Спасибо Всем, кто дочитал до конца.

Вот ссылка на проект.

Всем пока.

Источник: https://habr.com/ru/articles/740446/


Интересные статьи

Интересные статьи

В этой статье описывается пример интеграции библиотеки компонентов пользовательского интерфейса Primefaces, построенной на основе фреймворка JavaServer Faces (JSF), в MVC приложение на Spring Boot.Пер...
Проект curl основан на фундаменте, заложенном в конце 1996 года инструментом под названием httpget. ANSI C, ставший известным как C89 В 1996 году было не так много хороших альтернатив для создан...
Конкуренция за технические таланты продолжает накаляться. Кажется, что каждый год становится «горячее», чем предыдущий. Согласно почти всем проверенным нами крупным доскам объявлений, в настоящее врем...
Вот уже несколько лет производители электроники говорят о новой прорывной технологии, которая изменит привычные нам устройства и подход к их проектированию: никаких больш...
Сложная эпидемиологическая ситуация вынуждает людей отказываться от использования общественного транспорта. Машин, а главное — моторайдеров, на дорогах становится больше. Поэтому особенно важн...