Всем привет. В данной статье коснусь темы запросов к базе данных и как небольшие изменения в коде позволят значительно увеличить скорость работы нашего приложения, за счет увеличения производительности работы базы данных.
Данная статья будет исключительно практической.
И так поехали.
Допустим у нас есть такое задание: имеется какая-то база данных с аккаунтами, в каждом аккауте есть электронная почта. К нам приходят с отдела маркетинга и говорят «Вот вам список почтовых адресов, необходимо пройтись по базе данных с аккаутами, проверить есть ли такие адреса, и если есть — отправить на эту почту письмо».
Вроде задание не сложное, но если в базе данных аккаутов не сотни, а тысячи или десятки тысяч, то неправильно написанный код может понизить скорость работы нашего метода по отправке сообщений.
Приступим к созданию нашего приложения.
Зайдем на 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/
Добавить комментарий