← Persistencia con JPA y PostgreSQL

Sesión 26 · Semana 13

Consultas, N+1 y versión persistente desplegada

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado priorizar la evolución del mismo producto. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

La persistencia ya mantiene la integridad. Hoy revisarás cuántas consultas necesita una respuesta. N+1 describe una consulta inicial seguida de otras por cada elemento; paginar limita cuántos resultados devuelves. Trabajarás con datos suficientes para observar ambos problemas.

La trampa silenciosa: el problema N+1 al microscopio

El problema N+1 es, sin discusión, el fallo de rendimiento más extendido en el desarrollo de backends con ORM. Lo más peligroso es que el código parece totalmente inocente y la aplicación funciona en apariencia a la perfección.

Observa este método de tu servicio:

@Transactional(readOnly = true)
public List<TareaResponse> listarTodas() {
    // Consulta 1: Traer todas las tareas
    List<Tarea> tareas = tareaRepo.findAll();

    // Mapear cada tarea a su DTO de respuesta
    return tareas.stream()
            .map(t -> new TareaResponse(
                    t.getId(),
                    t.getTitulo(),
                    t.getPrioridad(),
                    t.isCompletada(),
                    t.getProyecto().getId(),
                    t.getProyecto().getNombre() // ¡AQUÍ OCURRE EL DESASTRE!
            ))
            .toList();
}

Analicemos qué ocurre en PostgreSQL cuando hay 100 tareas en la base de datos:

La avalancha del problema N+1
  1. 1 SELECT tareas
  2. 100 SELECT proyectos (1 por fila)
  3. 101 viajes TCP
  4. Colapso de red y pool
  1. tareaRepo.findAll() ejecuta 1 consulta:
    SELECT * FROM tareas;
  2. Al iterar el stream, llegamos a t.getProyecto().getNombre(). Como configuramos FetchType.LAZY (lo correcto para no cargar proyectos a ciegas), t.getProyecto() es un Proxy vacío.
  3. Al pedirle .getNombre(), el Proxy se ve obligado a viajar a PostgreSQL para traer los datos:
    SELECT * FROM proyectos WHERE id = 1;
  4. En la segunda tarea, vuelve a disparar:
    SELECT * FROM proyectos WHERE id = 2;
  5. …y así sucesivamente hasta completar las 100 tareas.

Total: 1 consulta inicial + 100 consultas secundarias = 101 consultas SQL para responder a un único cliente HTTP.

Por qué en desarrollo no te enteras

En tu ordenador de desarrollo tienes tres tareas y dos proyectos de prueba. Tres consultas tardan 0,5 milisegundos en localhost. El navegador carga al instante y crees que tu código vuela.

En producción, la aplicación y la base de datos están en servidores o contenedores separados por una red física. Aunque la red sea rápida, cada consulta introduce una pequeña latencia de ida y vuelta (Round Trip Time):

  • 100 consultas × 3 ms de latencia = 300 milisegundos de espera pura de red.
  • 1.000 tareas en una tabla real = ¡3 segundos enteros bloqueando la conexión!
  • Si diez usuarios hacen la misma petición a la vez, se satura el pool de conexiones de HikariCP y la API entera deja de responder (error 503 Service Unavailable).

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre los listados con relaciones, sus consultas de repositorio y los DTO. Activa la visualización de SQL solo en desarrollo.
  2. Prepara varios registros con relaciones y anota cuántas consultas produce un listado antes de optimizarlo.
  3. Localiza la configuración externa utilizada en Intermodular para el backend y PostgreSQL. El código persistente revisado será la versión que recorra ese workflow.

Paso 2 · Cargar las relaciones necesarias mediante JPQL y JOIN FETCH

Añade la consulta a TareaRepository e importa Query de Spring Data JPA. No basta con declarar un método que nadie llama: cambia el listado del servicio para utilizarlo y ejecuta la misma petición y los mismos datos antes y después. Cuenta las consultas SQL correspondientes solo a ese envío. Elige JOIN FETCH si el proyecto es obligatorio y LEFT JOIN FETCH si quieres conservar tareas sin relación.

Para lograrlo, utilizamos la cláusula JOIN FETCH en una consulta JPQL personalizada con la anotación @Query:

package com.ejemplo.gestor.repository;

