Soluciones N+1 en Spring Data JPA: Fetch Join, EntityGraph y Hibernate 7.4

Guía completa para detectar y corregir el problema N+1 en Spring Data JPA. Fetch join, @EntityGraph, batch fetching y mejoras de paginación en Hibernate 7.4.

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. Para una visión más amplia de las mejores prácticas de JPA, consulte el módulo de conceptos básicos de 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);
}

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.

Hibernate 7 introduce una sintaxis basada en texto simplificada para @NamedEntityGraph que reduce la verbosidad de las anotaciones:

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
}

Esta definición de una sola línea reemplaza las anotaciones anidadas @NamedAttributeNode y @NamedSubgraph.

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.

Paginación con Fetch Join en Hibernate 7.4

Una limitación histórica de los fetch joins era su incompatibilidad con la paginación. Combinar setMaxResults() con fetch joins en colecciones forzaba a Hibernate a cargar todas las filas coincidentes, para luego aplicar los límites en memoria. La advertencia firstResult/maxResults specified with collection fetch; applying in memory indicaba este problema de rendimiento.

Hibernate 7.4 resuelve este problema. La paginación con fetch joins en colecciones ahora funciona a nivel de base de datos utilizando consultas anidadas. Hibernate primero determina el conjunto limitado de identificadores de la entidad padre, y luego recupera las colecciones asociadas solo para esas filas padre.

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

El SQL generado ahora utiliza una subconsulta para paginar a nivel de base de datos:

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
)

Esta mejora reduce drásticamente el uso de memoria para endpoints paginados que muestran relaciones padre-hijo. Spring Boot 4.1 incluye Hibernate 7.4.5, haciendo que esta optimización esté disponible por defecto.

Para aplicaciones que aún usan versiones anteriores de Hibernate, el patrón de recuperación en dos fases sigue siendo el enfoque más seguro: primero recuperar los IDs padre con paginación, y luego cargar las asociaciones para esos IDs específicos.

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.

EstrategiaCaso de UsoVentajasDesventajas
Fetch JoinRelaciones @ManyToOneConsulta SQL únicaProducto cartesiano con colecciones
@EntityGraphCarga declarativaReutilizable, legibleMenos flexible que JPQL
Batch FetchingColecciones @OneToManyEvita el producto cartesianoMúltiples consultas
SubselectColecciones poco accedidasCarga solo cuando se necesitaSubconsulta 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. Este patrón es particularmente relevante para endpoints de solo lectura, como se detalla en el módulo de consultas 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
    );
}

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 en Spring Boot 4.x proporciona una gestión mejorada de EntityGraphs y optimización de consultas. Comprender la propagación de transacciones se vuelve esencial al combinar estas estrategias de carga con lógica de negocio compleja.

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. Para estrategias de prueba completas, consulte la guía de Testcontainers para 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();
    }
}

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

Fuentes

Lista de Verificación Anti-N+1 para Spring Data JPA

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. Con Hibernate 7.4, la paginación con fetch joins finalmente funciona a nivel de base de datos.

Qué aplicar en producción:

  • 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
  • Actualizar a Spring Boot 4.1+ para aprovechar las correcciones de paginación de Hibernate 7.4
Reto diario

¿Sabrías detectar el bug en Spring Boot?

Un fragmento real, un bug oculto, un intento al día. Sin cuenta para probar.

Anthony Fillion-Maillet

Escrito por

Anthony Fillion-Maillet

Fundador de SharpSkill

Desarrollador fullstack desde hace más de 10 años. Dirige SharpSkill y responde por todo lo que se publica aquí.

Actualizado el 2 de septiembre de 2026

Etiquetas

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

Compartir

Artículos relacionados