Optimización del problema N+1

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-jpa-optimizacion-n-mas-1

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 warning HHH000104: 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 FETCH a colecciones distintas lanzan MultipleBagFetchException o producen producto cartesiano.
  • Hay que añadir select distinct d si la BD devuelve duplicados (un departamento por cada empleado), o configurar query.applyDistinct(true) en Query.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: 25 en application.yml. Red de seguridad universal.
  • Usar @EntityGraph en métodos del repositorio cuando se sabe que se va a iterar la colección.
  • @BatchSize específico en entidades concretas que requieren un valor distinto al global.
  • SUBSELECT solo en casos puntuales donde se mide que es mejor.
  • JOIN FETCH solo dentro de @Query ya 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: 25 en application.yml siempre. Red de seguridad universal.
  • @EntityGraph(attributePaths = ...) en métodos del repositorio que iteran asociaciones.
  • Test con datasource-proxy para cada endpoint que devuelve listados con asociaciones. Si reaparece N+1, falla la build.
  • Asociaciones LAZY siempre. EAGER es 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 @OneToMany masiva (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_size como red de seguridad, @EntityGraph explícito en endpoints críticos, tests automatizados con datasource-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 - Autor del tutorial

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.