Módulo 16: Consultas y rendimiento

Objetivos del módulo

  • Escribir consultas JPQL: sintaxis, parámetros con nombre y posicionales, navegación por relaciones
  • Obtener proyecciones como valores, Object[], Tuple y records (DTO) con expresiones de constructor
  • Usar JOIN, LEFT JOIN, JOIN ... ON y JOIN FETCH
  • Resolver informes con agregados, GROUP BY, HAVING y subconsultas sobre EMPRESA (ventas por cliente, empleados por departamento, productos más vendidos)
  • Paginar resultados con setFirstResult y setMaxResults
  • Definir consultas con nombre (@NamedQuery), construir consultas dinámicas con la Criteria API y conocer el metamodelo
  • Ejecutar SQL nativo y actualizaciones y borrados masivos
  • Detectar el problema N+1 contando las consultas reales y resolverlo con JOIN FETCH, @EntityGraph y batch fetching
  • Configurar la caché de segundo nivel
  • Proteger los datos frente a escrituras concurrentes con bloqueo optimista (@Version) y pesimista
  • Medir con las estadísticas de Hibernate y aplicar buenas prácticas de rendimiento

1. Formas de consultar en JPA

En los módulos 14 y 15 solo usamos find() y la navegación por relaciones. Para todo lo demás (filtros, informes, listados) JPA ofrece varias herramientas:

Herramienta Cuándo usarla Ejemplo
em.find(Clase, id) Una entidad por su clave em.find(Empleado.class, 121)
JPQL (createQuery) La opción por defecto: consultas sobre entidades select e from Empleado e where e.salario > :min
@NamedQuery Consultas fijas que se reutilizan; se validan al arrancar em.createNamedQuery("Empleado.porOficio")
Criteria API Consultas que se construyen dinámicamente (filtros opcionales) cb.equal(e.get("oficio"), oficio)
SQL nativo (createNativeQuery) Funciones propias de MySQL, consultas recursivas, tuning WITH RECURSIVE ...

📘 Concepto: JPQL (Jakarta Persistence Query Language) se parece mucho a SQL, pero trabaja con entidades y atributos, no con tablas y columnas. Hibernate lo traduce al SQL del dialecto de tu base de datos. Hibernate amplía JPQL con su propio lenguaje, HQL, que es un superconjunto: todo JPQL válido es HQL válido.

La consulta JPQL sobre Empleado y departamento se traduce en un SELECT con JOIN sobre las tablas

Figura 1. De JPQL (entidades) a SQL (tablas).

1.1 El proyecto de este módulo

Usamos el mismo modelo del módulo 15 (sin las entidades de laboratorio Habilidad y Credencial) con tres cambios:

  1. Producto.categoria pasa a ser LAZY (en el módulo 15 la dejamos EAGER a propósito para estudiarla).
  2. Producto tiene un atributo @Version para el bloqueo optimista (sección 14). Necesita una columna nueva; ejecuta este script antes de empezar:
-- Módulo 16: columna para el bloqueo optimista (@Version) en productos
ALTER TABLE productos ADD COLUMN version INT NOT NULL DEFAULT 0;
-- Para dejar EMPRESA como estaba al terminar el módulo:
-- ALTER TABLE productos DROP COLUMN version;
  1. Se añaden al pom.xml las dependencias de la caché de segundo nivel (sección 13).

Los ejemplos omiten las líneas package e import (jakarta.persistence.*, jakarta.persistence.criteria.*, org.hibernate.SessionFactory, org.hibernate.stat.Statistics, java.util.*…). En las salidas de este módulo, cuando una sentencia SQL ya ha aparecido antes completa, se muestra abreviada en una línea (Hibernate: select ... from empleados e1_0 where ...).


2. JPQL: lo básico

public class Ejemplo01Jpql {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            // 1. Parámetros con nombre (:nombre)
            TypedQuery<Empleado> q = em.createQuery("""
                    select e
                    from Empleado e
                    where e.salario >= :minimo and e.oficio <> :excluido
                    order by e.salario desc""", Empleado.class);
            q.setParameter("minimo", new BigDecimal("4000"));
            q.setParameter("excluido", "DIRECTOR GENERAL");
            List<Empleado> directivos = q.getResultList();
            directivos.forEach(e -> System.out.println("  " + e.getNombreCompleto() + " " + e.getSalario()));

            // 2. Parámetros posicionales (?1, ?2) y getSingleResult
            Empleado ana = em.createQuery(
                    "select e from Empleado e where e.email = ?1", Empleado.class)
                    .setParameter(1, "ana.garcia@empresa.es")
                    .getSingleResult();
            System.out.println("Por email: " + ana.getNombreCompleto());

