Рішення проблеми N+1 у Spring Data JPA у 2026: Fetch Join і EntityGraph
Повний посібник з виявлення та усунення проблеми N+1 у Spring Data JPA. Fetch join, @EntityGraph, batch fetching та стратегії продуктивності запитів.

Проблема N+1 є однією з найпоширеніших пасток продуктивності в JPA. Невинний запит на отримання 100 замовлень може викликати 101 SQL-запит: один для замовлень, а потім по одному для кожного пов'язаного клієнта. Це тихе примноження запитів погіршує продуктивність і перевантажує базу даних.
Ендпоінт, який повертає 50 статей з їхніми авторами, через N+1 може зрости з 10 мс до 500 мс. Раннє виявлення запобігає критичним проблемам у продакшені.
Розуміння Проблеми N+1 у JPA
Проблема N+1 виникає, коли JPA завантажує колекцію сутностей, а потім виконує додатковий запит для кожної сутності, щоб завантажити її асоціації. Така поведінка випливає з типового лінивого завантаження зв'язків @OneToMany та @ManyToMany.
Розгляньмо класичну модель із замовленнями та клієнтами. Кожне замовлення належить клієнту, і цей зв'язок типово використовує лінивe завантаження.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private LocalDateTime createdAt;
// ManyToOne relationship is lazy by default since JPA 2.0
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// OneToMany relationship lazy by default
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// Getters and setters omitted
}@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Getters and setters omitted
}Коли запит отримує замовлення, а потім звертається до імені клієнта, Hibernate виконує додатковий запит для кожного замовлення.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// 1 query: SELECT * FROM orders
List<Order> orders = orderRepository.findAll();
// For each order, accessing customer triggers a query
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
// N queries: SELECT * FROM customers WHERE id = ?
order.getCustomer().getName()
))
.toList();
}
}Для 100 замовлень цей код виконує 101 SQL-запит. Логи Hibernate розкривають цей деструктивний шаблон.
-- Query 1: fetch orders
SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o
-- Queries 2-101: fetch each customer
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2
SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3
-- ... 97 more identical queriesВиявлення Проблеми N+1 за Допомогою Логів Hibernate
Перший крок полягає в активації SQL-логів для виявлення проблемних запитів. Наступна конфігурація відображає кожен запит, виконаний Hibernate.
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
# Format SQL for better readability
format_sql: true
# Display session statistics (queries, time)
generate_statistics: true
logging:
level:
# Detailed SQL query logging
org.hibernate.SQL: DEBUG
# Display prepared statement parameters
org.hibernate.orm.jdbc.bind: TRACEСтатистика Hibernate надає цінне підсумкове резюме наприкінці кожної транзакції.
Session Metrics {
23421 nanoseconds spent acquiring 1 JDBC connection;
0 nanoseconds spent releasing 0 JDBC connections;
1254789 nanoseconds spent preparing 101 JDBC statements;
15478963 nanoseconds spent executing 101 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
}Показник у 101 JDBC-інструкцію для простого списку замовлень чітко сигналізує про проблему N+1.
SQL-логи та статистика впливають на продуктивність. Ці параметри мають залишатися вимкненими у продакшені та використовуватися лише в середовищах розробки та тестування.
Рішення 1: Fetch Join з JPQL
Fetch join завантажує асоціації одним SQL-запитом через join. Цей явний підхід вирішує проблему N+1, отримуючи всі необхідні дані одночасно.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Explicit fetch join to load customers
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// Multiple fetch join for several associations
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"JOIN FETCH o.items i")
List<Order> findAllWithCustomerAndItems();
// Fetch join with WHERE condition
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.customer c " +
"WHERE o.createdAt > :since")
List<Order> findRecentOrdersWithCustomer(
@Param("since") LocalDateTime since
);
}Fetch join перетворює N+1 запитів на один оптимізований запит.
-- Single query with join
SELECT o.id, o.order_number, o.created_at, o.customer_id,
c.id, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.idСервіс тепер використовує оптимізований метод без зміни бізнес-коду.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public List<OrderDto> getAllOrders() {
// Single query with join
List<Order> orders = orderRepository.findAllWithCustomer();
// No additional queries
return orders.stream()
.map(order -> new OrderDto(
order.getId(),
order.getOrderNumber(),
order.getCustomer().getName() // Already loaded
))
.toList();
}
}Готовий до співбесід з Spring Boot?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Рішення 2: @EntityGraph для Декларативного Контролю
Анотація @EntityGraph пропонує декларативну альтернативу fetch join. Вона визначає, які асоціації завантажувати, без написання користувацького JPQL.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Inline EntityGraph with attributePaths
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
// EntityGraph with multiple attributes
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByCreatedAtAfter(LocalDateTime since);
// Named EntityGraph referencing entity definition
@EntityGraph(value = "Order.withCustomerAndItems")
List<Order> findByCustomerId(Long customerId);
// Combination with custom query
@EntityGraph(attributePaths = {"customer"})
@Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}Іменовані EntityGraph визначаються безпосередньо на сутності для повторного використання в кількох репозиторіях.
@Entity
@Table(name = "orders")
@NamedEntityGraph(
name = "Order.withCustomer",
attributeNodes = @NamedAttributeNode("customer")
)
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("items")
}
)
@NamedEntityGraph(
name = "Order.full",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode(value = "items", subgraph = "items-product")
},
subgraphs = @NamedSubgraph(
name = "items-product",
attributeNodes = @NamedAttributeNode("product")
)
)
public class Order {
// Fields unchanged
}Subgraph дозволяє завантажувати вкладені асоціації. Наведений вище приклад завантажує замовлення, їхні позиції та продукт кожної позиції в одному запиті.
Рішення 3: Batch Fetching для Колекцій
Batch fetching пропонує альтернативу fetch join для колекцій @OneToMany. Замість завантаження кожної колекції окремо, Hibernate групує запити в пакети.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// Batch fetching on the collection
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<OrderItem> items = new ArrayList<>();
}З @BatchSize(size = 25) Hibernate завантажує позиції для 25 замовлень одночасно замість 1. Для 100 замовлень кількість запитів падає зі 101 до 5.
-- Without batch fetching: 100 queries
SELECT * FROM order_items WHERE order_id = 1
SELECT * FROM order_items WHERE order_id = 2
-- ... 98 more queries
-- With @BatchSize(size = 25): 4 queries
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25)
SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50)
SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75)
SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100)Глобальна конфігурація розміру пакета застосовується до всіх колекцій програми.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch fetching підходить для випадків, коли fetch join генерує занадто великий декартовий добуток. Для замовлення з 10 позиціями та 5 платежами fetch join повертає 50 рядків. Batch fetching виконує 2 окремі, ефективніші запити.
Порівняння Стратегій Завантаження
Кожна стратегія пропонує переваги залежно від контексту використання. Наступна таблиця підсумовує оптимальні випадки використання.
| Стратегія | Випадок Використання | Переваги | Недоліки |
|----------|----------|------------|---------------|
| Fetch Join | Зв'язки @ManyToOne | Один SQL-запит | Декартовий добуток з колекціями |
| @EntityGraph | Декларативне завантаження | Багаторазове, читабельне | Менш гнучке за JPQL |
| Batch Fetching | Колекції @OneToMany | Уникає декартового добутку | Декілька запитів |
| Subselect | Рідко використовувані колекції | Завантажує лише за потреби | Корельований підзапит |
Стратегія subselect завантажує всю колекцію при першому доступі до будь-якого елемента.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)Уникнення N+1 за Допомогою Проєкцій DTO
Проєкції DTO пропонують радикальний, але ефективний підхід. Вибираючи лише необхідні стовпці, проєкції повністю уникають завантаження сутностей та їхніх асоціацій.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}public interface OrderRepository extends JpaRepository<Order, Long> {
// DTO projection with constructor
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c")
List<OrderSummaryDto> findAllOrderSummaries();
// DTO projection with condition
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name, c.email) " +
"FROM Order o JOIN o.customer c " +
"WHERE o.createdAt > :since")
List<OrderSummaryDto> findRecentOrderSummaries(
@Param("since") LocalDateTime since
);
}Цей підхід генерує оптимальний SQL-запит без накладних витрат на мапування сутностей.
SELECT o.id, o.order_number, c.name, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > ?Розширене Налаштування зі Spring Data JPA
Spring Data JPA 3.x вводить покращення для управління EntityGraph та оптимізованих запитів.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Dynamic EntityGraph with Specification
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll(Specification<Order> spec);
// Pagination with EntityGraph
@EntityGraph(attributePaths = {"customer"})
Page<Order> findByCustomerNameContaining(
String name,
Pageable pageable
);
// Slice for efficient pagination
@EntityGraph(attributePaths = {"customer"})
Slice<Order> findByCreatedAtBefore(
LocalDateTime date,
Pageable pageable
);
}Умовне завантаження дозволяє застосовувати різні стратегії залежно від контексту.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final EntityManager entityManager;
public List<Order> getOrdersWithGraph(String graphName) {
// Dynamic EntityGraph retrieval
EntityGraph<?> graph = entityManager
.getEntityGraph(graphName);
return entityManager
.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
}
public List<Order> getOrdersForListing() {
// Minimal graph for listing
return getOrdersWithGraph("Order.withCustomer");
}
public List<Order> getOrdersForDetail() {
// Full graph for detail view
return getOrdersWithGraph("Order.full");
}
}Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Тести Продуктивності для Виявлення N+1
Автоматизовані тести перевіряють відсутність проблем N+1, підраховуючи виконані SQL-запити.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryPerformanceTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@PersistenceContext
private EntityManager em;
private Statistics statistics;
@BeforeEach
void setUp() {
// Enable Hibernate statistics
Session session = entityManager.unwrap(Session.class);
SessionFactory factory = session.getSessionFactory();
statistics = factory.getStatistics();
statistics.setStatisticsEnabled(true);
statistics.clear();
}
@Test
void findAllWithCustomer_shouldExecuteSingleQuery() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch with fetch join
List<Order> orders = orderRepository.findAllWithCustomer();
// Then: single query executed
assertThat(orders).hasSize(50);
assertThat(statistics.getQueryExecutionCount())
.as("Should execute only 1 query with fetch join")
.isEqualTo(1);
}
@Test
void findAll_withoutOptimization_triggersNPlus1() {
// Given: 50 orders in database
createTestOrders(50);
entityManager.clear();
statistics.clear();
// When: fetch without optimization
List<Order> orders = orderRepository.findAll();
// Access customers to trigger lazy loading
orders.forEach(o -> o.getCustomer().getName());
// Then: N+1 queries (51 instead of 1)
assertThat(statistics.getQueryExecutionCount())
.as("Should detect N+1 problem")
.isGreaterThan(1);
}
private void createTestOrders(int count) {
for (int i = 0; i < count; i++) {
Customer customer = new Customer();
customer.setName("Customer " + i);
customer.setEmail("customer" + i + "@test.com");
entityManager.persist(customer);
Order order = new Order();
order.setOrderNumber("ORD-" + i);
order.setCustomer(customer);
order.setCreatedAt(LocalDateTime.now());
entityManager.persist(order);
}
entityManager.flush();
}
}Твердження щодо getQueryExecutionCount() гарантує, що оптимізації залишаються чинними під час еволюції коду.
Висновок
Проблема N+1 становить серйозний виклик продуктивності у Spring Data JPA, але існує кілька ефективних рішень. Fetch join та @EntityGraph вирішують більшість випадків, завантажуючи асоціації в одному запиті. Batch fetching пропонує альтернативу для великих колекцій, де fetch join генерує декартовий добуток.
Контрольний Список Запобігання N+1:
- ✅ Увімкнути SQL-логи в розробці для виявлення множинних запитів
- ✅ Використовувати
JOIN FETCHдля часто використовуваних зв'язків@ManyToOne - ✅ Застосовувати
@EntityGraphдля багаторазового декларативного завантаження - ✅ Налаштовувати
@BatchSizeдля великих колекцій@OneToMany - ✅ Віддавати перевагу проєкціям DTO для операцій лише для читання
- ✅ Писати тести, що перевіряють кількість виконаних запитів
- ✅ Вимикати SQL-логи та статистику в продакшені
- ✅ Регулярно профілювати критичні ендпоінти за допомогою метрик Hibernate
Теги
Поділитися
Пов'язані статті

Співбесіда Spring GraphQL: Резолвери, DataLoader та Розв'язання проблеми N+1
Підготовка до співбесід Spring GraphQL з цим повним посібником. Резолвери, DataLoader, обробка проблеми N+1, мутації та найкращі практики для технічних запитань.

Spring Boot 3.4 Virtual Threads: Питання Співбесід і Бенчмарки Продуктивності
Опануйте Virtual Threads з Java 21 та Spring Boot 3.4: 15 питань для співбесід, бенчмарки продуктивності й шаблони міграції для успішних технічних інтерв'ю.

Логування Spring Boot у 2026: структуровані логи у production з Logback і JSON
Повний посібник зі структурованого логування у Spring Boot. Конфігурація Logback JSON, MDC для трасування, найкращі практики для production та інтеграція з ELK Stack.