import com.ejemplo.gestor.model.Tarea;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface TareaRepository extends JpaRepository<Tarea, Long> {

    // Consulta optimizada con JOIN FETCH: elimina el N+1 de raíz
    @Query("SELECT t FROM Tarea t JOIN FETCH t.proyecto")
    List<Tarea> findAllConProyecto();
}
Qué diferencia hay entre JOIN y JOIN FETCH
En JPQL, un JOIN normal solo sirve para filtrar (por ejemplo, WHERE p.activo = true), pero deja el objeto relacionado como un Proxy perezoso. JOIN FETCH le dice explícitamente a Hibernate: «Filtra Y ADEMÁS inicializa el objeto relacionado con las columnas devueltas».

Observa la única sentencia SQL que PostgreSQL ejecuta ahora:

Hibernate:
    select
        t1_0.id,
        t1_0.completada,
        t1_0.prioridad,
        t1_0.titulo,
        p1_0.id,
        p1_0.activo,
        p1_0.descripcion,
        p1_0.nombre
    from
        tareas t1_0
    join
        proyectos p1_0
            on p1_0.id=t1_0.proyecto_id

De 101 consultas hemos pasado a 1 sola consulta. El tiempo de ejecución cae de 300 ms a 3 ms.

Paso 3 · La alternativa declarativa: @EntityGraph

Si prefieres no escribir consultas JPQL a mano para métodos estándar, Spring Data JPA ofrece la anotación @EntityGraph:

@EntityGraph(attributePaths = {"proyecto"})
@Override
List<Tarea> findAll();

@EntityGraph instruye a Hibernate a realizar automáticamente un LEFT OUTER JOIN trayendo el atributo especificado sin necesidad de alterar la signatura del método ni escribir la sentencia JPQL.

Paso 4 · Limitar y describir los resultados con Pageable y Page

Una página incluye los elementos y sus totales. Primero añade estos imports a TareaService: Page, Pageable y tu TareaResponse y TareaMapper. Añade el método siguiente al servicio existente, conservando su constructor y sus otros métodos. Aquí repositorio es el campo TareaRepository que ya inyectas.

@Transactional(readOnly = true)
public Page<TareaResponse> listarPaginadas(Pageable pageable) {
    return repositorio.findAll(pageable).map(TareaMapper::aRespuesta);
}

La conversión ocurre dentro de la transacción, donde pueden leerse las relaciones que necesita el DTO. No devuelvas entidades al controlador para convertirlas después de cerrar esa transacción.

En TareaController añade los imports Page, PageRequest, Sort de org.springframework.data.domain, HttpStatus de org.springframework.http y ResponseStatusException de org.springframework.web.server. Añade esta ruta, sin reemplazar el resto del controlador:

@GetMapping("/paginadas")
public Page<TareaResponse> listarPaginadas(
        @RequestParam(defaultValue = "0") int pagina,
        @RequestParam(defaultValue = "10") int tamano) {
    if (pagina < 0 || tamano < 1 || tamano > 100) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
            "La página debe ser >= 0 y el tamaño entre 1 y 100");
    }
    return servicio.listarPaginadas(
        PageRequest.of(pagina, tamano, Sort.by("id").ascending()));
}
  1. Prepara al menos tres tareas y pide /tareas/paginadas?pagina=0&tamano=2. Debe devolver dos elementos dentro de content y el total real en totalElements.
  2. Pide la página 1: debe contener los siguientes ids, sin repetir los de la primera. La numeración empieza en cero.
  3. Pide una página sin elementos: espera 200, content vacío y los mismos totales. Prueba después tamaño 0 y página -1: espera 400.
  4. Activa los logs SQL y localiza el límite y desplazamiento. Spring Data puede omitir el recuento si deduce el total del contenido; cuando lo necesita ejecuta también COUNT. Guarda peticiones y resultados en tu colección.

Paso 5 · Diagnóstico y optimización en vivo

