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

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