Всем привет! В данной статье попробуем разобраться с проблемой 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

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

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

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


  1. stackjava
    03.02.2023 16:13

    Еще немного о entityGraph:

    https://www.baeldung.com/jpa-entity-graph


  1. rezdm
    03.02.2023 23:33
    +1

    Сперва деталь: спринг в этом примере совсем не нужен, только шум добавляет с заметке.

    По делу:
    Наверное, стоит заметить, что предложенное решение скорее "да", чем "нет" для относительно небольших рекордсетов. Если зачем-то (случаи разные бывают) надо засосать 10к клиентов, у каждого из которых по пять имелов, десять контактов контактов, три телефона, четыре адреса, то перерабатывать такое "одним джоином" становится слишком плохо. Где граница? В каждом проекте/задаче своё.

    Я это пишу ровно потому как около года назад вырезал подобное из кода в противоположную сторону.


    1. atygaev
      04.02.2023 04:06

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


      1. sgjurano
        04.02.2023 10:20
        +1

        Тут 2 основных пути, насколько мне известно:

        1) денормализовать данные под тяжёлые сценарии

        2) радикально ускорять чтение — здесь обычно применяют in memory storage типа redis


      1. rezdm
        06.02.2023 08:55

        В моём случае это .net, но смысл тот же.

        Ровно, как описал.

        Можель данных весьма "вширь" (скорее куст, чем дерево). Для пересчёта состояния всей системы надо зачитать, скажем так всё из базы. Если делать так, как это (было до версии 5 entity framework), то получалась cartesian bomb. Ну и сделал вместо чтения "одном запросом" разбивку на подзапросы. (В обратную сторону от вашего примера). Делов несложно (тот же хайбернейт так делает по умолчанию), но проблема не проявилась сразу: табличка, ещё табличка, ещё табличка.

        Из полезного -- я какое-то время отлавливал профайлером бд дубликаты запросов.