Aplica la optimización en tu proyecto siguiendo estos pasos:

  1. Abre application.properties y asegúrate de tener activada la visibilidad SQL:
    spring.jpa.show-sql=true
    spring.jpa.properties.hibernate.format_sql=true
  2. Asegúrate de tener al menos tres proyectos y diez tareas creadas en tu base de datos.
  3. Haz una llamada a GET /tareas:
    • Cuenta en tu terminal cuántas sentencias select ... from proyectos aparecen. Verás una por cada tarea. Has pillado al N+1 con las manos en la masa.
  4. Cambia TareaService para que invoque tareaRepo.findAllConProyecto().
  5. Vuelve a hacer GET /tareas:
    • Comprueba en la consola que ahora solo se emite una única sentencia con JOIN.

Paso 6 · Optimizar la carga de proyectos con su lista de tareas

En la sesión 23 creamos GET /proyectos/{id}/detalle que cargaba el proyecto y luego inicializaba sus tareas.

  1. Añade en ProyectoRepository una consulta con JOIN FETCH:
    @Query("SELECT p FROM Proyecto p LEFT JOIN FETCH p.tareas WHERE p.id = :id")
    Optional<Proyecto> findByIdConTareas(@Param("id") Long id);
    (Usamos LEFT JOIN FETCH para que devuelva el proyecto incluso si no tiene tareas creadas todavía).
  2. Actualiza ProyectoService.obtenerConDetalle(id) para utilizar este nuevo método.
  3. Comprueba en la terminal que la consulta del detalle de un proyecto se resuelve ahora en una sola sentencia SQL en lugar de dos.

Paso 7 · Comprobar y registrar el resultado del proyecto

  1. Compara el resultado y el número de consultas antes y después de optimizar. Los datos deben ser equivalentes; comprueba también los límites y el orden de las páginas.
  2. Verifica la misma versión persistente en el entorno publicado mediante el flujo de Intermodular. Registra URL y commit, y comprueba con datos de prueba que un reinicio del backend conserva la información.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · El problema del producto cartesiano (MultipleBagFetchException)

Investiga una de las excepciones más desconcertantes de JPA:

Imagina que una Tarea tiene una lista de comentarios (List<Comentario>) y una lista de etiquetas (List<Etiqueta>). Escribes esta consulta optimizadora:

// INTENTO FALLIDO
@Query("SELECT t FROM Tarea t JOIN FETCH t.comentarios JOIN FETCH t.etiquetas")
List<Tarea> findTodo();
  • Al arrancar la aplicación, Hibernate aborta con este error:
    org.hibernate.loader.MultipleBagFetchException:
    cannot simultaneously fetch multiple bags: [com.ejemplo.gestor.model.Tarea.comentarios, com.ejemplo.gestor.model.Tarea.etiquetas]
  • ¿Qué es una bag en la terminología de Hibernate? (Una colección de tipo List sin orden definido).
  • Explica qué es el producto cartesiano relacional: si una tarea tiene 5 comentarios y 4 etiquetas, ¿cuántas filas devuelve un doble JOIN en PostgreSQL para esa sola tarea? (5 × 4 = 20 filas duplicadas).
  • Explica las dos soluciones de la industria para este problema:
    1. Cambiar las colecciones a Set (que no admiten duplicados).
    2. Dividir la carga en dos consultas dirigidas dentro de la misma transacción (aprovechando la caché de primer nivel de Hibernate).
Objetivo mínimoN+1 detectado en logs de consola y corregido mediante JOIN FETCH en TareaRepository.
Si lo tienesPaginación con Pageable y Page<T> implementada, con LIMIT y OFFSET verificados en PostgreSQL.
RetoConsulta con LEFT JOIN FETCH para proyectos implementada y justificación técnica de la MultipleBagFetchException.
Ver respuestas

1 · Porque en local la base de datos tiene pocos datos y la latencia de red es cero (localhost), ocultando el impacto del volumen de peticiones.

2 · JOIN ordinario solo permite aplicar condiciones de filtrado en la consulta; JOIN FETCH además inicializa y puebla el objeto relacionado directamente en la misma sentencia sin dejar un Proxy perezoso.

3 · Porque si la entidad padre no tiene ningún hijo asociado, un INNER JOIN descartaría al padre de la lista de resultados, mientras que LEFT JOIN devuelve al padre con la colección vacía.

4 · Genera las cláusulas LIMIT (tamaño de página) y OFFSET (desplazamiento inicial según el número de página).

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Los logs muestran el comportamiento de las consultas y los datos creados en la URL pública sobreviven a un reinicio del backend.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.