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.

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.
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.
@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
}Cuando una consulta recupera pedidos y luego accede al nombre del cliente, Hibernate ejecuta una consulta adicional por cada pedido.
@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.
-- 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 queriesDetectar 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.
# 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: TRACELas estadísticas de Hibernate proporcionan un resumen valioso al final de cada transacción.
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.
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.
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.
-- 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.idEl servicio ahora utiliza el método optimizado sin modificar el código de negocio.
@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.
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.
@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:
@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.
@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.
-- 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.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch 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.
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:
-- 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.
| 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.
@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 > ?)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.
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
);
}Este enfoque genera una consulta SQL óptima sin la sobrecarga del mapeo de entidades.
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.
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.
@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.
@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
- Hibernate 7.4 What's New: la paginación con fetch joins ahora es segura a nivel de base de datos
- Notas de lanzamiento de Spring Boot 4.1: incluye Hibernate 7.4.5.Final
- Documentación de Hibernate ORM: referencia oficial para estrategias de carga y EntityGraph
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 FETCHpara relaciones@ManyToOneaccedidas con frecuencia - Aplicar
@EntityGraphpara carga declarativa reutilizable - Configurar
@BatchSizepara colecciones@OneToManygrandes - 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
¿Sabrías detectar el bug en Spring Boot?
Un fragmento real, un bug oculto, un intento al día. Sin cuenta para probar.

Escrito por
Anthony Fillion-MailletFundador 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
Compartir
Artículos relacionados

Entrevista Spring GraphQL: Resolvers, DataLoaders y Soluciones al Problema N+1
Prepárate para entrevistas Spring GraphQL con esta guía completa. Resolvers, DataLoaders, gestión del problema N+1, mutaciones y mejores prácticas para preguntas técnicas.

Spring Boot 3.4 Virtual Threads: Preguntas de Entrevista y Benchmarks de Rendimiento
Domina los Virtual Threads de Java 21 con Spring Boot 3.4: 15 preguntas de entrevista, benchmarks de rendimiento y patrones de migración para superar las entrevistas técnicas.

Observabilidad en Spring Boot 2026: OpenTelemetry, Tracing Distribuido y Preguntas de Entrevista
Guía completa sobre observabilidad en Spring Boot con OpenTelemetry y tracing distribuido. Configuración OTLP, Observation API, propagación de contexto y preguntas técnicas para entrevistas backend.