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

Повний посібник з виявлення та усунення проблеми 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.

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

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

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 3.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-запити.

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

Контрольний Список Запобігання N+1:

  • ✅ Увімкнути SQL-логи в розробці для виявлення множинних запитів
  • ✅ Використовувати JOIN FETCH для часто використовуваних зв'язків @ManyToOne
  • ✅ Застосовувати @EntityGraph для багаторазового декларативного завантаження
  • ✅ Налаштовувати @BatchSize для великих колекцій @OneToMany
  • ✅ Віддавати перевагу проєкціям DTO для операцій лише для читання
  • ✅ Писати тести, що перевіряють кількість виконаних запитів
  • ✅ Вимикати SQL-логи та статистику в продакшені
  • ✅ Регулярно профілювати критичні ендпоінти за допомогою метрик Hibernate

Теги

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

Поділитися

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