Проблема N+1 и как её решить с помощью EntityGraph

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

Всем привет! В данной статье попробуем разобраться с проблемой N+1 (или может правильнее 1+N?) и как ее решить с помощью использования EntityGraph.

Проблема N+1 возникает, когда мы генерируем запрос на получение одной сущности из базы данных, но у данной сущности есть свои связанные сущности, которые мы тоже хотим получить и hibernate генерирует вначале один (1) запрос к базе данных, чтобы получить интересующую нас сущность, а потом N запросов, чтобы достать из базы данных связанные сущности. Данная проблема отражается отрицательно на производительности работы базы данных из-за большого числа обращений к ней.

Создадим проект и подключим следующие зависимости:

<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>com.github.javafaker</groupId>
			<artifactId>javafaker</artifactId>
			<version>1.0.2</version>
		</dependency>
</dependencies>

Создадим две простые сущности Client и EmailAddress.

@Data
@NoArgsConstructor
@Entity
@Table(name = "client")
public class Client {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "full_name")
    private String fullName;

    @Column(name = "mobile_number")
    private String mobileNumber;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "client")
    private List<EmailAddress> emailAddresses;

    public Client(String fullName, String mobileNumber, List<EmailAddress> emailAddresses) {
        this.fullName = fullName;
        this.mobileNumber = mobileNumber;
        this.emailAddresses = emailAddresses;
    }
}
@Entity
@Table(name = "email_address")
@Data
@NoArgsConstructor
public class EmailAddress {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "email")
    private String email;

    @JsonIgnore
    @ManyToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "client_id", referencedColumnName = "id")
    private Client client;

    public EmailAddress(String email) {
        this.email = email;
    }
}

Связь между Client и EmailAddress @OneToMany, то есть у одного клиента может быть несколько email адресов.

Создадим также ClientRepository.

public interface ClientRepository extends JpaRepository<Client, Long> {
}

В application.properties пропишем подключение к базе данных, а также чтобы в консоль выводились sql команды.

spring.datasource.url=jdbc:postgresql://localhost:5432/название Вашей БД
spring.datasource.username=Ваше имя для подключения к postgres
spring.datasource.password=Ваш пароль

spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.database=postgresql

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Создадим класс ClientService, где у нас будет бизнес-логика. В данном классе создадим метод для генерации данных в нашу базу. Создадим 2000 клиентов и пусть у каждого клиента будет по два email адреса.

@Service
public class ClientService {
    private final ClientRepository clientRepository;

    @Autowired
    public ClientService(ClientRepository clientRepository) {
        this.clientRepository = clientRepository;
    }

    public void generateDB(){
        List<Client> clients = create2000Clients();
        for (int i = 0; i < clients.size(); i++) {
            clientRepository.save(clients.get(i));
        }
    }


    public List<Client> create2000Clients() {
        List<Client> clients = new ArrayList<>();
        Faker faker = new Faker();
        for (int i = 0; i < 2_000; i++) {
            String firstName = faker.name().firstName();
            String lastName = faker.name().lastName();
            String sufixTel = String.valueOf(i);
            String telephone = "+375290000000";

            List<EmailAddress>emailAddresses= Arrays.asList(
                    new EmailAddress((firstName + lastName).toLowerCase() + "1" + i + "@gmail.com"),
                    new EmailAddress((firstName + lastName).toLowerCase() + "2" + i + "@gmail.com"));

            telephone = telephone.substring(0, telephone.length()-sufixTel.length()) + sufixTel;
            Client client = new Client(
                    firstName + " " + lastName,
                    telephone,
                    emailAddresses
            );

            for (EmailAddress emailAddress:emailAddresses) {
                emailAddress.setClient(client);
            }

            clients.add(client);
        }
        return clients;
    }
}

Также создадим ClientController, где будем вызывать методы.

@RestController
@RequestMapping("/api/v1/client")
public class ClientController {

    private final ClientService clientService;
    private final ClientRepository clientRepository;
    @Autowired
    public ClientController(ClientService clientService, ClientRepository clientRepository) {
        this.clientService = clientService;
        this.clientRepository = clientRepository;
    }