            // 3. Navegación por relaciones con el punto (JOIN implícito)
            List<String> nombres = em.createQuery("""
                    select e.nombre from Empleado e
                    where e.departamento.localidad = :loc
                    order by e.nombre""", String.class)
                    .setParameter("loc", "Valencia")
                    .getResultList();
            System.out.println("Trabajan en Valencia: " + nombres);
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        e1_0.emp_id,
        e1_0.apellidos,
        e1_0.comision,
        e1_0.dept_id,
        e1_0.email,
        e1_0.fecha_alta,
        e1_0.jefe_id,
        e1_0.nombre,
        e1_0.oficio,
        e1_0.salario,
        e1_0.telefono 
    from
        empleados e1_0 
    where
        e1_0.salario>=? 
        and e1_0.oficio<>? 
    order by
        e1_0.salario desc
  Marta Sánchez Gil 5000.00
  Luis Martínez Ruiz 4800.00
  Laura Fernández Moreno 4300.00
  Pedro Gómez Díaz 4200.00
  Javier López Navarro 4000.00
Hibernate: 
    select
        e1_0.emp_id,
        ...
    from
        empleados e1_0 
    where
        e1_0.email=?
Por email: Ana García López
Hibernate: 
    select
        e1_0.nombre 
    from
        empleados e1_0 
    join
        departamentos d1_0 
            on d1_0.dept_id=e1_0.dept_id 
    where
        d1_0.localidad=? 
    order by
        e1_0.nombre
Trabajan en Valencia: [Hugo, Iván, Marta, Pablo, Raquel, Sara]

Fíjate en la tercera consulta: e.departamento.localidad es una navegación por la relación, y Hibernate la convierte en un JOIN (implícito, de tipo inner: los empleados sin departamento quedan fuera).

Elemento JPQL Ejemplo Notas
select / from select e from Empleado e e es una variable de identificación (alias) obligatoria
where where e.salario between 2000 and 3000 =, <>, <, >=, between, like, in, is null, is empty, member of
Parámetros :minimo o ?1 Siempre con setParameter; nunca concatenes valores (inyección)
order by order by e.salario desc, e.apellidos  
Funciones de texto upper, lower, concat, length, substring, trim, locate  
Funciones numéricas abs, sqrt, mod, round (JPA 3.1)  
Fechas current_date, local date, extract(year from e.fechaAlta) (JPA 3.1)  
Condicionales case when ... then ... else ... end, coalesce, nullif  
Resultado getResultList(), getSingleResult(), getResultStream() getSingleResult() lanza NoResultException si no hay filas

⚠️ Importante: getSingleResult() lanza NoResultException si la consulta no devuelve nada y NonUniqueResultException si devuelve más de una fila. Para “buscar por email, si existe”, usa getResultStream().findFirst() (devuelve un Optional) o captura la excepción.

💡 Consejo: Escribe las consultas con bloques de texto (""", Java 15): son mucho más legibles que una cadena concatenada en varias líneas.


3. Proyecciones: solo los datos que necesitas

Cargar entidades completas para mostrar dos columnas es un desperdicio (y además las mete en el contexto de persistencia, con sus instantáneas para el dirty checking). JPQL puede devolver valores sueltos, filas de valores o directamente DTO.

/** Proyección: solo los datos que necesita la pantalla, sin ser una entidad. */
public record EmpleadoResumen(String nombre, String departamento, BigDecimal salario) { }
public class Ejemplo02Proyecciones {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            // 1. Un solo valor escalar
            BigDecimal maximo = em.createQuery("select max(e.salario) from Empleado e", BigDecimal.class)
                    .getSingleResult();
            System.out.println("Salario máximo: " + maximo);

            // 2. Varias columnas como Object[]
            List<Object[]> filas = em.createQuery(
                    "select e.nombre, e.salario from Empleado e where e.oficio = 'ANALISTA'", Object[].class)
                    .getResultList();
            for (Object[] f : filas) System.out.println("  Object[] -> " + f[0] + " " + f[1]);

            // 3. Tuple: columnas con alias
            List<Tuple> tuplas = em.createQuery(
                    "select e.nombre as nombre, e.oficio as oficio from Empleado e where e.salario > 4500",
                    Tuple.class).getResultList();
            for (Tuple t : tuplas) System.out.println("  Tuple -> " + t.get("nombre") + " (" + t.get("oficio") + ")");

            // 4. Expresión de constructor: directamente a un record (DTO)
            List<EmpleadoResumen> resumen = em.createQuery("""
                    select new es.curso.empresa.dto.EmpleadoResumen(
                               concat(e.nombre, ' ', e.apellidos), d.nombre, e.salario)
                    from Empleado e join e.departamento d
                    where d.id = :dep
                    order by e.salario desc""", EmpleadoResumen.class)
                    .setParameter("dep", 20)
                    .getResultList();
            resumen.forEach(r -> System.out.println("  " + r));
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        max(e1_0.salario) 
    from
        empleados e1_0
Salario máximo: 6500.00
Hibernate: 
    select
        e1_0.nombre,
        e1_0.salario 
    from
        empleados e1_0 
    where
        e1_0.oficio='ANALISTA'
  Object[] -> Pablo 3500.00
  Object[] -> Álvaro 2700.00
Hibernate: 
    select
        e1_0.nombre,
        e1_0.oficio 
    from
        empleados e1_0 
    where
        e1_0.salario>4500
  Tuple -> Ana (DIRECTOR GENERAL)
  Tuple -> Luis (DIRECTOR)
  Tuple -> Marta (DIRECTOR)
Hibernate: 
    select
        concat(e1_0.nombre, ' ', e1_0.apellidos),
        d1_0.nombre,
        e1_0.salario 
    from
        empleados e1_0 
    join
        departamentos d1_0 
            on d1_0.dept_id=e1_0.dept_id 
    where
        d1_0.dept_id=? 
    order by
        e1_0.salario desc
  EmpleadoResumen[nombre=Luis Martínez Ruiz, departamento=VENTAS, salario=4800.00]
  EmpleadoResumen[nombre=Elena Navarro Castro, departamento=VENTAS, salario=2300.00]
  EmpleadoResumen[nombre=Carmen Ruiz Ortega, departamento=VENTAS, salario=2200.00]
  EmpleadoResumen[nombre=David Torres Romero, departamento=VENTAS, salario=2100.00]
  EmpleadoResumen[nombre=Sergio Jiménez Vidal, departamento=VENTAS, salario=1900.00]
  EmpleadoResumen[nombre=Lucía Romero Serrano, departamento=VENTAS, salario=1700.00]

Observa el SQL de la proyección a record: solo pide las tres columnas necesarias, frente a las once de la entidad completa.

Tipo de resultado Ventajas Inconvenientes
Entidad Se puede modificar (dirty checking) Carga todas las columnas; ocupa el contexto de persistencia
Escalar / Object[] Rápido Acceso por posición (f[0]) propenso a errores
Tuple Acceso por alias Sin tipado fuerte (get devuelve Object salvo que indiques la clase)
DTO con select new Tipado, inmutable, solo lo necesario Hay que escribir la clase completa con su paquete en la consulta

💡 Consejo: Para leer datos que vas a mostrar o devolver en una API, prefiere DTO (records). Para modificar, carga entidades. Así evitas además la LazyInitializationException: un record no tiene proxies.


4. JOIN, LEFT JOIN, JOIN ... ON y JOIN FETCH

public class Ejemplo03Joins {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            // 1. LEFT JOIN: departamentos con su número de empleados (también los vacíos)
            System.out.println("--- LEFT JOIN ---");
            List<Object[]> filas = em.createQuery("""
                    select d.nombre, count(e)
                    from Departamento d left join d.empleados e
                    group by d.nombre
                    order by count(e) desc, d.nombre""", Object[].class)
                    .getResultList();
            filas.forEach(f -> System.out.println("  " + f[0] + ": " + f[1]));

            // 2. JOIN con condición sobre la entidad unida
            System.out.println("--- JOIN ... ON ---");
            List<String> conPedidosPendientes = em.createQuery("""
                    select distinct c.nombre
                    from Cliente c join c.pedidos p on p.estado = es.curso.empresa.modelo.EstadoPedido.PENDIENTE
                    order by c.nombre""", String.class)
                    .getResultList();
            System.out.println("  Con pedidos pendientes: " + conPedidosPendientes);

            // 3. JOIN FETCH: trae el pedido Y su cliente Y sus líneas en una sola consulta
            System.out.println("--- JOIN FETCH ---");
            Pedido p = em.createQuery("""
                    select p from Pedido p
                    join fetch p.cliente
                    left join fetch p.lineas l
                    left join fetch l.producto
                    where p.id = :id""", Pedido.class)
                    .setParameter("id", 5004)
                    .getSingleResult();
            System.out.println("  " + p.getCliente().getNombre() + ": " + p.getLineas().size()
                    + " líneas, total " + p.getTotal());
            p.getLineas().forEach(l -> System.out.println("    " + l));
        }
        JpaUtil.cerrar();
    }
}

Salida real (la lista de columnas del JOIN FETCH se ha abreviado):

--- LEFT JOIN ---
Hibernate: 
    select
        d1_0.nombre,
        count(e1_0.emp_id) 
    from
        departamentos d1_0 
    left join
        empleados e1_0 
            on d1_0.dept_id=e1_0.dept_id 
    group by
        d1_0.nombre 
    order by
        count(e1_0.emp_id) desc,
        d1_0.nombre
  INFORMÁTICA: 6
  VENTAS: 6
  LOGÍSTICA: 4
  CONTABILIDAD: 3
  MARKETING: 3
  DIRECCIÓN: 1
  INVESTIGACIÓN: 0
--- JOIN ... ON ---
Hibernate: 
    select
        distinct c1_0.nombre 
    from
        clientes c1_0 
    join
        pedidos p1_0 
            on c1_0.cliente_id=p1_0.cliente_id 
            and p1_0.estado='PENDIENTE' 
    order by
        c1_0.nombre
  Con pedidos pendientes: [Diseños Creativos S.L., Gestoría Delta, Laura Benítez Sanz]
--- JOIN FETCH ---
Hibernate: 
    select
        p1_0.pedido_id,
        p1_0.cliente_id,
        c1_0.cliente_id,
        c1_0.ciudad,
        ...
        l1_0.pedido_id,
        l1_0.num_linea,
        ...
        p2_0.producto_id,
        p2_0.nombre,
        ...
    from
        pedidos p1_0 
    join
        clientes c1_0 
            on c1_0.cliente_id=p1_0.cliente_id 
    left join
        lineas_pedido l1_0 
            on p1_0.pedido_id=l1_0.pedido_id 
    left join
        productos p2_0 
            on p2_0.producto_id=l1_0.producto_id 
    where
        p1_0.pedido_id=? 
    order by
        l1_0.num_linea
  Academia Nova: 3 líneas, total 6232.50
    Linea 1: 5 x Portátil UltraBook 14 = 4270.25
    Linea 2: 5 x Monitor 27 4K = 1562.75
    Linea 3: 5 x Teclado mecánico = 399.50
Tipo Qué hace Resultado
join e.departamento d Inner join por la relación Solo los que tienen departamento; d se puede usar en where/select
left join d.empleados e Outer join Incluye los departamentos sin empleados (INVESTIGACIÓN aparece con 0)
join ... on condición Añade una condición al ON (JPA 2.1) Útil con left join para filtrar sin perder filas
join fetch Además de unir, carga la relación en las entidades devueltas La relación queda inicializada: sin consultas extra ni LazyInitializationException

📘 Concepto: La diferencia clave: un join normal sirve para filtrar o seleccionar datos de la entidad unida, pero no rellena la relación en los objetos devueltos. join fetch sí la rellena. Aquí la consulta ha traído pedido, cliente, líneas y productos en una sentencia, y el @OrderBy("id.numLinea") de la colección se ha convertido en order by l1_0.num_linea.

⚠️ Importante: No combines join fetch de una colección con paginación (setMaxResults): como cada fila del resultado SQL es una línea, Hibernate no puede aplicar el LIMIT en la base de datos; carga todo y pagina en memoria (y avisa con un WARN). Para listados paginados, pagina la entidad principal y carga las colecciones con batch fetching (sección 12).


5. Agregados y GROUP BY: los informes de EMPRESA

Para los informes necesitamos tres records:

public record EstadisticaDepartamento(String departamento, Long empleados, Double salarioMedio,
                                      BigDecimal salarioMaximo) { }
public record VentasCliente(String cliente, Long pedidos, BigDecimal total) { }
public record ProductoVendido(String producto, Long unidades, BigDecimal importe) { }

⚠️ Importante: Los tipos de los componentes deben coincidir con lo que devuelve cada función: count devuelve Long, avg devuelve Double, y sum/max el tipo del atributo (BigDecimal para importes). Si no coinciden, Hibernate no encuentra el constructor.

public class Ejemplo04Informes {

    // Importe de una línea: cantidad * precio * (1 - descuento / 100)
    static final String IMPORTE = "l.cantidad * l.precioUnitario * (1 - l.descuento / 100)";

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            System.out.println("=== Empleados por departamento ===");
            List<EstadisticaDepartamento> deps = em.createQuery("""
                    select new es.curso.empresa.dto.EstadisticaDepartamento(
                               d.nombre, count(e), avg(e.salario), max(e.salario))
                    from Empleado e join e.departamento d
                    group by d.nombre
                    having count(e) >= 2
                    order by count(e) desc""", EstadisticaDepartamento.class)
                    .getResultList();
            deps.forEach(d -> System.out.printf("  %-13s %2d  media %8.2f  máx %s%n",
                    d.departamento(), d.empleados(), d.salarioMedio(), d.salarioMaximo()));

            System.out.println("=== Ventas por cliente (sin cancelados) ===");
            List<VentasCliente> ventas = em.createQuery("""
                    select new es.curso.empresa.dto.VentasCliente(
                               c.nombre, count(distinct p), sum(%s))
                    from LineaPedido l join l.pedido p join p.cliente c
                    where p.estado <> es.curso.empresa.modelo.EstadoPedido.CANCELADO
                    group by c.nombre
                    order by sum(%s) desc""".formatted(IMPORTE, IMPORTE), VentasCliente.class)
                    .setMaxResults(5)
                    .getResultList();
            ventas.forEach(v -> System.out.printf("  %-28s %2d pedidos %10.2f €%n",
                    v.cliente(), v.pedidos(), v.total()));

            System.out.println("=== Productos más vendidos ===");
            List<ProductoVendido> top = em.createQuery("""
                    select new es.curso.empresa.dto.ProductoVendido(pr.nombre, sum(l.cantidad), sum(%s))
                    from LineaPedido l join l.producto pr
                    group by pr.nombre
                    order by sum(l.cantidad) desc, pr.nombre""".formatted(IMPORTE), ProductoVendido.class)
                    .setMaxResults(5)
                    .getResultList();
            top.forEach(t -> System.out.printf("  %-24s %3d uds %10.2f €%n", t.producto(), t.unidades(), t.importe()));
        }
        JpaUtil.cerrar();
    }
}

Salida real:

=== Empleados por departamento ===
Hibernate: 
    select
        d1_0.nombre,
        count(e1_0.emp_id),
        avg(e1_0.salario),
        max(e1_0.salario) 
    from
        empleados e1_0 
    join
        departamentos d1_0 
            on d1_0.dept_id=e1_0.dept_id 
    group by
        d1_0.nombre 
    having
        count(e1_0.emp_id)>=2 
    order by
        count(e1_0.emp_id) desc
  INFORMÁTICA    6  media  3133.33  máx 5000.00
  VENTAS         6  media  2500.00  máx 4800.00
  LOGÍSTICA      4  media  2237.50  máx 4000.00
  CONTABILIDAD   3  media  2816.67  máx 4200.00
  MARKETING      3  media  3100.00  máx 4300.00
=== Ventas por cliente (sin cancelados) ===
Hibernate: 
    select
        c1_0.nombre,
        count(distinct p1_0.pedido_id),
        sum(((lp1_0.cantidad*lp1_0.precio_unitario)*(1-(lp1_0.descuento/100)))) 
    from
        lineas_pedido lp1_0 
    join
        pedidos p1_0 
            on p1_0.pedido_id=lp1_0.pedido_id 
    join
        clientes c1_0 
            on c1_0.cliente_id=p1_0.cliente_id 
    where
        p1_0.estado<>'CANCELADO' 
    group by
        c1_0.nombre 
    order by
        sum(((lp1_0.cantidad*lp1_0.precio_unitario)*(1-(lp1_0.descuento/100)))) desc 
    limit
        ?
  Instituto Tecnológico Sur     2 pedidos   20602.55 €
  Construcciones Levante S.A.   3 pedidos   14260.19 €
  Academia Nova                 2 pedidos    7891.30 €
  Talleres Martín S.L.          3 pedidos    4704.15 €
  Hotel Mar Azul S.L.           2 pedidos    4202.77 €
=== Productos más vendidos ===
Hibernate: 
    select
        p1_0.nombre,
        sum(lp1_0.cantidad),
        sum(((lp1_0.cantidad*lp1_0.precio_unitario)*(1-(lp1_0.descuento/100)))) 
    from
        lineas_pedido lp1_0 
    join
        productos p1_0 
            on p1_0.producto_id=lp1_0.producto_id 
    group by
        p1_0.nombre 
    order by
        sum(lp1_0.cantidad) desc,
        p1_0.nombre 
    limit
        ?
  Monitor 24 FHD            40 uds    4934.50 €
  Pack teclado y ratón      30 uds     897.00 €
  Portátil Básico 15        24 uds   10479.00 €
  Portátil UltraBook 14     15 uds   12361.25 €
  Teclado mecánico          15 uds    1166.54 €

Son las mismas consultas que escribirías en SQL (módulos de JDBC y Curso de Bases de Datos), pero expresadas sobre el modelo de objetos: l.pedido.cliente en lugar de dos JOIN ... ON con claves ajenas. Y setMaxResults(5) se traduce en el LIMIT de MySQL. Los resultados no son entidades, así que no ocupan el contexto de persistencia.


6. Subconsultas y funciones

public class Ejemplo05Subconsultas {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            // 1. Subconsulta escalar: ganan más que la media de SU departamento (correlacionada)
            List<String> sobreMedia = em.createQuery("""
                    select concat(e.nombre, ' (', e.departamento.nombre, ')')
                    from Empleado e
                    where e.salario > (select avg(e2.salario) from Empleado e2
                                       where e2.departamento = e.departamento)
                    order by e.departamento.nombre, e.nombre""", String.class)
                    .getResultList();
            System.out.println("Por encima de la media de su departamento: " + sobreMedia);

            // 2. NOT EXISTS: clientes que nunca han hecho un pedido
            List<String> sinPedidos = em.createQuery("""
                    select c.nombre from Cliente c
                    where not exists (select p from Pedido p where p.cliente = c)""", String.class)
                    .getResultList();
            System.out.println("Clientes sin pedidos: " + sinPedidos);

            // 3. NOT IN: productos que nunca se han vendido
            List<String> sinVentas = em.createQuery("""
                    select pr.nombre from Producto pr
                    where pr.id not in (select l.producto.id from LineaPedido l)""", String.class)
                    .getResultList();
            System.out.println("Productos sin ventas: " + sinVentas);

            // 4. Funciones: extract (JPA 3.1), upper, length, coalesce
            List<Object[]> altas = em.createQuery("""
                    select extract(year from e.fechaAlta), count(e)
                    from Empleado e
                    where extract(year from e.fechaAlta) >= 2020
                    group by extract(year from e.fechaAlta)
                    order by 1""", Object[].class)
                    .getResultList();
            altas.forEach(a -> System.out.println("Altas en " + a[0] + ": " + a[1]));
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        concat(e1_0.nombre, ' (', d1_0.nombre, ')') 
    from
        empleados e1_0 
    join
        departamentos d1_0 
            on d1_0.dept_id=e1_0.dept_id 
    where
        e1_0.salario>(
            select
                avg(e2_0.salario) 
            from
                empleados e2_0 
            where
                e2_0.dept_id=e1_0.dept_id
        ) 
    order by
        d1_0.nombre,
        e1_0.nombre
Por encima de la media de su departamento: [Pedro (CONTABILIDAD), Marta (INFORMÁTICA), Pablo (INFORMÁTICA), Javier (LOGÍSTICA), Laura (MARKETING), Luis (VENTAS)]
Hibernate: 
    select
        c1_0.nombre 
    from
        clientes c1_0 
    where
        not exists(select
            p1_0.pedido_id from pedidos p1_0 
        where
            p1_0.cliente_id=c1_0.cliente_id)
Clientes sin pedidos: [Carlos Ibáñez Rey, Farmacia Central]
Hibernate: 
    select
        p1_0.nombre 
    from
        productos p1_0 
    where
        p1_0.producto_id not in (select
            lp1_0.producto_id from lineas_pedido lp1_0)
Productos sin ventas: [Altavoz Inteligente]
Hibernate: 
    select
        year(e1_0.fecha_alta),
        count(e1_0.emp_id) 
    from
        empleados e1_0 
    where
        year(e1_0.fecha_alta)>=2020 
    group by
        year(e1_0.fecha_alta) 
    order by
        1
Altas en 2020: 2
Altas en 2021: 2
Altas en 2022: 2
Altas en 2023: 1
Altas en 2025: 1

La función estándar extract(year from ...) se ha traducido a la función year() de MySQL: esa es la ventaja de escribir en JPQL y dejar que el dialecto haga el resto. Comparar entidades (e2.departamento = e.departamento, p.cliente = c) se traduce en comparar sus claves.

💡 Consejo: Las subconsultas en JPQL pueden ir en where y having (y en select y from con Hibernate 6+). Con exists, in, all, any y some cubres prácticamente todos los casos del SQL que ya conoces.


7. Paginación

public class Ejemplo06Paginacion {

    static List<Empleado> pagina(EntityManager em, int numPagina, int tamano) {
        return em.createQuery("select e from Empleado e order by e.apellidos, e.id", Empleado.class)
                .setFirstResult(numPagina * tamano)    // filas que se saltan (OFFSET)
                .setMaxResults(tamano)                 // filas que se devuelven (LIMIT)
                .getResultList();
    }

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            long total = em.createQuery("select count(e) from Empleado e", Long.class).getSingleResult();
            int tamano = 10;
            long paginas = (total + tamano - 1) / tamano;
            System.out.println(total + " empleados en " + paginas + " páginas");

            List<Empleado> tercera = pagina(em, 2, tamano);       // páginas numeradas desde 0
            System.out.println("Página 3: ");
            tercera.forEach(e -> System.out.println("  " + e.getApellidos() + ", " + e.getNombre()));
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        count(e1_0.emp_id) 
    from
        empleados e1_0
24 empleados en 3 páginas
Hibernate: 
    select
        e1_0.emp_id,
        ...
    from
        empleados e1_0 
    order by
        e1_0.apellidos,
        e1_0.emp_id 
    limit
        ?, ?
Página 3: 
  Ruiz Ortega, Carmen
  Sánchez Gil, Marta
  Torres Romero, David
  Vázquez Ramos, Jorge

⚠️ Importante: Ordena siempre al paginar, y por un criterio único (aquí apellidos y, en caso de empate, id). Sin order by, la base de datos puede devolver las filas en cualquier orden y un mismo empleado podría aparecer en dos páginas o en ninguna. En Spring Data JPA (módulo 19) la paginación se simplifica con Pageable y Page, pero por debajo es exactamente esto.


8. Consultas con nombre: @NamedQuery

Las consultas que se usan en varios sitios pueden declararse en la entidad con un nombre:

@Entity
@Table(name = "empleados")
@NamedQuery(name = "Empleado.porOficio",
        query = "select e from Empleado e where e.oficio = :oficio order by e.salario desc")
@NamedQuery(name = "Empleado.contarPorDepartamento",
        query = "select count(e) from Empleado e where e.departamento.id = :deptId")
public class Empleado { /* ... */ }
public class Ejemplo07NamedQuery {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            em.createNamedQuery("Empleado.porOficio", Empleado.class)
                    .setParameter("oficio", "VENDEDOR")
                    .getResultList()
                    .forEach(e -> System.out.println("  " + e.getNombreCompleto() + " " + e.getSalario()));

            Long n = em.createNamedQuery("Empleado.contarPorDepartamento", Long.class)
                    .setParameter("deptId", 60)
                    .getSingleResult();
            System.out.println("Empleados de LOGÍSTICA: " + n);
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        e1_0.emp_id,
        ...
    from
        empleados e1_0 
    where
        e1_0.oficio=? 
    order by
        e1_0.salario desc
  Elena Navarro Castro 2300.00
  Carmen Ruiz Ortega 2200.00
  David Torres Romero 2100.00
  Sergio Jiménez Vidal 1900.00
Hibernate: 
    select
        count(e1_0.emp_id) 
    from
        empleados e1_0 
    where
        e1_0.dept_id=?
Empleados de LOGÍSTICA: 4

Hibernate analiza las consultas con nombre al arrancar: si una tiene un error (un atributo mal escrito), la aplicación no llega a iniciarse. Además, e.departamento.id se traduce directamente en la columna dept_id, sin JOIN: Hibernate sabe que el id del departamento ya está en empleados.

💡 Consejo: Usa el convenio Entidad.descripcion para los nombres. En Spring Data JPA, una @NamedQuery llamada Empleado.porOficio se asocia automáticamente al método porOficio del repositorio.


9. Criteria API: consultas dinámicas

¿Cómo escribirías un buscador de empleados con filtros opcionales (oficio, salario mínimo, localidad)? Concatenando trozos de JPQL según los filtros que vengan rellenos… algo frágil y propenso a errores. La Criteria API construye la consulta con objetos Java:

Piezas de la Criteria API: CriteriaBuilder, CriteriaQuery, Root y lista de Predicate

Figura 2. Construcción de una consulta con la Criteria API.

public class Ejemplo08Criteria {

    /** Búsqueda con filtros OPCIONALES: solo se añaden los que no son null. */
    static List<Empleado> buscar(EntityManager em, String oficio, BigDecimal salarioMinimo, String localidad) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Empleado> cq = cb.createQuery(Empleado.class);
        Root<Empleado> e = cq.from(Empleado.class);                          // from Empleado e

        List<Predicate> filtros = new ArrayList<>();
        if (oficio != null) {
            filtros.add(cb.equal(e.get("oficio"), oficio));                  // e.oficio = ?
        }
        if (salarioMinimo != null) {
            filtros.add(cb.greaterThanOrEqualTo(e.get("salario"), salarioMinimo));
        }
        if (localidad != null) {
            Join<Empleado, Departamento> d = e.join("departamento");          // join e.departamento d
            filtros.add(cb.equal(d.get("localidad"), localidad));
        }
        cq.select(e)
          .where(filtros.toArray(Predicate[]::new))
          .orderBy(cb.desc(e.get("salario")));
        return em.createQuery(cq).getResultList();
    }

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            System.out.println("--- Solo oficio ---");
            buscar(em, "PROGRAMADOR", null, null)
                    .forEach(x -> System.out.println("  " + x.getNombreCompleto()));
            System.out.println("--- Salario mínimo y localidad ---");
            buscar(em, null, new BigDecimal("2500"), "Valencia")
                    .forEach(x -> System.out.println("  " + x.getNombreCompleto() + " " + x.getSalario()));
        }
        JpaUtil.cerrar();
    }
}

Salida real:

--- Solo oficio ---
Hibernate: select ... from empleados e1_0 where e1_0.oficio=? order by e1_0.salario desc
  Raquel Molina Prieto
  Iván Castro Méndez
  Sara Ortiz Delgado
--- Salario mínimo y localidad ---
Hibernate: 
    select
        e1_0.emp_id,
        ...
    from
        empleados e1_0 
    join
        departamentos d1_0 
            on d1_0.dept_id=e1_0.dept_id 
    where
        e1_0.salario>=? 
        and d1_0.localidad=? 
    order by
        e1_0.salario desc
  Marta Sánchez Gil 5000.00
  Pablo Moreno Herrera 3500.00
  Raquel Molina Prieto 2800.00
  Iván Castro Méndez 2600.00
  Hugo Rubio Marín 2500.00

La primera búsqueda genera exactamente el mismo SQL que la @NamedQuery de la sección anterior. La segunda solo añade el JOIN porque se pidió un filtro por localidad.

9.1 El metamodelo: Criteria sin cadenas

e.get("salario") tiene un punto débil: si te equivocas en el nombre, el error aparece al ejecutar. El metamodelo estático de JPA son unas clases generadas al compilar (Empleado_, Departamento_…) con un atributo por cada atributo de la entidad, de modo que escribes e.get(Empleado_.salario) y el compilador comprueba el nombre y el tipo. Con Hibernate las genera el procesador de anotaciones hibernate-processor:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.16.0</version>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>org.hibernate.orm</groupId>
                <artifactId>hibernate-processor</artifactId>
                <version>${hibernate.version}</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>
  JPQL Criteria API
Legibilidad Alta: parece SQL Baja: mucho código
Consultas dinámicas Concatenando cadenas (frágil) Natural
Errores de nombre Al ejecutar (o al arrancar, con @NamedQuery) Al compilar, con el metamodelo
Uso típico La mayoría de consultas Buscadores con filtros opcionales

10. SQL nativo

Cuando necesitas algo propio de MySQL que JPQL no expresa (consultas recursivas, funciones de ventana específicas, hints del optimizador…), puedes escribir SQL directamente:

public class Ejemplo09Nativa {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            // 1. SQL nativo que devuelve entidades
            List<Producto> sinStock = em.createNativeQuery(
                    "SELECT * FROM productos WHERE stock = 0 ORDER BY nombre", Producto.class)
                    .getResultList();
            sinStock.forEach(p -> System.out.println("Sin stock: " + p.getNombre()));

            // 2. SQL propio de MySQL: consulta recursiva (WITH RECURSIVE) sobre el árbol de categorías
            @SuppressWarnings("unchecked")
            List<Object[]> arbol = em.createNativeQuery("""
                    WITH RECURSIVE arbol (categoria_id, nombre, nivel, ruta) AS (
                        SELECT categoria_id, nombre, 0, CAST(nombre AS CHAR(200))
                        FROM categorias WHERE categoria_padre_id IS NULL
                        UNION ALL
                        SELECT c.categoria_id, c.nombre, a.nivel + 1, CONCAT(a.ruta, ' > ', c.nombre)
                        FROM categorias c JOIN arbol a ON c.categoria_padre_id = a.categoria_id
                    )
                    SELECT nivel, ruta FROM arbol WHERE nivel = 2 ORDER BY ruta""")
                    .getResultList();
            arbol.forEach(f -> System.out.println("Nivel " + f[0] + ": " + f[1]));

            // 3. Parámetros en SQL nativo
            Number total = (Number) em.createNativeQuery(
                    "SELECT COUNT(*) FROM pedidos WHERE YEAR(fecha_pedido) = ?1")
                    .setParameter(1, 2026)
                    .getSingleResult();
            System.out.println("Pedidos de 2026: " + total);
        }
        JpaUtil.cerrar();
    }
}

Salida real (el formateador de Hibernate parte de forma curiosa la consulta recursiva, pero es exactamente el texto que escribimos):

Hibernate: 
    SELECT
        * 
    FROM
        productos 
    WHERE
        stock = 0 
    ORDER BY
        nombre
Sin stock: Altavoz Inteligente
Sin stock: Pack teclado y ratón
Hibernate: 
    WITH RECURSIVE arbol (categoria_id, nombre, nivel, ruta) AS (     SELECT
        categoria_id, nombre, 0, CAST(nombre AS CHAR(200))     FROM categorias 
    WHERE
        categoria_padre_id IS NULL     
    UNION
    ALL     SELECT
        c.categoria_id, c.nombre, a.nivel + 1, CONCAT(a.ruta, ' > ', c.nombre)     FROM categorias c 
    JOIN
        arbol a 
            ON c.categoria_padre_id = a.categoria_id ) SELECT
            nivel,
            ruta 
    FROM
        arbol 
    WHERE
        nivel = 2 
    ORDER BY
        ruta
Nivel 2: Informática > Periféricos > Monitores
Nivel 2: Informática > Periféricos > Ratones y teclados
Hibernate: 
    SELECT
        COUNT(*) 
    FROM
        pedidos 
    WHERE
        YEAR(fecha_pedido) = ?
Pedidos de 2026: 9

⚠️ Importante: El SQL nativo ata tu código a MySQL y Hibernate no lo valida: los errores aparecen al ejecutar. Úsalo solo cuando JPQL no llegue. Y aunque devuelva entidades, ten en cuenta que antes de ejecutarlo Hibernate hace flush de todo lo pendiente, porque no puede saber qué tablas lee tu SQL.


11. Actualizaciones y borrados masivos

Para cambiar muchas filas, cargar cada entidad y modificarla generaría un SELECT y un UPDATE por fila. JPQL permite update y delete masivos, que se ejecutan con una sola sentencia:

public class Ejemplo10Masivas {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            em.getTransaction().begin();
            Producto raton = em.find(Producto.class, 6);
            System.out.println("Antes: " + raton);

            // UPDATE masivo: una sola sentencia para muchas filas, sin cargar entidades
            int n = em.createQuery("""
                    update Producto p set p.precio = p.precio * 1.10
                    where p.categoria.id = :cat""")
                    .setParameter("cat", 5)
                    .executeUpdate();
            System.out.println("Filas actualizadas: " + n);

            // ¡El contexto de persistencia NO se entera!
            System.out.println("En memoria: " + raton);
            em.refresh(raton);                         // relee la fila de la base de datos
            System.out.println("Tras refresh: " + raton);

            em.getTransaction().rollback();            // deshacemos para no tocar EMPRESA
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        p1_0.producto_id,
        p1_0.categoria_id,
        ...
        p1_0.version 
    from
        productos p1_0 
    where
        p1_0.producto_id=?
Antes: Producto[6, Ratón inalámbrico, precio=19.90, stock=200, v0]
Hibernate: 
    update
        productos p1_0 
    set
        precio=(p1_0.precio*1.10) 
    where
        p1_0.categoria_id=?
Filas actualizadas: 3
En memoria: Producto[6, Ratón inalámbrico, precio=19.90, stock=200, v0]
Hibernate: select ... from productos p1_0 where p1_0.producto_id=?
Tras refresh: Producto[6, Ratón inalámbrico, precio=21.89, stock=200, v0]

Tres conclusiones:

  1. Un solo UPDATE ha cambiado los tres productos de la categoría “Ratones y teclados”.
  2. El objeto raton que ya estaba en memoria sigue con el precio antiguo: las operaciones masivas van directas a la base de datos y se saltan el contexto de persistencia (y el dirty checking, los callbacks y las cascadas). Hay que releer con refresh, o hacer em.clear().
  3. La versión no ha cambiado (v0): una actualización masiva JPQL no incrementa @Version. Si lo necesitas, en HQL existe update versioned Producto ..., o inclúyelo a mano: set p.version = p.version + 1.

Un borrado masivo es igual de sencillo: em.createQuery("delete from Asignacion a where a.horas = 0").executeUpdate().

⚠️ Importante: Ejecuta las operaciones masivas al principio de la transacción o seguidas de em.clear(), para no mezclar objetos en memoria desactualizados con los datos reales. Y recuerda que delete masivo no aplica CascadeType.REMOVE ni orphanRemoval: solo las restricciones ON DELETE CASCADE de la base de datos.


12. El problema N+1

📘 Concepto: El problema N+1 ocurre cuando una consulta devuelve N entidades (1 consulta) y luego, al recorrerlas, el acceso a una relación perezosa de cada una dispara una consulta más (N consultas). Es el problema de rendimiento más frecuente con cualquier ORM.

Una consulta de pedidos más una de clientes por cada pedido, frente a JOIN FETCH, entity graph y batch fetching

Figura 3. El problema N+1 y sus tres soluciones.

Para demostrarlo contamos las sentencias reales con las estadísticas de Hibernate (sección 16) y quitamos format_sql para que cada sentencia ocupe una línea. Para el entity graph declaramos uno con nombre en Pedido:

@Entity
@Table(name = "pedidos")
@NamedEntityGraph(name = "Pedido.conCliente",
        attributeNodes = @NamedAttributeNode("cliente"))
public class Pedido { /* ... */ }
public class Ejemplo11NMasUno {

    static final LocalDate DESDE = LocalDate.of(2026, 1, 1);

    static void listar(List<Pedido> pedidos) {
        for (Pedido p : pedidos) {
            System.out.println("  " + p.getId() + " " + p.getCliente().getNombre());   // ¡acceso LAZY!
        }
    }

    static void probar(EntityManagerFactory emf, String titulo, String jpql, String grafo) {
        Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
        stats.clear();
        System.out.println("===== " + titulo + " =====");
        try (EntityManager em = emf.createEntityManager()) {
            var q = em.createQuery(jpql, Pedido.class).setParameter("desde", DESDE);
            if (grafo != null) {
                q.setHint("jakarta.persistence.fetchgraph", em.getEntityGraph(grafo));
            }
            listar(q.getResultList());
        }
        System.out.println("  >> Sentencias SQL ejecutadas: " + stats.getPrepareStatementCount());
    }

    public static void main(String[] args) {
        Map<String, Object> props = Map.of(
                "hibernate.format_sql", "false",              // una línea por sentencia
                "hibernate.generate_statistics", "true");      // para contar las sentencias
        String jpql = "select p from Pedido p where p.fechaPedido >= :desde order by p.id";

        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("empresa", props)) {
            probar(emf, "1. Problema N+1", jpql, null);
            probar(emf, "2. JOIN FETCH",
                    "select p from Pedido p join fetch p.cliente where p.fechaPedido >= :desde order by p.id", null);
            probar(emf, "3. @NamedEntityGraph", jpql, "Pedido.conCliente");
        }

        // 4. Batch fetching: los proxies se inicializan en bloques con IN (...)
        Map<String, Object> batch = Map.of(
                "hibernate.format_sql", "false",
                "hibernate.generate_statistics", "true",
                "hibernate.default_batch_fetch_size", "10");
        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("empresa", batch)) {
            probar(emf, "4. Batch fetching (default_batch_fetch_size = 10)", jpql, null);
        }
    }
}

Salida real (las listas de columnas de cada select se han sustituido por ...):

===== 1. Problema N+1 =====
Hibernate: select p1_0.pedido_id,... from pedidos p1_0 where p1_0.fecha_pedido>=? order by p1_0.pedido_id
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5016 Instituto Tecnológico Sur
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5017 Panadería La Espiga
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5018 Construcciones Levante S.A.
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5019 Clínica Dental Sonrisa
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5020 Hotel Mar Azul S.L.
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5021 Talleres Martín S.L.
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5022 Diseños Creativos S.L.
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5023 Laura Benítez Sanz
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id=?
  5024 Gestoría Delta
  >> Sentencias SQL ejecutadas: 10
===== 2. JOIN FETCH =====
Hibernate: select p1_0.pedido_id,p1_0.cliente_id,c1_0.cliente_id,c1_0.ciudad,...,p1_0.fecha_pedido from pedidos p1_0 join clientes c1_0 on c1_0.cliente_id=p1_0.cliente_id where p1_0.fecha_pedido>=? order by p1_0.pedido_id
  5016 Instituto Tecnológico Sur
  (... los 9 pedidos ...)
  5024 Gestoría Delta
  >> Sentencias SQL ejecutadas: 1
===== 3. @NamedEntityGraph =====
Hibernate: select p1_0.pedido_id,p1_0.cliente_id,c1_0.cliente_id,c1_0.ciudad,...,p1_0.fecha_pedido from pedidos p1_0 join clientes c1_0 on c1_0.cliente_id=p1_0.cliente_id where p1_0.fecha_pedido>=? order by p1_0.pedido_id
  5016 Instituto Tecnológico Sur
  (... los 9 pedidos ...)
  >> Sentencias SQL ejecutadas: 1
===== 4. Batch fetching (default_batch_fetch_size = 10) =====
Hibernate: select p1_0.pedido_id,... from pedidos p1_0 where p1_0.fecha_pedido>=? order by p1_0.pedido_id
Hibernate: select c1_0.cliente_id,... from clientes c1_0 where c1_0.cliente_id in (?,?,?,?,?,?,?,?,?,?)
  5016 Instituto Tecnológico Sur
  (... los 9 pedidos ...)
  >> Sentencias SQL ejecutadas: 2
  • N+1: 1 consulta de pedidos + 9 de clientes (los 9 pedidos de 2026 son de 9 clientes distintos; si se repitiera alguno, el contexto de persistencia evitaría su segunda consulta). Con 10 000 pedidos serían 10 001 viajes a la base de datos.
  • JOIN FETCH: una sola sentencia. Hay que escribirlo en cada consulta que lo necesite.
  • @NamedEntityGraph + la pista jakarta.persistence.fetchgraph: el mismo SQL que el JOIN FETCH, pero el grafo se define una vez y se aplica a cualquier consulta (o a find, pasando el grafo en el mapa de propiedades). Spring Data JPA lo usa con la anotación @EntityGraph en los métodos del repositorio.
  • Batch fetching: la consulta original no cambia; cuando se accede al primer proxy, Hibernate inicializa de golpe hasta 10 proxies pendientes con un IN (...). Fíjate en que rellena la lista hasta 10 parámetros aunque solo haya 9 ids, para reutilizar la misma sentencia preparada. Se configura globalmente con hibernate.default_batch_fetch_size o por relación con la anotación de Hibernate @BatchSize(size = 10).
Solución SQL Cuándo usarla
join fetch 1 consulta con JOIN Cuando una consulta concreta siempre necesita la relación
@EntityGraph / fetchgraph 1 consulta con JOIN El mismo grafo en varias consultas; Spring Data
Batch fetching 1 + ⌈N / tamaño⌉ consultas Colecciones, listados paginados, cuando no controlas las consultas
Proyección a DTO 1 consulta, solo columnas necesarias Listados de solo lectura (lo más eficiente)

⚠️ Importante: No intentes join fetch de dos colecciones a la vez (por ejemplo, Departamento.empleados y Departamento.proyectos): el resultado sería un producto cartesiano y, con List, Hibernate lanza MultipleBagFetchException. Haz fetch de una y usa batch fetching (o una segunda consulta) para la otra.


13. La caché de segundo nivel

La caché de primer nivel (módulo 14) vive dentro de cada EntityManager. La caché de segundo nivel (L2) es compartida por todos los EntityManager de la EntityManagerFactory: si un usuario ya cargó el departamento 10, el siguiente no necesita ir a la base de datos.

Cada EntityManager tiene su caché de primer nivel; la de segundo nivel la comparten todos y está delante de la base de datos

Figura 4. Cachés de primer y segundo nivel.

13.1 Configuración

Hibernate no implementa la caché: se integra con un proveedor a través del estándar JCache (JSR-107). Usaremos Ehcache 3:

<!-- Caché de segundo nivel: integración JCache + proveedor Ehcache 3 -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
    <version>${hibernate.version}</version>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.12.0</version>
    <classifier>jakarta</classifier>
    <exclusions>
        <!-- trae una versión antigua (javax) de JAXB; Hibernate ya incluye la de Jakarta -->
        <exclusion>
            <groupId>org.glassfish.jaxb</groupId>
            <artifactId>jaxb-runtime</artifactId>
        </exclusion>
    </exclusions>
</dependency>

⚠️ Importante: Usa el clasificador jakarta de Ehcache y la exclusión de jaxb-runtime: sin ellos, Maven intenta descargar artefactos javax antiguos de repositorios que ya no existen y la compilación falla con Could not collect dependencies … javax.xml.bind:jaxb-api.

En persistence.xml creamos una unidad empresa-cache (igual que empresa, con estas propiedades), y en la unidad normal desactivamos la caché explícitamente, porque con hibernate-jcache en el classpath Hibernate la activaría sola:

<persistence-unit name="empresa-cache" transaction-type="RESOURCE_LOCAL">
    <!-- ... las mismas <class> ... -->
    <shared-cache-mode>ENABLE_SELECTIVE</shared-cache-mode>
    <properties>
        <!-- ... conexión como siempre ... -->
        <property name="hibernate.cache.use_second_level_cache" value="true"/>
        <property name="hibernate.cache.region.factory_class" value="jcache"/>
        <property name="hibernate.javax.cache.provider" value="org.ehcache.jsr107.EhcacheCachingProvider"/>
        <property name="hibernate.javax.cache.missing_cache_strategy" value="create"/>
        <property name="hibernate.generate_statistics" value="true"/>
    </properties>
</persistence-unit>

<!-- en la unidad "empresa": -->
<property name="hibernate.cache.use_second_level_cache" value="false"/>

Con ENABLE_SELECTIVE, solo se cachean las entidades marcadas con @Cacheable. Elegimos Departamento, que cambia muy poco:

@Entity
@Table(name = "departamentos")
@Cacheable                                      // candidata a la caché de segundo nivel
@org.hibernate.annotations.Cache(usage = org.hibernate.annotations.CacheConcurrencyStrategy.READ_WRITE)
public class Departamento { /* ... */ }

13.2 La caché en acción

public class Ejemplo12CacheSegundoNivel {

    public static void main(String[] args) {
        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("empresa-cache")) {
            Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();

            System.out.println("--- EntityManager 1 ---");
            emf.runInTransaction(em -> System.out.println(em.find(Departamento.class, 10)));
            System.out.println("--- EntityManager 2 (otro contexto de persistencia) ---");
            emf.runInTransaction(em -> System.out.println(em.find(Departamento.class, 10)));

            System.out.println("Aciertos L2: " + stats.getSecondLevelCacheHitCount()
                    + ", fallos: " + stats.getSecondLevelCacheMissCount()
                    + ", guardados: " + stats.getSecondLevelCachePutCount());
            System.out.println("¿Está en caché? " + emf.getCache().contains(Departamento.class, 10));

            emf.getCache().evict(Departamento.class);       // vaciamos la región de Departamento
            System.out.println("--- EntityManager 3 (tras evict) ---");
            emf.runInTransaction(em -> System.out.println(em.find(Departamento.class, 10)));
        }
    }
}

Salida real:

WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by org.ehcache.impl.internal.concurrent.ThreadLocalRandomUtil (file:/root/.m2/repository/org/ehcache/ehcache/3.12.0/ehcache-3.12.0-jakarta.jar)
WARNING: Please consider reporting this to the maintainers of class org.ehcache.impl.internal.concurrent.ThreadLocalRandomUtil
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
--- EntityManager 1 ---
Hibernate: 
    select
        d1_0.dept_id,
        d1_0.localidad,
        d1_0.nombre,
        d1_0.presupuesto 
    from
        departamentos d1_0 
    where
        d1_0.dept_id=?
Departamento[10, DIRECCIÓN]
--- EntityManager 2 (otro contexto de persistencia) ---
Departamento[10, DIRECCIÓN]
Aciertos L2: 1, fallos: 1, guardados: 1
¿Está en caché? true
--- EntityManager 3 (tras evict) ---
Hibernate: select ... from departamentos d1_0 where d1_0.dept_id=?
Departamento[10, DIRECCIÓN]

El segundo EntityManager no ha ejecutado ningún SQL: el departamento ha salido de la caché compartida (1 acierto). Tras vaciarla con evict, vuelve a consultar. Las cuatro líneas WARNING iniciales las escribe la JVM (desde Java 24 avisa cuando una biblioteca usa métodos de sun.misc.Unsafe que se van a eliminar); no afectan al funcionamiento, pero indican que esa versión de Ehcache aún no está adaptada.

Estrategia de concurrencia Uso
READ_ONLY Datos que nunca cambian (catálogos fijos): la más rápida
NONSTRICT_READ_WRITE Cambian poco y se toleran lecturas ligeramente obsoletas
READ_WRITE Cambian a veces y se necesita coherencia (usa bloqueos ligeros)
TRANSACTIONAL Solo con proveedores transaccionales (JTA)

⚠️ Importante: La caché L2 no es “gratis”: consume memoria, puede servir datos obsoletos si otra aplicación modifica la base de datos directamente (Hibernate no se entera) y en un despliegue con varios servidores hace falta una caché distribuida. Úsala solo para entidades muy leídas y poco modificadas (departamentos, categorías, provincias…), y mide antes y después. Existe también una caché de consultas (hibernate.cache.use_query_cache + la pista org.hibernate.cacheable en la consulta) que guarda los ids que devuelve una consulta; úsala con aún más cuidado.


14. Bloqueo optimista con @Version

Dos empleados de almacén abren a la vez la ficha del portátil UltraBook (stock 25). El primero vende 1 unidad y guarda (24). El segundo, que sigue viendo 25, vende 2 y guarda 23. Resultado: se ha perdido la venta del primero. Es el problema de la actualización perdida (lost update).

📘 Concepto: El bloqueo optimista supone que los conflictos son raros y no bloquea nada al leer. Cada fila tiene un número de versión; al guardar, Hibernate comprueba que la versión sigue siendo la que leyó (... where id = ? and version = ?) y la incrementa. Si otro la cambió entretanto, el UPDATE no afecta a ninguna fila y Hibernate lanza OptimisticLockException.

@Entity
@Table(name = "productos")
public class Producto {
    // ... resto de atributos (ahora con @ManyToOne(fetch = LAZY) categoria)

    @Version                          // bloqueo optimista (columna añadida con el script de la sección 1)
    private Integer version;
}

Línea de tiempo: A y B leen la versión 0; A guarda la versión 1; el UPDATE de B con version=0 no afecta a ninguna fila

Figura 5. Bloqueo optimista (arriba) y pesimista (abajo).

public class Ejemplo13Optimista {

    public static void main(String[] args) {
        try (EntityManager em1 = JpaUtil.crearEntityManager();     // usuario A
             EntityManager em2 = JpaUtil.crearEntityManager()) {   // usuario B

            em1.getTransaction().begin();
            em2.getTransaction().begin();
            Producto pA = em1.find(Producto.class, 1);
            Producto pB = em2.find(Producto.class, 1);
            System.out.println("A lee: " + pA);
            System.out.println("B lee: " + pB);

            pA.setStock(pA.getStock() - 1);                // A vende 1 unidad
            System.out.println("--- commit de A ---");
            em1.getTransaction().commit();
            System.out.println("A guarda: " + pA);

            pB.setStock(pB.getStock() - 2);                // B vende 2, pero con datos ANTIGUOS
            System.out.println("--- commit de B ---");
            try {
                em2.getTransaction().commit();
            } catch (RuntimeException e) {
                System.out.println("B falla: " + e.getClass().getSimpleName());
                System.out.println("  causa: " + e.getCause().getClass().getName());
            }
        }

        // Restauramos el stock original (25)
        JpaUtil.getEmf().runInTransaction(em -> {
            Producto p = em.find(Producto.class, 1);
            p.setStock(25);
            System.out.println("Restaurado: " + p);
        });
        JpaUtil.cerrar();
    }
}

Salida real (de la primera ejecución, con la columna version recién creada):

Hibernate: 
    select
        p1_0.producto_id,
        ...
        p1_0.version 
    from
        productos p1_0 
    where
        p1_0.producto_id=?
Hibernate: select ... from productos p1_0 where p1_0.producto_id=?
A lee: Producto[1, Portátil UltraBook 14, precio=899.00, stock=25, v0]
B lee: Producto[1, Portátil UltraBook 14, precio=899.00, stock=25, v0]
--- commit de A ---
Hibernate: 
    update
        productos 
    set
        categoria_id=?,
        descatalogado=?,
        descripcion=?,
        fecha_alta=?,
        nombre=?,
        precio=?,
        stock=?,
        version=? 
    where
        producto_id=? 
        and version=?
A guarda: Producto[1, Portátil UltraBook 14, precio=899.00, stock=24, v1]
--- commit de B ---
Hibernate: update productos set ... where producto_id=? and version=?
B falla: RollbackException
  causa: jakarta.persistence.OptimisticLockException
Hibernate: select ... from productos p1_0 where p1_0.producto_id=?
Restaurado: Producto[1, Portátil UltraBook 14, precio=899.00, stock=25, v1]
Hibernate: update productos set ... where producto_id=? and version=?

El UPDATE de A lleva and version=? (0) y pone version=? (1). El de B busca la versión 0, que ya no existe: 0 filas afectadas, OptimisticLockException y rollback. La venta de A no se pierde. Lo que debe hacer la aplicación con B es releer el producto y reintentar (o avisar al usuario de que alguien modificó los datos): lo implementarás en el ejercicio 6.

💡 Consejo: El bloqueo optimista es la opción recomendada en aplicaciones web: no bloquea nada en la base de datos y el coste es una columna y una condición en el WHERE. El campo @Version puede ser int, Integer, long, Long, short o un Instant/LocalDateTime (marca de tiempo); los enteros son más fiables. No lo modifiques nunca a mano.


15. Bloqueo pesimista

📘 Concepto: El bloqueo pesimista supone que el conflicto es probable y bloquea la fila en la base de datos al leerla (SELECT ... FOR UPDATE). Otras transacciones que intenten bloquearla esperan hasta que la primera termine (o fallan si se indica un tiempo de espera).

public class Ejemplo14Pesimista {

    public static void main(String[] args) {
        try (EntityManager em1 = JpaUtil.crearEntityManager();
             EntityManager em2 = JpaUtil.crearEntityManager()) {

            em1.getTransaction().begin();
            System.out.println("--- A bloquea el producto 2 ---");
            Producto p = em1.find(Producto.class, 2, LockModeType.PESSIMISTIC_WRITE);
            System.out.println("A tiene el bloqueo: " + p);

            em2.getTransaction().begin();
            System.out.println("--- B intenta bloquearlo sin esperar (timeout 0) ---");
            try {
                em2.find(Producto.class, 2, LockModeType.PESSIMISTIC_WRITE,
                        Map.of("jakarta.persistence.lock.timeout", 0));
            } catch (RuntimeException e) {
                System.out.println("B no puede: " + e.getClass().getSimpleName());
            }
            em2.getTransaction().rollback();

            p.setStock(p.getStock() - 1);
            em1.getTransaction().commit();               // libera el bloqueo
            System.out.println("A termina: " + p);

            em1.getTransaction().begin();                // restauramos el stock (10)
            em1.find(Producto.class, 2).setStock(10);
            em1.getTransaction().commit();
        }
        JpaUtil.cerrar();
    }
}

Salida real (de la primera ejecución):

--- A bloquea el producto 2 ---
Hibernate: 
    select
        p1_0.producto_id,
        ...
        p1_0.version 
    from
        productos p1_0 
    where
        p1_0.producto_id=? 
    for
        updateof p1_0
A tiene el bloqueo: Producto[2, Portátil Gaming 16, precio=1499.00, stock=10, v0]
--- B intenta bloquearlo sin esperar (timeout 0) ---
Hibernate: 
    select
        ...
    where
        p1_0.producto_id=? 
    for
        updateof p1_0 nowait
WARN  org.hibernate.orm.jdbc.error - HHH000247: ErrorCode: 3572, SQLState: HY000
WARN  org.hibernate.orm.jdbc.error - Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.
B no puede: LockTimeoutException
Hibernate: update productos set ... where producto_id=? and version=?
A termina: Producto[2, Portátil Gaming 16, precio=1499.00, stock=9, v1]
Hibernate: update productos set ... where producto_id=? and version=?

La sentencia real es select ... for update of p1_0 (el formateador de Hibernate pega update y of en la salida: updateof). Con lock.timeout = 0, Hibernate añade nowait y MySQL responde al instante con el error 3572, que Hibernate traduce a LockTimeoutException. Sin esa pista, B se quedaría esperando hasta que A hiciera commit (o hasta el innodb_lock_wait_timeout de MySQL, 50 segundos por defecto).

LockModeType Efecto
OPTIMISTIC Comprueba la versión al final de la transacción aunque la entidad no se modifique
OPTIMISTIC_FORCE_INCREMENT Además, incrementa la versión
PESSIMISTIC_READ Bloqueo compartido: otros pueden leer, nadie puede modificar
PESSIMISTIC_WRITE Bloqueo exclusivo (for update)
PESSIMISTIC_FORCE_INCREMENT Exclusivo + incrementa la versión

⚠️ Importante: Los bloqueos pesimistas reducen la concurrencia y pueden provocar interbloqueos (deadlocks) si dos transacciones bloquean filas en distinto orden (lo viste con hilos en el módulo 06). Mantén esas transacciones muy cortas, bloquea siempre en el mismo orden y usa un tiempo de espera. Para un simple descuento de stock, a menudo basta con un UPDATE atómico: update ... set stock = stock - ? where id = ? and stock >= ? (lo verás en el módulo 17).


16. Estadísticas de Hibernate

Con hibernate.generate_statistics = true, Hibernate cuenta todo lo que hace. Es la forma más fiable de detectar problemas como el N+1 en las pruebas automáticas (por ejemplo, comprobar con AssertJ que un listado ejecuta como mucho 2 sentencias).

public class Ejemplo15Estadisticas {

    public static void main(String[] args) {
        Map<String, Object> props = Map.of(
                "hibernate.show_sql", "false",
                "hibernate.generate_statistics", "true");
        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("empresa", props)) {
            Statistics st = emf.unwrap(SessionFactory.class).getStatistics();

            // Una pequeña "carga de trabajo"
            emf.runInTransaction(em -> {
                for (Departamento d : em.createQuery("select d from Departamento d", Departamento.class)
                        .getResultList()) {
                    d.getEmpleados().size();                        // inicializa cada colección
                }
                em.createQuery("select e from Empleado e where e.salario > 3000", Empleado.class)
                        .getResultList();
                em.find(Empleado.class, 100).setSalario(em.find(Empleado.class, 100).getSalario());
            });

            System.out.println("Sesiones abiertas ............ " + st.getSessionOpenCount());
            System.out.println("Transacciones ................ " + st.getTransactionCount());
            System.out.println("Sentencias JDBC preparadas ... " + st.getPrepareStatementCount());
            System.out.println("Consultas JPQL ejecutadas .... " + st.getQueryExecutionCount());
            System.out.println("Entidades cargadas ........... " + st.getEntityLoadCount());
            System.out.println("Colecciones cargadas ......... " + st.getCollectionLoadCount());
            System.out.println("Entidades actualizadas ....... " + st.getEntityUpdateCount());
            System.out.println("Consulta más lenta ........... " + st.getQueryExecutionMaxTimeQueryString());
        }
    }
}

Salida real:

Sesiones abiertas ............ 1
Transacciones ................ 1
Sentencias JDBC preparadas ... 9
Consultas JPQL ejecutadas .... 2
Entidades cargadas ........... 30
Colecciones cargadas ......... 7
Entidades actualizadas ....... 0
Consulta más lenta ........... select d from Departamento d

Interpretación: 2 consultas JPQL, pero 9 sentencias: las 7 restantes son la carga de la colección de empleados de cada departamento (otro N+1, esta vez con una colección). 30 entidades cargadas = 7 departamentos + 23 empleados con departamento. Ningún UPDATE, porque asignar el mismo salario no ensucia la entidad. Y el find del empleado 100 no generó sentencia: ya estaba en el contexto.

💡 Consejo: Activa las estadísticas en desarrollo y pruebas, no en producción (tienen un pequeño coste). Además de estos contadores, st.getQueryStatistics("select ...") da el tiempo medio y máximo de cada consulta, y con el logger org.hibernate.SQL_SLOW Hibernate puede avisar de las consultas que superen un umbral (hibernate.log_slow_query, en milisegundos).


17. Buenas prácticas de rendimiento

Práctica Por qué
Todas las relaciones LAZY y cargar lo necesario por consulta (join fetch, entity graph) Evita cargar datos que no se usan y el N+1 de EAGER
Proyecciones a DTO para listados e informes Solo las columnas necesarias, sin contexto de persistencia ni dirty checking
hibernate.default_batch_fetch_size (10-50) Red de seguridad contra el N+1 en colecciones y relaciones
Paginar siempre los listados, con orden único Nunca cargar tablas enteras
No paginar con join fetch de colecciones Hibernate paginaría en memoria
Operaciones masivas con update/delete JPQL Una sentencia en lugar de N
flush() + clear() cada N elementos en procesos por lotes Evita que el contexto de persistencia crezca sin límite
Transacciones de solo lectura donde proceda (@Transactional(readOnly = true) en Spring) Hibernate se ahorra el dirty checking
Índices en las columnas de where, join y order by El ORM no sustituye al diseño de la base de datos
Caché L2 solo para datos muy leídos y poco modificados La caché mal usada sirve datos obsoletos y consume memoria
@Version en las entidades que se editan concurrentemente Evita actualizaciones perdidas sin bloquear
Mirar el SQL y contar sentencias en las pruebas Los problemas de rendimiento no dan errores: hay que buscarlos

18. Ejercicios

Los ejercicios 3 y 4 crean la fábrica sin el log de SQL para que la salida sea breve; en el resto se muestra solo la sentencia principal.

Ejercicio 1: Altas recientes con un record anidado

Lista los empleados dados de alta desde el 1 de enero de 2021 (fecha, nombre completo y departamento, o “(sin departamento)” si no tienen) ordenados por fecha, proyectando a un record declarado dentro de la clase del ejercicio.

Ver solución
public class Ejercicio1 {

    record Alta(String empleado, String departamento, LocalDate fecha) { }

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            List<Alta> altas = em.createQuery("""
                    select new es.curso.empresa.ejercicios.Ejercicio1$Alta(
                               concat(e.nombre, ' ', e.apellidos),
                               coalesce(d.nombre, '(sin departamento)'),
                               e.fechaAlta)
                    from Empleado e left join e.departamento d
                    where e.fechaAlta >= :desde
                    order by e.fechaAlta""", Alta.class)
                    .setParameter("desde", LocalDate.of(2021, 1, 1))
                    .getResultList();
            altas.forEach(a -> System.out.printf("%s  %-22s %s%n", a.fecha(), a.empleado(), a.departamento()));
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        concat(e1_0.nombre, ' ', e1_0.apellidos),
        coalesce(d1_0.nombre, '(sin departamento)'),
        e1_0.fecha_alta 
    from
        empleados e1_0 
    left join
        departamentos d1_0 
            on d1_0.dept_id=e1_0.dept_id 
    where
        e1_0.fecha_alta>=? 
    order by
        e1_0.fecha_alta
2021-04-12  Álvaro Peña Cano       MARKETING
2021-09-01  Sergio Jiménez Vidal   VENTAS
2022-06-01  Sara Ortiz Delgado     INFORMÁTICA
2022-10-17  Manuel Herrero Lozano  LOGÍSTICA
2023-01-09  Jorge Vázquez Ramos    CONTABILIDAD
2025-09-01  Tomás Reyes Fuentes    (sin departamento)

Dos detalles: el left join explícito es imprescindible para que aparezca Tomás (con e.departamento.nombre el join implícito sería inner y lo perderíamos), y un record anidado se nombra en select new con $ (Ejercicio1$Alta), igual que su nombre binario en Java.

Ejercicio 2: Ventas mensuales de 2025

Calcula, para cada mes de 2025, el número de pedidos y el importe vendido (excluyendo los cancelados), y el total del año. Usa extract en JPQL y una proyección a record.

Ver solución
public class Ejercicio2 {

    record VentasMes(Integer mes, Long pedidos, BigDecimal importe) { }

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            List<VentasMes> meses = em.createQuery("""
                    select new es.curso.empresa.ejercicios.Ejercicio2$VentasMes(
                               extract(month from p.fechaPedido),
                               count(distinct p),
                               sum(l.cantidad * l.precioUnitario * (1 - l.descuento / 100)))
                    from LineaPedido l join l.pedido p
                    where extract(year from p.fechaPedido) = :anio
                      and p.estado <> es.curso.empresa.modelo.EstadoPedido.CANCELADO
                    group by extract(month from p.fechaPedido)
                    order by 1""", VentasMes.class)
                    .setParameter("anio", 2025)
                    .getResultList();
            BigDecimal total = BigDecimal.ZERO;
            for (VentasMes m : meses) {
                System.out.printf("Mes %2d: %d pedidos  %10.2f €%n", m.mes(), m.pedidos(), m.importe());
                total = total.add(m.importe());
            }
            System.out.printf("Total 2025:        %10.2f €%n", total);
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        month(p1_0.fecha_pedido),
        count(distinct p1_0.pedido_id),
        sum(((lp1_0.cantidad*lp1_0.precio_unitario)*(1-(lp1_0.descuento/100)))) 
    from
        lineas_pedido lp1_0 
    join
        pedidos p1_0 
            on p1_0.pedido_id=lp1_0.pedido_id 
    where
        year(p1_0.fecha_pedido)=? 
        and p1_0.estado<>'CANCELADO' 
    group by
        month(p1_0.fecha_pedido) 
    order by
        1
Mes  1: 2 pedidos     9205.40 €
Mes  2: 2 pedidos     6791.40 €
Mes  3: 2 pedidos     1375.20 €
Mes  4: 1 pedidos     2539.13 €
Mes  5: 1 pedidos     4772.44 €
Mes  6: 2 pedidos    12656.50 €
Mes  9: 1 pedidos     1658.80 €
Mes 10: 1 pedidos     1555.00 €
Mes 11: 2 pedidos      358.80 €
Total 2025:          40912.67 €

En mayo solo cuenta un pedido: el 5008 está cancelado. extract(month ...) devuelve Integer, por eso el primer componente del record es Integer. Julio y agosto no aparecen porque no hubo pedidos; si el informe debiera mostrar los doce meses, habría que rellenar los huecos en Java.

Ejercicio 3: Buscador de productos con Criteria

Escribe un buscador de productos no descatalogados con cuatro filtros opcionales: texto contenido en el nombre (sin distinguir mayúsculas), precio máximo, categoría y “solo con stock”. Debe devolver los resultados ordenados por precio y el número total de coincidencias (con una segunda consulta count). Reutiliza la construcción de los predicados para ambas consultas.

Ver solución
public class Ejercicio3 {

    /** Filtros opcionales del buscador de productos (null = sin filtro). */
    record Filtro(String texto, BigDecimal precioMaximo, Integer categoriaId, boolean soloConStock) { }

    static List<Predicate> predicados(CriteriaBuilder cb, Root<Producto> p, Filtro f) {
        List<Predicate> lista = new ArrayList<>();
        lista.add(cb.isFalse(p.get("descatalogado")));
        if (f.texto() != null) {
            lista.add(cb.like(cb.lower(p.get("nombre")), "%" + f.texto().toLowerCase() + "%"));
        }
        if (f.precioMaximo() != null) {
            lista.add(cb.le(p.get("precio"), f.precioMaximo()));
        }
        if (f.categoriaId() != null) {
            lista.add(cb.equal(p.get("categoria").get("id"), f.categoriaId()));
        }
        if (f.soloConStock()) {
            lista.add(cb.gt(p.get("stock"), 0));
        }
        return lista;
    }

    static void buscar(EntityManager em, Filtro f) {
        CriteriaBuilder cb = em.getCriteriaBuilder();

        CriteriaQuery<Producto> cq = cb.createQuery(Producto.class);
        Root<Producto> p = cq.from(Producto.class);
        cq.where(predicados(cb, p, f).toArray(Predicate[]::new)).orderBy(cb.asc(p.get("precio")));
        List<Producto> resultado = em.createQuery(cq).getResultList();

        CriteriaQuery<Long> cuenta = cb.createQuery(Long.class);
        Root<Producto> pc = cuenta.from(Producto.class);
        cuenta.select(cb.count(pc)).where(predicados(cb, pc, f).toArray(Predicate[]::new));
        Long total = em.createQuery(cuenta).getSingleResult();

        System.out.println(f + " -> " + total + " productos");
        resultado.forEach(x -> System.out.println("   " + x.getNombre() + " " + x.getPrecio() + " (stock " + x.getStock() + ")"));
    }

    public static void main(String[] args) {
        try (var emf = Persistence.createEntityManagerFactory("empresa", Map.of("hibernate.show_sql", "false"));
             EntityManager em = emf.createEntityManager()) {
            buscar(em, new Filtro("portátil", null, null, false));
            buscar(em, new Filtro(null, new BigDecimal("100"), 5, true));
            buscar(em, new Filtro("monitor", new BigDecimal("200"), null, true));
        }
    }
}

Salida real:

Filtro[texto=portátil, precioMaximo=null, categoriaId=null, soloConStock=false] -> 3 productos
   Portátil Básico 15 499.00 (stock 40)
   Portátil UltraBook 14 899.00 (stock 25)
   Portátil Gaming 16 1499.00 (stock 10)
Filtro[texto=null, precioMaximo=100, categoriaId=5, soloConStock=true] -> 2 productos
   Ratón inalámbrico 19.90 (stock 200)
   Teclado mecánico 79.90 (stock 60)
Filtro[texto=monitor, precioMaximo=200, categoriaId=null, soloConStock=true] -> 1 productos
   Monitor 24 FHD 139.00 (stock 35)

El pack de teclado y ratón (categoría 5, 34,50 €) no aparece en la segunda búsqueda porque tiene stock 0. Los predicados se construyen dos veces porque cada Root pertenece a su propia CriteriaQuery. El filtro descatalogado funciona con isFalse gracias al conversor SiNoConverter: Hibernate compara con 'N'. p.get("categoria").get("id") no genera JOIN: compara directamente la columna categoria_id.

Ejercicio 4: Paginación con un record Pagina

Crea un record genérico Pagina<T>(List<T> contenido, int numero, int tamano, long totalElementos) con los métodos totalPaginas() y hayMas(), y un método que devuelva una página de clientes ordenados por provincia y nombre. Recorre todas las páginas de 4 en 4.

Ver solución
public class Ejercicio4 {

    record Pagina<T>(List<T> contenido, int numero, int tamano, long totalElementos) {
        long totalPaginas() { return (totalElementos + tamano - 1) / tamano; }
        boolean hayMas() { return numero + 1 < totalPaginas(); }
    }

    static Pagina<Cliente> clientes(EntityManager em, int numero, int tamano) {
        long total = em.createQuery("select count(c) from Cliente c", Long.class).getSingleResult();
        List<Cliente> lista = em.createQuery(
                "select c from Cliente c order by c.provincia, c.nombre", Cliente.class)
                .setFirstResult(numero * tamano)
                .setMaxResults(tamano)
                .getResultList();
        return new Pagina<>(lista, numero, tamano, total);
    }

    public static void main(String[] args) {
        try (var emf = Persistence.createEntityManagerFactory("empresa", Map.of("hibernate.show_sql", "false"));
             EntityManager em = emf.createEntityManager()) {
            int n = 0;
            Pagina<Cliente> pag;
            do {
                pag = clientes(em, n, 4);
                System.out.println("Página " + (pag.numero() + 1) + " de " + pag.totalPaginas() + ":");
                pag.contenido().forEach(c -> System.out.println("   " + c.getProvincia() + " - " + c.getNombre()));
                n++;
            } while (pag.hayMas());
        }
    }
}

Salida real:

Página 1 de 4:
   Alicante - Construcciones Levante S.A.
   Asturias - Farmacia Central
   Barcelona - Diseños Creativos S.L.
   Bizkaia - Laura Benítez Sanz
Página 2 de 4:
   Granada - Instituto Tecnológico Sur
   Illes Balears - Hotel Mar Azul S.L.
   Madrid - Academia Nova
   Madrid - María José Herrera
Página 3 de 4:
   Sevilla - Antonio Ruiz Pérez
   Sevilla - Clínica Dental Sonrisa
   Valencia - Carlos Ibáñez Rey
   Valencia - Talleres Martín S.L.
Página 4 de 4:
   Valladolid - Panadería La Espiga
   Zaragoza - Gestoría Delta

Es, en pequeño, lo que hacen Pageable y Page de Spring Data (módulo 19). El orden por provincia y nombre garantiza que no haya empates que muevan filas entre páginas. En una aplicación real, la consulta count se puede evitar en las páginas siguientes a la primera si el total no cambia.

Ejercicio 5: De 10 sentencias a 1

Muestra para cada proyecto su departamento, el número de personas asignadas y las horas totales. Hazlo primero navegando por las relaciones y después con una única consulta de agregación con left join, y compara el número de sentencias con las estadísticas.

Ver solución
public class Ejercicio5 {

    record ResumenProyecto(String proyecto, String departamento, Long personas, Long horas) { }

    public static void main(String[] args) {
        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("empresa",
                Map.of("hibernate.show_sql", "false", "hibernate.generate_statistics", "true"))) {
            Statistics st = emf.unwrap(SessionFactory.class).getStatistics();

            // Versión ingenua: navegar por las relaciones perezosas
            st.clear();
            try (EntityManager em = emf.createEntityManager()) {
                for (Proyecto p : em.createQuery("select p from Proyecto p order by p.id", Proyecto.class)
                        .getResultList()) {
                    int horas = p.getAsignaciones().stream().mapToInt(a -> a.getHoras()).sum();
                    System.out.println("  " + p.getNombre() + " | " + p.getDepartamento().getNombre()
                            + " | " + p.getAsignaciones().size() + " personas | " + horas + " h");
                }
            }
            System.out.println("Versión ingenua: " + st.getPrepareStatementCount() + " sentencias");

            // Versión optimizada: una sola consulta de agregación con proyección a record
            st.clear();
            try (EntityManager em = emf.createEntityManager()) {
                List<ResumenProyecto> lista = em.createQuery("""
                        select new es.curso.empresa.ejercicios.Ejercicio5$ResumenProyecto(
                                   p.nombre, d.nombre, count(a), coalesce(sum(a.horas), 0))
                        from Proyecto p join p.departamento d left join p.asignaciones a
                        group by p.id, p.nombre, d.nombre
                        order by p.id""", ResumenProyecto.class).getResultList();
                lista.forEach(r -> System.out.println("  " + r.proyecto() + " | " + r.departamento()
                        + " | " + r.personas() + " personas | " + r.horas() + " h"));
            }
            System.out.println("Versión optimizada: " + st.getPrepareStatementCount() + " sentencia(s)");
        }
    }
}

Salida real:

  Migración a la nube | INFORMÁTICA | 3 personas | 1430 h
  Nueva tienda online | INFORMÁTICA | 4 personas | 1350 h
  Campaña Navidad 2025 | MARKETING | 4 personas | 480 h
  Optimización de almacén | LOGÍSTICA | 3 personas | 300 h
  Plan estratégico 2027 | DIRECCIÓN | 0 personas | 0 h
Versión ingenua: 10 sentencias
  Migración a la nube | INFORMÁTICA | 3 personas | 1430 h
  Nueva tienda online | INFORMÁTICA | 4 personas | 1350 h
  Campaña Navidad 2025 | MARKETING | 4 personas | 480 h
  Optimización de almacén | LOGÍSTICA | 3 personas | 300 h
  Plan estratégico 2027 | DIRECCIÓN | 0 personas | 0 h
Versión optimizada: 1 sentencia(s)

Mismo resultado, una décima parte de sentencias. Las 10 de la versión ingenua son: 1 de proyectos + 5 colecciones de asignaciones + 4 departamentos (INFORMÁTICA se repite y sale del contexto de persistencia). El left join mantiene el proyecto 5 aunque no tenga asignaciones, y coalesce(sum(...), 0) evita el null de sumar un conjunto vacío.

Ejercicio 6: Reintento ante un conflicto de versión

Escribe retirarStock(productoId, cantidad) que use el bloqueo optimista y, si se produce un conflicto de versión, reintente hasta tres veces leyendo de nuevo el producto. Simula que, durante el primer intento, otro usuario retira una unidad del mismo producto. Si no hay stock suficiente, lanza IllegalStateException. Restaura el stock al terminar.

Ver solución
public class Ejercicio6 {

    static EntityManagerFactory emf;
    static boolean simularConflicto = true;     // solo en el primer intento

    /** Retira unidades del stock; si otro usuario modificó el producto, reintenta (máx. 3 veces). */
    static void retirarStock(int productoId, int cantidad) {
        for (int intento = 1; intento <= 3; intento++) {
            try (EntityManager em = emf.createEntityManager()) {
                em.getTransaction().begin();
                Producto p = em.find(Producto.class, productoId);
                if (p.getStock() < cantidad) {
                    em.getTransaction().rollback();
                    throw new IllegalStateException("Stock insuficiente de " + p.getNombre());
                }
                if (simularConflicto) {                 // otro "usuario" se adelanta
                    simularConflicto = false;
                    emf.runInTransaction(otro -> otro.find(Producto.class, productoId)
                            .setStock(otro.find(Producto.class, productoId).getStock() - 1));
                    System.out.println("  (otro usuario ha retirado 1 unidad mientras tanto)");
                }
                p.setStock(p.getStock() - cantidad);
                em.getTransaction().commit();
                System.out.println("Intento " + intento + ": OK, stock = " + p.getStock() + ", versión " + p.getVersion());
                return;
            } catch (RollbackException e) {
                if (!(e.getCause() instanceof OptimisticLockException)) throw e;
                System.out.println("Intento " + intento + ": conflicto de versión, se reintenta");
            }
        }
        throw new IllegalStateException("No se pudo retirar el stock tras 3 intentos");
    }

    public static void main(String[] args) {
        emf = Persistence.createEntityManagerFactory("empresa", Map.of("hibernate.show_sql", "false"));
        try {
            Producto antes = emf.callInTransaction(em -> em.find(Producto.class, 9));
            System.out.println("Antes: stock = " + antes.getStock() + ", versión " + antes.getVersion());
            retirarStock(9, 5);
            try {
                retirarStock(9, 1000);
            } catch (IllegalStateException e) {
                System.out.println("Error: " + e.getMessage());
            }
        } finally {
            emf.runInTransaction(em -> em.find(Producto.class, 9).setStock(35));   // stock original
            emf.close();
        }
    }
}

Salida real:

Antes: stock = 35, versión 0
  (otro usuario ha retirado 1 unidad mientras tanto)
Intento 1: conflicto de versión, se reintenta
Intento 2: OK, stock = 29, versión 2
Error: Stock insuficiente de Monitor 24 FHD

El primer intento leyó la versión 0; el “otro usuario” la subió a 1, así que el commit falló. El segundo intento empieza con un EntityManager nuevo, lee los datos actuales (stock 34, versión 1) y guarda 29 con versión 2: ninguna de las dos retiradas se pierde. Es imprescindible un EntityManager nuevo en cada intento: el anterior queda inservible tras el rollback. Spring ofrece este patrón con anotaciones (@Retryable, módulo 17).

Ejercicios propuestos

Ejercicio 7: Ranking de comerciales

Con una sola consulta JPQL, obtén para cada vendedor (empleado con pedidos asignados) el número de pedidos, el número de clientes distintos y el importe total vendido en 2025 y 2026 por separado (pista: sum(case when extract(year from p.fechaPedido) = 2025 then ... else 0 end)). Ordena por el total y proyecta a un record.

Ejercicio 8: @NamedEntityGraph con subgrafo

Define un grafo Pedido.completo que cargue el cliente, las líneas y el producto de cada línea (@NamedSubgraph). Úsalo con em.find(Pedido.class, 5010, Map.of("jakarta.persistence.fetchgraph", grafo)) y comprueba en el log que se genera una sola consulta. ¿Qué diferencia hay entre fetchgraph y loadgraph?

Ejercicio 9: Metamodelo

Añade hibernate-processor al pom.xml, compila y localiza las clases generadas en target/generated-sources/annotations. Reescribe el buscador del ejercicio 3 usando Producto_.nombre, Producto_.precio, etc. Introduce a propósito un error de tipo (comparar Producto_.stock con un String) y observa que ahora falla al compilar.

Ejercicio 10: Procesamiento por lotes

Escribe un programa que inserte 2000 clientes de prueba (NIF T0000001 a T0002000) haciendo flush() y clear() cada 50, mida el tiempo y cuente las sentencias con las estadísticas. Repite con hibernate.jdbc.batch_size = 50. ¿Cambia algo con la estrategia IDENTITY? Borra después los clientes con un delete masivo.

Ejercicio 11: Caché de categorías

Marca Categoria como @Cacheable con READ_ONLY y escribe un programa que recorra el árbol de categorías tres veces, cada vez con un EntityManager nuevo, mostrando los aciertos y fallos de la caché L2 tras cada recorrido. ¿Se cachean también las colecciones hijas? Investiga @Cache sobre una colección.

Ejercicio 12: Bloqueo pesimista con espera

Modifica el ejemplo 14 para que B espere con jakarta.persistence.lock.timeout = 3000 (3 segundos) en un hilo virtual (módulo 07) mientras A mantiene el bloqueo 1 segundo antes de hacer commit. Muestra los instantes en que B pide y obtiene el bloqueo. ¿Qué ocurre si A tarda 5 segundos?


Resumen

Concepto Sintaxis / clave
JPQL em.createQuery("select e from Empleado e where ...", Empleado.class)
Parámetros :nombre + setParameter("nombre", v), ?1 + setParameter(1, v)
Resultados getResultList(), getSingleResult(), getResultStream()
Proyecciones Escalares, Object[], Tuple, select new paquete.Dto(...)
Joins join, left join, join ... on, navegación con . (inner implícito)
Carga en la consulta join fetch, left join fetch
Agregados count, sum, avg, min, max, group by, having
Subconsultas exists, not exists, in, not in, escalares en where
Funciones concat, upper, coalesce, case, extract(year from ...)
Paginación setFirstResult(pagina * tamano), setMaxResults(tamano) + order by único
Consultas con nombre @NamedQuery(name, query) + createNamedQuery (validadas al arrancar)
Criteria API CriteriaBuilder, CriteriaQuery, Root, Predicate; metamodelo Entidad_
SQL nativo createNativeQuery(sql[, Entidad.class])
Masivas update ... set ... where, delete from ... where + executeUpdate(); luego clear()/refresh()
N+1 1 + N consultas al acceder a relaciones LAZY en un bucle
Soluciones N+1 join fetch, @NamedEntityGraph + jakarta.persistence.fetchgraph, default_batch_fetch_size/@BatchSize, DTO
Caché L2 hibernate-jcache + proveedor, use_second_level_cache, @Cacheable + @Cache(usage = ...)
Bloqueo optimista @Version; update ... where id = ? and version = ?; OptimisticLockException → reintentar
Bloqueo pesimista LockModeType.PESSIMISTIC_WRITE (for update), jakarta.persistence.lock.timeout
Estadísticas hibernate.generate_statistics, emf.unwrap(SessionFactory.class).getStatistics()