Rozwiązania problemu N+1 w Spring Data JPA w 2026: Fetch Join i EntityGraph
Kompletny przewodnik po wykrywaniu i usuwaniu problemu N+1 w Spring Data JPA. Fetch join, @EntityGraph, batch fetching i strategie wydajności zapytań.

Problem N+1 stanowi jedną z najczęstszych pułapek wydajnościowych w JPA. Niewinne zapytanie pobierające 100 zamówień może wyzwolić 101 zapytań SQL: jedno dla zamówień, a następnie po jednym dla każdego powiązanego klienta. To ciche mnożenie zapytań pogarsza wydajność i przeciąża bazę danych.
Endpoint zwracający 50 artykułów wraz z autorami może wzrosnąć z 10ms do 500ms z powodu problemu N+1. Wczesne wykrycie zapobiega krytycznym problemom w produkcji.
Zrozumienie Problemu N+1 w JPA
Problem N+1 występuje, gdy JPA ładuje kolekcję encji, a następnie wykonuje dodatkowe zapytanie dla każdej encji w celu załadowania jej powiązań. Zachowanie to wynika z domyślnego leniwego ładowania relacji @OneToMany i @ManyToMany.
Rozważmy klasyczny model z zamówieniami i klientami. Każde zamówienie należy do klienta, a relacja ta domyślnie używa leniwego ładowania.
@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
}Gdy zapytanie pobiera zamówienia, a następnie uzyskuje dostęp do nazwy klienta, Hibernate wykonuje dodatkowe zapytanie dla każdego zamówienia.
@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();
}
}Dla 100 zamówień ten kod wykonuje 101 zapytań SQL. Logi Hibernate ujawniają ten destrukcyjny wzorzec.
-- 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 queriesWykrywanie Problemu N+1 za pomocą Logów Hibernate
Pierwszym krokiem jest włączenie logów SQL w celu zidentyfikowania problematycznych zapytań. Poniższa konfiguracja wyświetla każde zapytanie wykonane przez 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: TRACEStatystyki Hibernate dostarczają cennego podsumowania na końcu każdej transakcji.
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;
}Liczba 101 instrukcji JDBC dla prostej listy zamówień jednoznacznie sygnalizuje problem N+1.
Logi SQL i statystyki wpływają na wydajność. Te opcje powinny pozostać wyłączone w produkcji i być zarezerwowane dla środowisk deweloperskich i testowych.
Rozwiązanie 1: Fetch Join z JPQL
Fetch join ładuje powiązania w jednym zapytaniu SQL poprzez join. To jawne podejście rozwiązuje problem N+1, pobierając wszystkie potrzebne dane za jednym razem.
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 przekształca N+1 zapytań w jedno zoptymalizowane zapytanie.
-- 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.idUsługa korzysta teraz ze zoptymalizowanej metody bez modyfikacji kodu biznesowego.
@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();
}
}Gotowy na rozmowy o Spring Boot?
Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.
Rozwiązanie 2: @EntityGraph dla Deklaratywnej Kontroli
Adnotacja @EntityGraph oferuje deklaratywną alternatywę dla fetch join. Definiuje, które powiązania mają być ładowane, bez konieczności pisania niestandardowego 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);
}Nazwane EntityGraph są definiowane bezpośrednio na encji w celu ponownego wykorzystania w wielu repozytoriach.
@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 umożliwia ładowanie zagnieżdżonych powiązań. Powyższy przykład ładuje zamówienia, ich pozycje i produkt każdej pozycji w jednym zapytaniu.
Rozwiązanie 3: Batch Fetching dla Kolekcji
Batch fetching oferuje alternatywę dla fetch join w przypadku kolekcji @OneToMany. Zamiast ładować każdą kolekcję indywidualnie, Hibernate grupuje zapytania w paczki.
@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<>();
}Dzięki @BatchSize(size = 25) Hibernate ładuje pozycje dla 25 zamówień jednocześnie zamiast 1. Dla 100 zamówień liczba zapytań spada ze 101 do 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)Globalna konfiguracja rozmiaru paczki dotyczy wszystkich kolekcji w aplikacji.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch fetching nadaje się do przypadków, w których fetch join generuje zbyt duży iloczyn kartezjański. Dla zamówienia z 10 pozycjami i 5 płatnościami fetch join zwraca 50 wierszy. Batch fetching wykonuje 2 oddzielne, bardziej wydajne zapytania.
Porównanie Strategii Ładowania
Każda strategia oferuje korzyści w zależności od kontekstu użycia. Poniższa tabela podsumowuje optymalne przypadki użycia.
| Strategia | Przypadek Użycia | Zalety | Wady |
|----------|----------|------------|---------------|
| Fetch Join | Relacje @ManyToOne | Pojedyncze zapytanie SQL | Iloczyn kartezjański z kolekcjami |
| @EntityGraph | Deklaratywne ładowanie | Reużywalne, czytelne | Mniej elastyczne niż JPQL |
| Batch Fetching | Kolekcje @OneToMany | Unika iloczynu kartezjańskiego | Wiele zapytań |
| Subselect | Rzadko używane kolekcje | Ładuje tylko gdy potrzebne | Skorelowane podzapytanie |
Strategia subselect ładuje całą kolekcję przy pierwszym dostępie do dowolnego elementu.
@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 > ?)Unikanie N+1 dzięki Projekcjom DTO
Projekcje DTO oferują radykalne, ale skuteczne podejście. Wybierając tylko niezbędne kolumny, projekcje całkowicie unikają ładowania encji i ich powiązań.
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
);
}To podejście generuje optymalne zapytanie SQL bez narzutu mapowania encji.
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 > ?Zaawansowana Konfiguracja ze Spring Data JPA
Spring Data JPA 3.x wprowadza ulepszenia w zarządzaniu EntityGraph i zoptymalizowanymi zapytaniami.
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
);
}Warunkowe ładowanie pozwala stosować różne strategie w zależności od kontekstu.
@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");
}
}Zacznij ćwiczyć!
Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.
Testy Wydajności do Wykrywania N+1
Zautomatyzowane testy weryfikują brak problemów N+1 przez liczenie wykonanych zapytań 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();
}
}Asercja na getQueryExecutionCount() gwarantuje, że optymalizacje zostaną zachowane podczas ewolucji kodu.
Podsumowanie
Problem N+1 stanowi poważne wyzwanie wydajnościowe w Spring Data JPA, ale istnieje kilka skutecznych rozwiązań. Fetch join i @EntityGraph rozwiązują większość przypadków, ładując powiązania w jednym zapytaniu. Batch fetching oferuje alternatywę dla dużych kolekcji, w których fetch join generuje iloczyn kartezjański.
Lista Kontrolna Zapobiegania N+1:
- ✅ Włączyć logi SQL w trybie deweloperskim, aby wykrywać wielokrotne zapytania
- ✅ Używać
JOIN FETCHdla często używanych relacji@ManyToOne - ✅ Stosować
@EntityGraphdla reużywalnego deklaratywnego ładowania - ✅ Konfigurować
@BatchSizedla dużych kolekcji@OneToMany - ✅ Preferować projekcje DTO dla operacji tylko do odczytu
- ✅ Pisać testy weryfikujące liczbę wykonanych zapytań
- ✅ Wyłączać logi SQL i statystyki w produkcji
- ✅ Regularnie profilować krytyczne endpointy z metrykami Hibernate
Tagi
Udostępnij
Powiązane artykuły

Rozmowa kwalifikacyjna Spring GraphQL: Resolvery, DataLoadery i Rozwiązania problemu N+1
Przygotowanie do rozmów kwalifikacyjnych Spring GraphQL z tym kompletnym przewodnikiem. Resolvery, DataLoadery, obsługa problemu N+1, mutacje i najlepsze praktyki dla pytań technicznych.

Spring Boot 3.4 Virtual Threads: Pytania Rekrutacyjne i Benchmarki Wydajności
Opanuj Virtual Threads z Java 21 i Spring Boot 3.4: 15 pytań rekrutacyjnych, benchmarki wydajności oraz wzorce migracji do zaliczenia rozmów technicznych.

Logowanie w Spring Boot 2026: logi strukturalne na produkcji z Logback i JSON
Kompletny przewodnik po logowaniu strukturalnym w Spring Boot. Konfiguracja Logback JSON, MDC do tracingu, najlepsze praktyki produkcyjne i integracja z ELK Stack.