Soluciones N+1 en Spring Data JPA en 2026: Fetch Join y EntityGraph

Guía completa para detectar y corregir el problema N+1 en Spring Data JPA. Fetch join, @EntityGraph, batch fetching y estrategias de rendimiento de consultas.

Resolver el problema N+1 con Spring Data JPA usando fetch join y EntityGraph

El problema N+1 representa una de las trampas de rendimiento más comunes en JPA. Una consulta inocente para recuperar 100 pedidos puede desencadenar 101 consultas SQL: una para los pedidos, y luego una por cada cliente asociado. Esta multiplicación silenciosa de consultas degrada el rendimiento y sobrecarga la base de datos.

Impacto Real del N+1

Un endpoint que devuelve 50 artículos con sus autores puede pasar de 10ms a 500ms debido al N+1. Una detección temprana evita problemas críticos en producción.

Comprender el Problema N+1 en JPA

El problema N+1 ocurre cuando JPA carga una colección de entidades y luego ejecuta una consulta adicional por cada entidad para cargar sus asociaciones. Este comportamiento proviene de la carga perezosa por defecto de las relaciones @OneToMany y @ManyToMany.

Considere un modelo clásico con pedidos y clientes. Cada pedido pertenece a un cliente, y esta relación tiene carga perezosa por defecto.

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
}

Cuando una consulta recupera pedidos y luego accede al nombre del cliente, Hibernate ejecuta una consulta adicional por cada pedido.

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();
    }
}

Para 100 pedidos, este código ejecuta 101 consultas SQL. Los logs de Hibernate revelan este patrón destructivo.

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

Detectar el Problema N+1 con Logs de Hibernate

El primer paso consiste en activar los logs SQL para identificar las consultas problemáticas. La siguiente configuración muestra cada consulta ejecutada por 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

Las estadísticas de Hibernate proporcionan un resumen valioso al final de cada transacción.

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;
}

El recuento de 101 sentencias JDBC para una simple lista de pedidos señala claramente un problema N+1.

Desactivar en Producción

Los logs SQL y las estadísticas afectan al rendimiento. Estas opciones deben permanecer desactivadas en producción y reservadas para entornos de desarrollo y pruebas.

Solución 1: Fetch Join con JPQL

Fetch join carga las asociaciones en una sola consulta SQL mediante un join. Este enfoque explícito resuelve el N+1 recuperando todos los datos necesarios de una vez.

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 transforma N+1 consultas en una única consulta optimizada.

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

El servicio ahora utiliza el método optimizado sin modificar el código de negocio.

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();
    }
}

¿Listo para aprobar tus entrevistas de Spring Boot?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Solución 2: @EntityGraph para Control Declarativo

La anotación @EntityGraph ofrece una alternativa declarativa a fetch join. Define qué asociaciones cargar sin escribir JPQL personalizado.

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);
}

Los EntityGraph nombrados se definen directamente en la entidad para reutilizarse en varios repositorios.

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
}

El subgraph permite cargar asociaciones anidadas. El ejemplo anterior carga los pedidos, sus ítems y el producto de cada ítem en una sola consulta.

Solución 3: Batch Fetching para Colecciones

Batch fetching ofrece una alternativa a fetch join para colecciones @OneToMany. En lugar de cargar cada colección individualmente, Hibernate agrupa las consultas en lotes.

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<>();
}

Con @BatchSize(size = 25), Hibernate carga los ítems de 25 pedidos a la vez en lugar de 1. Para 100 pedidos, el número de consultas baja de 101 a 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)

La configuración global del tamaño de lote se aplica a todas las colecciones de la aplicación.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch vs Fetch Join

Batch fetching conviene en casos donde fetch join genera un producto cartesiano demasiado grande. Para un pedido con 10 ítems y 5 pagos, fetch join devuelve 50 filas. Batch fetching ejecuta 2 consultas separadas más eficientes.

Comparación de Estrategias de Carga

Cada estrategia ofrece ventajas según el contexto de uso. La siguiente tabla resume los casos de uso óptimos.

| Estrategia | Caso de Uso | Ventajas | Desventajas | |----------|----------|------------|---------------| | Fetch Join | Relaciones @ManyToOne | Consulta SQL única | Producto cartesiano con colecciones | | @EntityGraph | Carga declarativa | Reutilizable, legible | Menos flexible que JPQL | | Batch Fetching | Colecciones @OneToMany | Evita el producto cartesiano | Múltiples consultas | | Subselect | Colecciones poco accedidas | Carga solo cuando se necesita | Subconsulta correlacionada |

La estrategia subselect carga toda la colección en el primer acceso a cualquier elemento.

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

Evitar el N+1 con Proyecciones DTO

Las proyecciones DTO ofrecen un enfoque radical pero efectivo. Al seleccionar solo las columnas necesarias, las proyecciones evitan por completo la carga de entidades y sus asociaciones.

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
    );
}

Este enfoque genera una consulta SQL óptima sin la sobrecarga del mapeo de entidades.

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

Configuración Avanzada con Spring Data JPA

Spring Data JPA 3.x introduce mejoras para la gestión de EntityGraph y consultas optimizadas.

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
    );
}

La carga condicional permite aplicar diferentes estrategias según el contexto.

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");
    }
}

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Pruebas de Rendimiento para Detectar N+1

Las pruebas automatizadas verifican la ausencia de problemas N+1 contando las consultas SQL ejecutadas.

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();
    }
}

La aserción sobre getQueryExecutionCount() garantiza que las optimizaciones se mantengan durante la evolución del código.

Conclusión

El problema N+1 representa un desafío de rendimiento importante en Spring Data JPA, pero existen varias soluciones efectivas. Fetch join y @EntityGraph resuelven la mayoría de los casos cargando las asociaciones en una sola consulta. Batch fetching ofrece una alternativa para colecciones grandes donde fetch join genera un producto cartesiano.

Lista de Verificación para Prevenir N+1:

  • ✅ Activar los logs SQL en desarrollo para detectar consultas múltiples
  • ✅ Usar JOIN FETCH para relaciones @ManyToOne accedidas con frecuencia
  • ✅ Aplicar @EntityGraph para carga declarativa reutilizable
  • ✅ Configurar @BatchSize para colecciones @OneToMany grandes
  • ✅ Preferir proyecciones DTO para operaciones de solo lectura
  • ✅ Escribir pruebas que verifiquen el número de consultas ejecutadas
  • ✅ Desactivar logs SQL y estadísticas en producción
  • ✅ Perfilar regularmente los endpoints críticos con métricas de Hibernate

Etiquetas

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

Compartir

Artículos relacionados