Рішення проблеми N+1 у Spring Data JPA: Fetch Join, EntityGraph і Hibernate 7.4
Повний посібник з виявлення та усунення проблеми 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. Ширший огляд найкращих практик JPA міститься в модулі основ Spring Data JPA.
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 дозволяє завантажувати вкладені асоціації. Наведений вище приклад завантажує замовлення, їхні позиції та продукт кожної позиції в одному запиті.
Hibernate 7 вводить спрощений текстовий синтаксис @NamedEntityGraph, який зменшує надмірність анотацій:
@Entity
@Table(name = "orders")
@org.hibernate.annotations.NamedEntityGraph(
name = "Order.full",
graph = "customer, items(product)"
)
public class Order {
// Fields unchanged
}Це однорядкове визначення замінює громіздкі вкладені анотації @NamedAttributeNode та @NamedSubgraph.
Рішення 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 у Hibernate 7.4
Довгостроковим обмеженням fetch join була несумісність із пагінацією. Поєднання setMaxResults() з fetch join для колекцій змушувало Hibernate завантажувати всі відповідні рядки, а потім застосовувати обмеження в пам'яті. Попередження firstResult/maxResults specified with collection fetch; applying in memory вказувало на цю проблему продуктивності.
Hibernate 7.4 вирішує цю проблему. Пагінація з fetch join для колекцій тепер працює на рівні бази даних за допомогою вкладених запитів. Hibernate спочатку визначає обмежений набір ідентифікаторів батьківських сутностей, а потім отримує пов'язані колекції лише для цих батьківських рядків.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);
@Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.items")
Slice<Order> findAllWithCustomerAndItems(Pageable pageable);
}Згенерований SQL тепер використовує підзапит для пагінації на рівні бази даних:
-- Hibernate 7.4: database-level pagination with fetch join
SELECT o.*, i.*
FROM orders o
JOIN order_items i ON o.id = i.order_id
WHERE o.id IN (
SELECT id FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 0
)Це покращення значно зменшує використання пам'яті для пагінованих ендпоінтів, що відображають зв'язки батько-дитина. Spring Boot 4.1 включає Hibernate 7.4.5, тож ця оптимізація доступна за замовчуванням.
Для застосунків, які все ще використовують попередні версії Hibernate, патерн двофазного отримання залишається найбезпечнішим підходом: спочатку отримайте ідентифікатори батьків із пагінацією, а потім завантажте асоціації для цих конкретних ідентифікаторів.
Порівняння Стратегій Завантаження
Кожна стратегія пропонує переваги залежно від контексту використання. Наступна таблиця підсумовує оптимальні випадки використання.
| Стратегія | Випадок Використання | Переваги | Недоліки |
|---|---|---|---|
| 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 пропонують радикальний, але ефективний підхід. Вибираючи лише необхідні стовпці, проєкції повністю уникають завантаження сутностей та їхніх асоціацій. Цей патерн особливо актуальний для ендпоінтів із великою кількістю читань, як обговорюється в модулі запитів JPA.
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 у Spring Boot 4.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-запити. Комплексні стратегії тестування містяться в посібнику Testcontainers для Spring Boot.
@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() гарантує, що оптимізації залишаються чинними під час еволюції коду.
Джерела
- Hibernate 7.4 What's New – пагінація з fetch join тепер безпечна на рівні бази даних
- Spring Boot 4.1 Release Notes – включає Hibernate 7.4.5.Final
- Hibernate ORM Documentation – офіційна документація стратегій отримання та EntityGraph
Контрольний Список Запобігання N+1 у Spring Data JPA
Проблема N+1 становить серйозний виклик продуктивності у Spring Data JPA, але існує кілька ефективних рішень. Fetch join та @EntityGraph вирішують більшість випадків, завантажуючи асоціації в одному запиті. Batch fetching пропонує альтернативу для великих колекцій, де fetch join генерує декартовий добуток. Завдяки Hibernate 7.4 пагінація з fetch join нарешті працює на рівні бази даних.
Що застосовувати в продакшені:
- Увімкнути SQL-логи в розробці для виявлення множинних запитів
- Використовувати
JOIN FETCHдля часто використовуваних зв'язків@ManyToOne - Застосовувати
@EntityGraphдля багаторазового декларативного завантаження - Налаштовувати
@BatchSizeдля великих колекцій@OneToMany - Віддавати перевагу проєкціям DTO для операцій лише для читання
- Писати тести, що перевіряють кількість виконаних запитів
- Вимикати SQL-логи та статистику в продакшені
- Регулярно профілювати критичні ендпоінти за допомогою метрик Hibernate
- Оновитися до Spring Boot 4.1+, щоб скористатися виправленнями пагінації в Hibernate 7.4
Чи знайдеш ти помилку в Spring Boot?
Справжній фрагмент коду, прихована помилка, одна спроба на день. Щоб спробувати, акаунт не потрібен.

Автор:
Anthony Fillion-MailletЗасновник SharpSkill
Fullstack-розробник понад 10 років. Керує SharpSkill і відповідає за все, що тут публікується.
Оновлено 2 вересня 2026 р.
Теги
Поділитися
Пов'язані статті

Співбесіда 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: OpenTelemetry, розподілене трасування та питання на співбесідах
Повний посібник зі спостережуваності Spring Boot з OpenTelemetry та Micrometer Tracing. Налаштування розподіленого трасування, Observation API, експорт OTLP та підготовка до технічних співбесід.