    @ResponseStatus(HttpStatus.OK)
    @GetMapping("/fillDB")
    public String fillDataBase() {
        clientService.generateDB();
        return "Amount clients: " + clientRepository.count();
    }

}

Через postman сделаем get запрос на http://localhost:8080/api/v1/client/fillDB наша тестовая база данных должна заполниться.

Далее дополним ClientRepository методом

 List<Client> findByFullNameContaining(String name);

Мы будем искать клиентов по части имени.

Дополним класс ClientService методом

 public List<Client> findByNameContaining(String userName){
        return clientRepository.findByFullNameContaining(userName);
    }

а также дополним класс ClientController методом

  @ResponseStatus(HttpStatus.OK)
    @GetMapping()
    public List<Client> findByNameContaining(@RequestParam String clientName) {
        List<Client> clients = clientService.findByNameContaining(clientName);
        return clients;
    }

Создадим проблему N+1: зайдем в postman и сделаем get запрос с параметром clientName на http://localhost:8080/api/v1/client?clientName=Ren

И в консоли мы увидим, что hibernate сделал вначале один запрос в базу данных в таблицу client и нашел всех клиентов, а потом еще N-запросов к таблице email_address, чтобы получить у каждого клиента email адреса.

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

Есть несколько возможных решений, я покажу как это решить с помощью JPA Entity Graph.

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

Вариант 1. Пишем аннотацию@EntityGraph над методом findByFullNameContaining в ClientRepository.

public interface ClientRepository extends JpaRepository<Client, Long> {
    @EntityGraph(type = EntityGraph.EntityGraphType.FETCH, attributePaths = "emailAddresses")
    List<Client> findByFullNameContaining(String name);
}

По умолчания @EntityGraph имеет тип EntityGraphType.FETCH , но для того чтобы понимать, что происходит я его указываю, и он применяет стратегию FetchType.EAGER к указанным атрибутам, то есть к emailAddresses.

Зайдем в postman и сделаем снова get запрос с параметром clientName на http://localhost:8080/api/v1/client?clientName=Ren

Мы получим только один запрос к базе данных.

Вариант 2. Пишем аннотацию @NamedEntityGraphнад классом Client.

@Data
@NoArgsConstructor
@Entity
@Table(name = "client")
@NamedEntityGraph(name = "client_entity-graph", attributeNodes = @NamedAttributeNode("emailAddresses"))
public class Client {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "full_name")
    private String fullName;

    @Column(name = "mobile_number")
    private String mobileNumber;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "client")
    private List<EmailAddress> emailAddresses;

    public Client(String fullName, String mobileNumber, List<EmailAddress> emailAddresses) {
        this.fullName = fullName;
        this.mobileNumber = mobileNumber;
        this.emailAddresses = emailAddresses;
    }
}

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

Также необходимо исправить аннотацию над ClientRepository.

public interface ClientRepository extends JpaRepository<Client, Long> {
    @EntityGraph(type = EntityGraph.EntityGraphType.FETCH, value = "client_entity-graph")
    List<Client> findByFullNameContaining(String name);
}

Cделаем снова get запрос с параметром clientName на http://localhost:8080/api/v1/client?clientName=Ren

Получим один запрос к базе данных.

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

Источник: https://habr.com/ru/post/714704/


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

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

Промоделируем ситуацию: знаменитейший помощник повара по имени Хуан после прогремевшей на весь мир истории со сковородками (отсылка) решил открыть свой собственный ресторанчик. Хуан готовит прекр...
Задача - сделать AMP версию всего сайта на 1С-Битрикс, чтобы содержание AMP страниц всегда соответствовало оригинальным и изменялось при изменении оригинальных.
Видеоблогер Конор Хекстра использовал разные языки программирования, чтобы решить одну и ту же задачу. Попутно выяснилось, что у Фортрана полно поклонников.
В мире фронтенда многие ресурсы (ассеты) связаны между собой. В Joomla никогда не было простого способа указать эту связь, но Joomla 4 изменила эту ситуацию, введя концепцию Web Assets. Управлени...
Собрались как-то в чистом поле полтыщи людей. В костюмах настолько странных, что только в чистом поле им ничего не могло угрожать. Почти у каждого на поясе висел котелок и в сумке позвякивали про...