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.

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.
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.
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.
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.
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 3.x introduce mejoras para la gestión de EntityGraph y consultas optimizadas.
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.
@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 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
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.

Logging en Spring Boot 2026: logs estructurados en producción con Logback y JSON
Guía completa sobre logs estructurados en Spring Boot. Configuración Logback JSON, MDC para tracing, mejores prácticas en producción e integración con ELK Stack.