Рішення проблеми N+1 у Spring Data JPA: Fetch Join, EntityGraph і Hibernate 7.4

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

Розв'язання проблеми N+1 у Spring Data JPA за допомогою fetch join та EntityGraph

Проблема N+1 є однією з найпоширеніших пасток продуктивності в JPA. Невинний запит на отримання 100 замовлень може викликати 101 SQL-запит: один для замовлень, а потім по одному для кожного пов'язаного клієнта. Це тихе примноження запитів погіршує продуктивність і перевантажує базу даних.

Реальний Вплив N+1

Ендпоінт, який повертає 50 статей з їхніми авторами, через N+1 може зрости з 10 мс до 500 мс. Раннє виявлення запобігає критичним проблемам у продакшені.

Розуміння Проблеми N+1 у JPA

Проблема N+1 виникає, коли JPA завантажує колекцію сутностей, а потім виконує додатковий запит для кожної сутності, щоб завантажити її асоціації. Така поведінка випливає з типового лінивого завантаження зв'язків @OneToMany та @ManyToMany.

Розгляньмо класичну модель із замовленнями та клієнтами. Кожне замовлення належить клієнту, і цей зв'язок типово використовує лінивe завантаження.

Order.javajava
@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
}
Customer.javajava
@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 виконує додатковий запит для кожного замовлення.

OrderService.java - Problematic codejava
@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 розкривають цей деструктивний шаблон.

sql
-- 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.

yaml
# 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 надає цінне підсумкове резюме наприкінці кожної транзакції.

text
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, отримуючи всі необхідні дані одночасно.

OrderRepository.javajava
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 запитів на один оптимізований запит.

sql
-- 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

Сервіс тепер використовує оптимізований метод без зміни бізнес-коду.

OrderService.java - Optimized codejava
@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.

OrderRepository.javajava
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 визначаються безпосередньо на сутності для повторного використання в кількох репозиторіях.

Order.javajava
@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, який зменшує надмірність анотацій:

Order.java - Hibernate 7 text-based syntaxjava
@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 групує запити в пакети.

Order.javajava
@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.

sql
-- 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)

Глобальна конфігурація розміру пакета застосовується до всіх колекцій програми.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch проти Fetch Join

Batch 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 спочатку визначає обмежений набір ідентифікаторів батьківських сутностей, а потім отримує пов'язані колекції лише для цих батьківських рядків.

OrderRepository.java - Safe pagination with Hibernate 7.4java
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 тепер використовує підзапит для пагінації на рівні бази даних:

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 завантажує всю колекцію при першому доступі до будь-якого елемента.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();
sql
-- Generated subselect query
SELECT * FROM order_items
WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?)

Уникнення N+1 за Допомогою Проєкцій DTO

Проєкції DTO пропонують радикальний, але ефективний підхід. Вибираючи лише необхідні стовпці, проєкції повністю уникають завантаження сутностей та їхніх асоціацій. Цей патерн особливо актуальний для ендпоінтів із великою кількістю читань, як обговорюється в модулі запитів JPA.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
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-запит без накладних витрат на мапування сутностей.

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 та оптимізацію запитів. Розуміння пропагації транзакцій стає важливим при поєднанні цих стратегій отримання зі складною бізнес-логікою.

OrderRepository.javajava
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
    );
}

Умовне завантаження дозволяє застосовувати різні стратегії залежно від контексту.

OrderService.javajava
@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.

OrderRepositoryPerformanceTest.javajava
@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

Проблема 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

Автор:

Anthony Fillion-Maillet

Засновник SharpSkill

Fullstack-розробник понад 10 років. Керує SharpSkill і відповідає за все, що тут публікується.

Оновлено 2 вересня 2026 р.

Теги

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

Поділитися

Пов'язані статті