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

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