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:
- 1 SELECT tareas
- 100 SELECT proyectos (1 por fila)
- 101 viajes TCP
- Colapso de red y pool
tareaRepo.findAll()ejecuta 1 consulta:SELECT * FROM tareas;- Al iterar el stream, llegamos a
t.getProyecto().getNombre(). Como configuramosFetchType.LAZY(lo correcto para no cargar proyectos a ciegas),t.getProyecto()es un Proxy vacío. - Al pedirle
.getNombre(), el Proxy se ve obligado a viajar a PostgreSQL para traer los datos:SELECT * FROM proyectos WHERE id = 1; - En la segunda tarea, vuelve a disparar:
SELECT * FROM proyectos WHERE id = 2; - …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
- Abre los listados con relaciones, sus consultas de repositorio y los DTO. Activa la visualización de SQL solo en desarrollo.
- Prepara varios registros con relaciones y anota cuántas consultas produce un listado antes de optimizarlo.
- 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
JOINyJOIN FETCH - En JPQL, un
JOINnormal solo sirve para filtrar (por ejemplo,WHERE p.activo = true), pero deja el objeto relacionado como un Proxy perezoso.JOIN FETCHle 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()));
}
- Prepara al menos tres tareas y pide
/tareas/paginadas?pagina=0&tamano=2. Debe devolver dos elementos dentro decontenty el total real entotalElements. - Pide la página 1: debe contener los siguientes ids, sin repetir los de la primera. La numeración empieza en cero.
- Pide una página sin elementos: espera 200,
contentvacío y los mismos totales. Prueba después tamaño 0 y página -1: espera 400. - 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:
- Abre
application.propertiesy asegúrate de tener activada la visibilidad SQL:spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true - Asegúrate de tener al menos tres proyectos y diez tareas creadas en tu base de datos.
- Haz una llamada a
GET /tareas:- Cuenta en tu terminal cuántas sentencias
select ... from proyectosaparecen. Verás una por cada tarea. Has pillado al N+1 con las manos en la masa.
- Cuenta en tu terminal cuántas sentencias
- Cambia
TareaServicepara que invoquetareaRepo.findAllConProyecto(). - Vuelve a hacer
GET /tareas:- Comprueba en la consola que ahora solo se emite una única sentencia con
JOIN.
- Comprueba en la consola que ahora solo se emite una única sentencia con
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.
- Añade en
ProyectoRepositoryuna consulta conJOIN FETCH:
(Usamos@Query("SELECT p FROM Proyecto p LEFT JOIN FETCH p.tareas WHERE p.id = :id") Optional<Proyecto> findByIdConTareas(@Param("id") Long id);LEFT JOIN FETCHpara que devuelva el proyecto incluso si no tiene tareas creadas todavía). - Actualiza
ProyectoService.obtenerConDetalle(id)para utilizar este nuevo método. - 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
- 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.
- 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
Listsin orden definido). - Explica qué es el producto cartesiano relacional: si una tarea tiene 5 comentarios y 4 etiquetas, ¿cuántas filas devuelve un doble
JOINen PostgreSQL para esa sola tarea? (5 × 4 = 20 filas duplicadas). - Explica las dos soluciones de la industria para este problema:
- Cambiar las colecciones a
Set(que no admiten duplicados). - Dividir la carga en dos consultas dirigidas dentro de la misma transacción (aprovechando la caché de primer nivel de Hibernate).
- Cambiar las colecciones a
JOIN FETCH en TareaRepository.Pageable y Page<T> implementada, con LIMIT y OFFSET verificados en PostgreSQL.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.