
Qué es el problema N+1
Una entidad Departamento con una colección LAZY de Empleado:
@Entity
public class Departamento {
@Id @GeneratedValue
private Long id;
private String nombre;
@OneToMany(mappedBy = "departamento", fetch = FetchType.LAZY)
private List<Empleado> empleados = new ArrayList<>();
}
Y un endpoint que lista los departamentos con su número de empleados:
@GetMapping
public List<DepartamentoDto> listar() {
return repository.findAll().stream()
.map(d -> new DepartamentoDto(
d.getId(),
d.getNombre(),
d.getEmpleados().size())) // <- aquí salta la trampa
.toList();
}
Hibernate ejecuta:
-- Query 1: findAll
select d.id, d.nombre from departamento d;
-- Query 2..N+1: una por cada departamento, al llamar .getEmpleados().size()
select e.id, e.nombre from empleado e where e.departamento_id = ?;
select e.id, e.nombre from empleado e where e.departamento_id = ?;
select e.id, e.nombre from empleado e where e.departamento_id = ?;
-- ... una por departamento
Con 50 departamentos: 51 queries. Con 500: 501. La latencia crece linealmente, la BD se satura, el endpoint deja de escalar. Es el bug de rendimiento más común en aplicaciones JPA.
N+1 no aparece en desarrollo (10 registros) ni en testing (100 registros). Aparece en producción (10.000 registros). Por eso hay que detectarlo automáticamente desde los tests, no esperar a verlo en métricas APM.
Detectarlo: tres técnicas
1. Logs SQL en DEBUG
La forma más rápida en local. En application-dev.yml:
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
Al ejecutar el endpoint, la consola muestra cada query. Ver la misma select e from empleado repetida es la señal inequívoca de N+1. La desventaja: requiere ojo humano y no detecta regresiones automáticamente.
2. datasource-proxy con asserts en tests
datasource-proxy envuelve el DataSource y permite contar queries en tests:
<dependency>
<groupId>net.ttddyy</groupId>
<artifactId>datasource-proxy</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@AutoConfigureTestDatabase
class DepartamentoServiceN1Test {
@Autowired DepartamentoService service;
@Test
void listarNoDisparaNMas1() {
SQLStatementCountValidator.reset();
var resultado = service.listarConContadores();
SQLStatementCountValidator.assertSelectCount(1); // sólo 1 query, no N+1
}
}
Hay variantes (assertSelectCount, assertInsertCount, assertUpdateCount). Cualquier cambio futuro que reintroduzca N+1 hace fallar el test en CI. Es la única detección sostenible a largo plazo.
3. Hibernate Statistics
Hibernate.getStatistics() expone contadores acumulados sobre el SessionFactory:
@Test
void verificarQueriesEjecutadas() {
var sessionFactory = em.getEntityManagerFactory().unwrap(SessionFactory.class);
var stats = sessionFactory.getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
service.listar();
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
assertThat(stats.getEntityLoadCount()).isEqualTo(50); // todos los departamentos
}
Más detallado que datasource-proxy (separa loads de fetches de updates), pero requiere acceso al SessionFactory. Útil cuando el problema es sutil o cuando se quiere diferenciar entre cargar entidad y cargar colección.
graph TD
A[Detección N+1] --> B[Logs SQL DEBUG<br/>desarrollo manual]
A --> C[datasource-proxy<br/>asserts en tests]
A --> D[Hibernate Statistics<br/>contadores finos]
B --> E[Útil en exploración local]
C --> F[Recomendado para CI<br/>previene regresiones]
D --> G[Útil cuando el problema es sutil]
Solución 1: JOIN FETCH en JPQL
Cuando la query no se pagina y solo hay una asociación a traer, JOIN FETCH es directo:
@Query("""
select d from Departamento d
left join fetch d.empleados
""")
List<Departamento> findAllConEmpleados();
Una sola query con LEFT JOIN. Resuelve N+1 totalmente.
Limitaciones:
- No combina con
Pageable. Hibernate emite el warningHHH000104: firstResult/maxResults specified with collection fetch; applying in memory!y pagina en memoria, lo que es peor que N+1 (carga toda la tabla). - Solo una colección por query. Dos
JOIN FETCHa colecciones distintas lanzanMultipleBagFetchExceptiono producen producto cartesiano. - Hay que añadir
select distinct dsi la BD devuelve duplicados (un departamento por cada empleado), o configurarquery.applyDistinct(true)enQuery.unwrap(org.hibernate.query.Query.class).
Solución 2: @BatchSize en colecciones
@BatchSize indica a Hibernate que cuando tenga que resolver una colección LAZY, lo haga en lotes en lugar de uno por uno:
@Entity
public class Departamento {
@OneToMany(mappedBy = "departamento", fetch = FetchType.LAZY)
@BatchSize(size = 25)
private List<Empleado> empleados = new ArrayList<>();
}
Con 100 departamentos y @BatchSize(size = 25), Hibernate ejecuta:
-- 1 query: findAll
select d.id, d.nombre from departamento d;
-- 4 queries: una por cada lote de 25 departamentos al resolver empleados
select e.id, e.nombre, e.departamento_id from empleado e
where e.departamento_id in (?, ?, ?, ..., ?); -- 25 IDs
Total: 5 queries en lugar de 101. La aplicación es robusta a N+1 sin cambiar el código que itera. El secreto está en la entidad, no en el método.
@BatchSize también vale en @ManyToOne para resolver la asociación al padre:
@Entity
public class Empleado {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 25)
private Departamento departamento;
}
Solución 3: hibernate.default_batch_fetch_size global
Aplicar @BatchSize entidad por entidad es disciplinado pero olvidable. La opción global es configurar el batch fetch size por defecto:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25
batch_fetch_style: PADDED
Con esto, todas las asociaciones LAZY se resuelven en lotes de 25, aunque la entidad no tenga @BatchSize. Es la mejor red de seguridad: cualquier N+1 olvidado queda mitigado a N/25.
batch_fetch_style: PADDED es el valor recomendado en Hibernate 7: rellena los IN con valores duplicados para que el plan de la query se cachee. Las alternativas (LEGACY, DYNAMIC) tienen casos específicos.
En proyectos Spring Boot 4 modernos, siempre activar
default_batch_fetch_size: 25(o un valor entre 10 y 100 según el dominio). Es una protección global con coste casi nulo.
Solución 4: FetchMode.SUBSELECT
Para colecciones donde se carga toda la lista de padres y luego se itera:
@Entity
public class Departamento {
@OneToMany(mappedBy = "departamento", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<Empleado> empleados = new ArrayList<>();
}
Hibernate emite una sola query para resolver todas las colecciones, usando una subquery con los IDs:
-- 1 query: findAll
select d.id, d.nombre from departamento d;
-- 1 query: empleados de TODOS los departamentos
select e.id, e.nombre, e.departamento_id from empleado e
where e.departamento_id in (select d.id from departamento d);
Total: 2 queries, independientemente de cuántos padres haya. Útil cuando:
- Se cargan todos los padres de una entidad (sin filtros) y se necesitan sus colecciones.
- La colección es grande pero solo se itera una vez.
No usar cuando se cargan pocos padres específicos: la subquery se vuelve cara y tiende a leer demasiados hijos.
Solución 5: Entity Graphs (referencia a la lección anterior)
La lección Entity Graphs cubre esta técnica en detalle. La síntesis:
public interface DepartamentoRepository extends JpaRepository<Departamento, Long> {
@EntityGraph(attributePaths = {"empleados"})
List<Departamento> findAll();
@EntityGraph(attributePaths = {"empleados"})
Page<Departamento> findByActivoTrue(Pageable pageable);
}
Igual que JOIN FETCH en SQL emitido, pero funciona con paginación (Spring Data hace count + data). Es la solución recomendada cuando hay una asociación a traer y se necesita Pageable.
Tabla comparativa
| Técnica | Mejor caso de uso | Funciona con Pageable | Múltiples colecciones | Coste de configuración |
|---------|-------------------|--------------------------|------------------------|------------------------|
| JOIN FETCH JPQL | Query fija, sin paginar, una asociación | No | No | Bajo (@Query) |
| @BatchSize por entidad | Colección concreta sabida problemática | Sí | Sí | Bajo (anotación) |
| default_batch_fetch_size | Protección global | Sí | Sí | Bajísimo (config) |
| FetchMode.SUBSELECT | Carga total de padres + colección grande | Sí | Sí | Bajo (anotación) |
| Entity Graph | Listados paginados con asociaciones | Sí | No (mismo límite) | Bajo (anotación) |
Receta por defecto en proyectos modernos:
- Activar
default_batch_fetch_size: 25enapplication.yml. Red de seguridad universal. - Usar
@EntityGraphen métodos del repositorio cuando se sabe que se va a iterar la colección. @BatchSizeespecífico en entidades concretas que requieren un valor distinto al global.SUBSELECTsolo en casos puntuales donde se mide que es mejor.JOIN FETCHsolo dentro de@Queryya existentes.
Caso completo paso a paso
Endpoint que devuelve los 100 departamentos más recientes con su número de empleados y los nombres de los proyectos en los que participa cada empleado.
Paso 1: implementación ingenua (con N+1 múltiple)
@Service
@RequiredArgsConstructor
public class DepartamentoService {
private final DepartamentoRepository repository;
public List<DepartamentoDetalladoDto> listarConDetalle() {
return repository.findTop100ByOrderByFechaCreacionDesc().stream()
.map(d -> new DepartamentoDetalladoDto(
d.getId(),
d.getNombre(),
d.getEmpleados().stream()
.map(e -> new EmpleadoDto(
e.getId(),
e.getNombre(),
e.getProyectos().stream()
.map(Proyecto::getNombre)
.toList()))
.toList()))
.toList();
}
}
Queries emitidas con 100 departamentos y 5 empleados por departamento:
- 1 query:
findTop100 - 100 queries: una por departamento, al resolver
getEmpleados() - 500 queries: una por empleado, al resolver
getProyectos()
Total: 601 queries. Latencia inaceptable.
Paso 2: añadir test que detecte N+1
@SpringBootTest
class DepartamentoServiceN1Test {
@Autowired DepartamentoService service;
@BeforeEach void seedData() { /* 100 departamentos, 5 empleados c/u, 3 proyectos c/u */ }
@Test
void listarConDetalleNoDisparaNMas1() {
SQLStatementCountValidator.reset();
service.listarConDetalle();
// Esperamos máximo 5 queries (incluso siendo generosos)
SQLStatementCountValidator.assertSelectCount(5);
}
}
El test falla con 601 queries. La build no pasa.
Paso 3: configuración global
application.yml:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25
batch_fetch_style: PADDED
Tras este cambio:
- 1 query:
findTop100 - 4 queries: empleados de los 100 departamentos en lotes de 25
- 20 queries: proyectos de los 500 empleados en lotes de 25
Total: 25 queries. Mejor, pero todavía no óptimo.
Paso 4: Entity Graph en el repositorio
public interface DepartamentoRepository extends JpaRepository<Departamento, Long> {
@EntityGraph(attributePaths = {"empleados", "empleados.proyectos"})
List<Departamento> findTop100ByOrderByFechaCreacionDesc();
}
Hibernate emite una sola query con dos LEFT JOIN. Total: 1 query.
Aquí aparece el riesgo: dos colecciones LAZY (empleados y proyectos) en el mismo grafo. Si ambas son List, salta MultipleBagFetchException. Solución: usar Set en una de las dos:
@Entity
public class Empleado {
@ManyToMany
@JoinTable(name = "empleado_proyecto")
private Set<Proyecto> proyectos = new HashSet<>();
}
Paso 5: verificar el test
@Test
void listarConDetalleNoDisparaNMas1() {
SQLStatementCountValidator.reset();
service.listarConDetalle();
SQLStatementCountValidator.assertSelectCount(1); // pasa
}
El test pasa. Cualquier cambio futuro que reintroduzca N+1 hará fallar la build.
Anti-patrones
Marcar todo EAGER para evitar N+1
@OneToMany(mappedBy = "departamento", fetch = FetchType.EAGER)
private List<Empleado> empleados; // PELIGRO
Esto carga empleados en todas las queries de Departamento, incluso cuando solo se necesita el nombre. Multiplica la presión de memoria y el tráfico de BD para los casos donde no hace falta. La regla es LAZY siempre, y decidir la carga ansiosa por query.
Usar JOIN FETCH con Pageable
@Query("select d from Departamento d left join fetch d.empleados")
Page<Departamento> findAll(Pageable pageable); // ROTO
Hibernate emite el warning HHH000104 y pagina en memoria: trae toda la tabla y descarta el resto. La aplicación parece funcionar en local con 50 registros y se cae con 500.000. Usa @EntityGraph en su lugar.
Confiar solo en monitoring de producción
Detectar N+1 en producción por queries lentas o métricas APM funciona, pero significa que el bug ya llegó al cliente. La detección debe estar en el suite de tests con datasource-proxy. CI lo bloquea antes de mergear.
Activar default_batch_fetch_size y olvidarse
default_batch_fetch_size = 25 reduce 101 queries a 5, pero 5 queries siguen siendo más que 1. Es una red de seguridad, no una solución óptima. Para los endpoints críticos, sigue mereciendo la pena un Entity Graph explícito.
Confundir N+1 con queries lentas
Una query lenta de 500 ms no es N+1: es falta de índice o JOIN mal escrito. N+1 son muchas queries rápidas. La solución (Entity Graph, BatchSize) no arregla queries lentas: para eso hay que mirar EXPLAIN ANALYZE y diseño de índices.
Buenas prácticas
default_batch_fetch_size: 25enapplication.ymlsiempre. Red de seguridad universal.@EntityGraph(attributePaths = ...)en métodos del repositorio que iteran asociaciones.- Test con
datasource-proxypara cada endpoint que devuelve listados con asociaciones. Si reaparece N+1, falla la build. - Asociaciones LAZY siempre.
EAGERes un olor de diseño en proyectos serios. - Logs SQL en DEBUG en local durante desarrollo activo. Aprende a leer el SQL emitido.
- Métricas en producción (APM, Hibernate Statistics expuesto en Actuator) como red final.
- Diseño de la entidad primero. Una
@OneToManymasiva (10.000 hijos) no se resuelve con N+1: se resuelve con paginación específica de los hijos.
N+1 es la suma de muchos descuidos pequeños. La fórmula que lo mantiene a raya en proyectos Spring Boot 4 maduros:
default_batch_fetch_sizecomo red de seguridad,@EntityGraphexplícito en endpoints críticos, tests automatizados condatasource-proxy. Las tres piezas combinadas reducen las queries a un orden de magnitud aceptable y previenen regresiones futuras. Detectar N+1 ya en producción es síntoma de que falta una de las tres.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Detectar N+1 con tres técnicas distintas (logs, datasource-proxy y Statistics). Resolver N+1 con JOIN FETCH cuando hay una sola asociación. Aplicar @BatchSize en colecciones para reducir queries de N+1 a N/batchSize. Configurar hibernate.default_batch_fetch_size global. Decidir entre las cinco soluciones según el caso. Diseñar tests automatizados que fallen si reaparece N+1.