Módulo 05
crítico
Días 11–12
≈ 9 h de estudio activo
Spring Data JPA e Hibernate sin sorpresas
JPA es la tecnología que más productividad regala y más disgustos causa, y por el mismo motivo: escribes
objetos y se ejecuta SQL que no ves. Este módulo te enseña a ver ese SQL, a entender el
contexto de persistencia (de donde salen el 80 % de los comportamientos «raros»), a matar los N+1 con
conocimiento de causa, a manejar transacciones, bloqueos y concurrencia como en producción, y a versionar el
esquema sin cortar el servicio. No es un catálogo de anotaciones: es el mapa mental que separa a quien
«usa Spring Data» de quien sabe qué está pasando por debajo.
Progreso de este módulo0 / 0
Cómo leer este módulo: las secciones 1 a 5 son los cimientos y hay que leerlas en orden: sin el
contexto de persistencia (sección 5), todo lo demás parece magia arbitraria. Las secciones 6 y 7
(consultas y rendimiento) son las que más te van a preguntar en una entrevista técnica. Las 8 y 9
(transacciones y concurrencia) son las que evitan incidentes de datos corruptos. Las 10 a 14 son el
conocimiento de producción: cachés, migraciones, patrones, testing y diagnóstico. Si tienes prisa y ya has
trabajado con JPA, ve directo a 5, 7, 8 y 9: es donde están los agujeros más habituales.
Requisito previo: este módulo asume que dominas SQL y el modelo relacional (módulo 06) y que
entiendes el contenedor de Spring, los proxies y la AOP (módulo 04). Si no sabes leer un
EXPLAIN ANALYZE ni por qué un @Transactional se aplica con un proxy, vuelve a esos
dos módulos antes de seguir: aquí se dan por sabidos.
18
preguntas de entrevista
1 · Del JDBC al ORM: por qué existe JPA
No se puede entender JPA sin entender el dolor que vino a resolver. Y no se puede usar bien sin entender el
dolor nuevo que introduce. Esta sección es corta en código y larga en criterio: es la que te permitirá decir
en una entrevista «para este caso no usaría JPA, usaría esto otro, y por estos motivos», que es una
respuesta que vale mucho más que recitar anotaciones.
1.1 El punto de partida: JDBC a mano
JDBC es la API de bajo nivel de Java para hablar con una base de datos relacional. Funciona, es rápida y no
esconde nada. El problema es la cantidad de código ceremonial que exige para algo tan trivial como leer un
pedido con sus líneas.
// JDBC puro: leer un pedido con sus líneas. Todo correcto y todo a mano.
public Pedido cargarPedido(long id) {
String sql = """
select p.id, p.referencia, p.estado, p.total,
l.id as linea_id, l.cantidad, l.precio_unitario, l.producto_id
from pedido p
left join linea_pedido l on l.pedido_id = p.id
where p.id = ?
""";
try (Connection cn = dataSource.getConnection();
PreparedStatement ps = cn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
Pedido pedido = null;
while (rs.next()) {
if (pedido == null) { // cabecera: solo la primera vez
pedido = new Pedido(
rs.getLong("id"),
rs.getString("referencia"),
Estado.valueOf(rs.getString("estado")),
rs.getBigDecimal("total"));
}
long lineaId = rs.getLong("linea_id");
if (!rs.wasNull()) { // ojo: getLong devuelve 0, no null
pedido.lineas().add(new Linea(
lineaId,
rs.getInt("cantidad"),
rs.getBigDecimal("precio_unitario"),
rs.getLong("producto_id")));
}
}
return pedido; // puede ser null: el llamante debe acordarse
}
} catch (SQLException e) {
throw new DataAccessException("No se pudo cargar el pedido " + id, e);
}
}
Cuenta lo que hay ahí que no es lógica de negocio: abrir y cerrar recursos, poner parámetros
por posición (y que un día alguien añada una condición en medio y desplace los índices), convertir tipos a
mano, deduplicar la cabecera porque el LEFT JOIN la repite por cada línea, tratar el
wasNull, traducir SQLException. Multiplica esto por 40 entidades y 200 consultas y
tienes un proyecto donde el 60 % del código de la capa de datos es ceremonia.
1.2 El desajuste objeto-relacional
El problema de fondo tiene nombre: object-relational impedance mismatch. El modelo de objetos y el
modelo relacional resuelven cosas distintas y no encajan de forma natural. Estas son las cinco fricciones
concretas, porque cada una explica una parte del diseño de JPA:
| Fricción | En objetos | En relacional | Qué hace JPA con ello |
| Granularidad |
Puedes tener una clase Direccion con cinco campos. |
No merece una tabla propia; son cinco columnas de cliente. |
@Embeddable / @Embedded: un objeto, varias columnas de la misma tabla. |
| Herencia |
Natural: PagoTarjeta extends Pago. |
No existe. Solo hay tablas y columnas. |
Cuatro estrategias (SINGLE_TABLE, JOINED…), ninguna gratis. Sección 3.6. |
| Identidad |
Dos: == (referencia) y equals (valor). |
Una: la clave primaria. |
El contexto de persistencia garantiza == para la misma clave. Sección 5.3. |
| Asociaciones |
Referencias con dirección: pedido.getCliente(). |
Claves foráneas sin dirección: un JOIN se navega en ambos sentidos. |
Lado propietario y mappedBy, con la carga de mantener la coherencia. Sección 4. |
| Navegación |
Salto a salto: a.getB().getC().getD(), y cada salto es barato. |
De golpe: se declara todo el JOIN y se ejecuta una vez. |
Carga perezosa con proxies… que es exactamente el origen del N+1. Sección 7. |
Fíjate en la última fila, porque es la más importante del módulo: el N+1 no es un bug de Hibernate,
es la consecuencia inevitable de fingir que navegar por objetos es barato cuando por debajo hay red y
disco. Si entiendes eso, ya no necesitas memorizar soluciones: las deduces.
1.3 Qué ganas y qué pagas con un ORM
Lo que ganas
- Menos código repetitivo. El ejemplo anterior son 4 líneas con JPA.
- Un modelo de dominio real. Objetos con comportamiento, no bolsas de datos.
- Escrituras automáticas. Modificas el objeto y el dirty checking genera el
UPDATE mínimo.
- Portabilidad razonable entre motores (nunca total, pero real).
- Caché de primer nivel gratis: la misma fila no se lee dos veces en una transacción.
- Bloqueo optimista con una anotación (
@Version) en lugar de un protocolo a mano.
- Ecosistema: auditoría, soft delete, migraciones, testing, todo integrado con Spring.
Lo que pagas
- El SQL es implícito. Hasta que lo mires, no sabes cuántas consultas hace tu endpoint.
- Abstracción con fugas. Para usarlo bien necesitas saber JPA y SQL, no menos.
- Curva de aprendizaje engañosa. Tres días para el CRUD, tres años para el resto.
- Fallos en tiempo de ejecución difíciles de anticipar (
LazyInitializationException, N+1, MultipleBagFetchException).
- Malo para lo masivo: cargar un millón de entidades para actualizarlas es la peor forma de hacerlo.
- Malo para informes: agregaciones, ventanas y CTE viven mejor en SQL.
- Coste de memoria: cada entidad gestionada lleva una copia instantánea para el dirty checking.
La frase que resume el módulo: un ORM es un acelerador de escritura transaccional sobre un
modelo de dominio. Cuanto más se parece tu caso a «lee un agregado pequeño, modifícalo, guárdalo»,
más gana JPA. Cuanto más se parece a «recorre diez millones de filas y agrégalas», más pierde. Casi todas
las malas experiencias con JPA vienen de usarlo para lo segundo.
1.4 JPA, Hibernate y Spring Data JPA: quién es quién
Esta confusión es tan frecuente que es literalmente la primera pregunta de muchas entrevistas. Son
tres capas distintas, y saber en cuál estás te dice dónde buscar la documentación y qué
puedes cambiar sin reescribir nada.
| Capa | Qué es | Artefacto | Ejemplos de lo que aporta | ¿Se puede cambiar? |
| JDBC |
API estándar de Java para hablar con una base de datos. El suelo. |
java.sql + driver (org.postgresql:postgresql) |
Connection, PreparedStatement, ResultSet, transacciones nativas. |
Solo cambiando de motor. |
| Jakarta Persistence (JPA) |
Una especificación: interfaces y anotaciones. No ejecuta nada por sí sola. |
jakarta.persistence-api (JPA 3.2 en Boot 3.5) |
@Entity, @ManyToOne, EntityManager, JPQL, Criteria API. |
Es el contrato; no se cambia. |
| Hibernate ORM |
La implementación de JPA que usa el 95 % del mundo Java. Aporta muchísimo más que la especificación. |
org.hibernate.orm:hibernate-core (6.6.x) |
@BatchSize, @Formula, @SQLRestriction, StatelessSession, filtros, Envers, estadísticas. |
Sí: EclipseLink u OpenJPA, pero perderías todo lo específico. |
Spring ORM / JpaTransactionManager |
El pegamento: integra el EntityManager con las transacciones declarativas de Spring. |
spring-orm |
@Transactional, EntityManager compartido por hilo, traducción de excepciones. |
En la práctica, no. |
| Spring Data JPA |
Generación de repositorios a partir de interfaces. Azúcar muy útil, pero solo azúcar. |
spring-data-jpa |
JpaRepository, métodos derivados, @Query, Pageable, Specification, auditoría. |
Sí: puedes usar JPA sin Spring Data. |
// La misma operación en las tres capas de arriba hacia abajo.
// 1) Spring Data JPA: declaras la intención
public interface PedidoRepository extends JpaRepository<Pedido, Long> {
Optional<Pedido> findByReferencia(String referencia);
}
// 2) JPA estándar: lo que Spring Data genera por debajo
@PersistenceContext EntityManager em;
Optional<Pedido> buscar(String referencia) {
return em.createQuery("select p from Pedido p where p.referencia = :r", Pedido.class)
.setParameter("r", referencia)
.getResultStream()
.findFirst();
}
// 3) Hibernate específico: cuando la especificación no llega
Session session = em.unwrap(Session.class);
Statistics stats = session.getSessionFactory().getStatistics(); // no existe en JPA
session.setDefaultReadOnly(true); // tampoco
Por qué importa esta separación: cuando algo no funciona, tienes que saber a quién preguntar. Si
findByReferenciaAndEstadoIn no compila el nombre, es Spring Data. Si el JPQL da error de
sintaxis, es JPA/Hibernate. Si el SQL generado es horrible, es Hibernate. Si la conexión no se devuelve al
pool, es Spring ORM o tu @Transactional. Buscar «spring data jpa lento» en Google cuando el
problema es un plan de ejecución te va a hacer perder una tarde.
Trampa de vocabulario en entrevistas: si alguien dice «Spring Data JPA no soporta funciones de
ventana», está confundiendo capas. Spring Data no soporta ni no soporta SQL: pasa la consulta a Hibernate. Lo
que no soporta ventanas es JPQL, y se resuelve con nativeQuery = true o
JdbcClient. Distinguir esto en voz alta es una señal fuerte de seniority.
1.5 La jerarquía de repositorios de Spring Data
Muchos proyectos extienden JpaRepository por costumbre y exponen así 20 métodos que nadie quería
(incluidos deleteAll() y findAll() sin paginar, que son bombas de relojería).
Conocer la jerarquía te permite exponer solo lo que necesitas.
| Interfaz | Qué añade | Cuándo usarla |
Repository<T, ID> |
Nada: solo marca la interfaz para que Spring Data la implemente. |
Cuando quieres control absoluto de la API expuesta. Es la más recomendable en arquitecturas limpias. |
CrudRepository |
save, saveAll, findById, existsById, findAll, count, delete*. |
CRUD simple sin paginación. |
ListCrudRepository |
Lo mismo, pero devuelve List en vez de Iterable. |
Siempre preferible a CrudRepository desde Spring Data 3: menos fricción. |
PagingAndSortingRepository |
findAll(Pageable), findAll(Sort). |
Cuando necesitas paginación genérica. |
JpaRepository |
flush, saveAndFlush, deleteAllInBatch, getReferenceById, y todo lo anterior. |
Lo habitual en aplicaciones normales; asume que sabes lo que expones. |
JpaSpecificationExecutor |
findAll(Specification), count(Specification), findBy(spec, fn). |
Filtros dinámicos combinables. Sección 6.6. |
QuerydslPredicateExecutor |
findAll(Predicate) con la API tipada de Querydsl. |
Filtros dinámicos con seguridad de tipos real. Sección 6.7. |
// Repositorio mínimo y honesto: expone exactamente lo que el dominio necesita.
// Nadie podrá llamar a findAll() sin paginar ni a deleteAll() desde un controlador.
public interface PedidoRepository extends Repository<Pedido, Long> {
Optional<Pedido> findById(Long id);
Optional<Pedido> findByReferencia(String referencia);
Page<Pedido> findByClienteId(Long clienteId, Pageable pageable);
Pedido save(Pedido pedido);
boolean existsByReferencia(String referencia);
}
Regla práctica: en el núcleo del dominio, extiende Repository y declara los métodos a
mano. En módulos de soporte o CRUD administrativo, ListCrudRepository o
JpaRepository están bien. El coste de JpaRepository no es de rendimiento, es de
diseño: cada método público es una invitación a usarlo mal desde el sitio equivocado.
1.6 Alternativas a JPA y cuándo elegir cada una
JPA no es la única forma de hablar con una base de datos desde Spring, y en 2026 elegir bien es una
competencia esperada de un perfil senior. Estas son las opciones reales, todas con soporte de primer nivel.
| Tecnología | Modelo mental | Puntos fuertes | Puntos débiles | Cuándo elegirla |
| Spring Data JPA (Hibernate) |
Objetos gestionados con estado y sincronización automática. |
Productividad en CRUD, dominio rico, caché, bloqueo optimista, ecosistema enorme. |
Complejidad conceptual, SQL implícito, malo para lo masivo y los informes. |
Aplicaciones de negocio transaccionales con agregados: el 70 % de los casos. |
| Spring Data JDBC |
Agregados de DDD sin sesión ni carga perezosa. Cargas y guardas el agregado completo. |
Simplicidad radical: sin proxies, sin N+1 sorpresa, sin LazyInitializationException. Fácil de razonar. |
Sin caché, sin bloqueo perezoso, relaciones limitadas (no hay @ManyToMany real), menos maduro en herramientas. |
Microservicios pequeños, agregados bien delimitados, equipos que sufren con JPA. |
| jOOQ |
SQL tipado generado desde el esquema. Escribes SQL, pero lo comprueba el compilador. |
Todo el poder del motor (ventanas, CTE, jsonb), errores en compilación, planes predecibles. |
Licencia comercial para motores propietarios, paso de generación de código, no gestiona estado. |
Informes, ETL, dominios muy orientados a consulta, equipos con SQL fuerte. |
| MyBatis |
SQL en XML o anotaciones, mapeado a objetos a mano. |
Control total del SQL, curva suave, muy usado en banca y en Asia. |
Mapeos verbosos, sin seguridad de tipos, sin gestión de estado. |
Migración de código legado con SQL existente, DBAs que quieren revisar cada consulta. |
JdbcClient (Spring 6.1+) |
JDBC sin ceremonia, con parámetros nombrados y mapeo automático a record. |
Cero abstracción sorpresa, ideal para consultas puntuales, conviven perfectamente con JPA. |
Sin tipado del SQL, sin generación de esquema. |
Las 10 consultas de informe de una aplicación que por lo demás usa JPA. Combinación ganadora. |
| R2DBC (Spring Data R2DBC) |
Acceso reactivo no bloqueante con Mono/Flux. |
Miles de conexiones lógicas con pocos hilos; presión de vuelta (backpressure) real. |
Sin ORM (no hay JPA reactivo), transacciones más difíciles, depuración dolorosa, los hilos virtuales le han quitado casi toda la razón de ser. |
Streaming de muchísimas filas o integración en una arquitectura ya reactiva. Rara vez es la mejor opción nueva en 2026. |
// ---------------------------------------------------------------------------
// La misma consulta de informe en cuatro tecnologías, para que veas el tono
// de cada una. Objetivo: facturación por país en un rango de fechas.
// ---------------------------------------------------------------------------
// 1) JPA con JPQL y proyección a record: legible, suficiente, sin ventanas
record FacturacionPais(String pais, BigDecimal total, long pedidos) { }
@Query("""
select new com.ejemplo.tienda.informes.FacturacionPais(
c.pais, sum(p.total), count(p))
from Pedido p join p.cliente c
where p.creadoEn >= :desde and p.creadoEn < :hasta
group by c.pais
order by sum(p.total) desc
""")
List<FacturacionPais> facturacionPorPais(Instant desde, Instant hasta);
// 2) JdbcClient: SQL directo, mapeo automático al record por nombre de columna
List<FacturacionPais> porPais = jdbcClient.sql("""
select c.pais,
sum(p.total) as total,
count(*) as pedidos,
rank() over (order by sum(p.total) desc) as puesto
from pedido p
join cliente c on c.id = p.cliente_id
where p.creado_en >= :desde and p.creado_en < :hasta
group by c.pais
""")
.param("desde", desde)
.param("hasta", hasta)
.query(FacturacionPais.class)
.list();
// 3) jOOQ: el mismo SQL, pero si te equivocas en un nombre no compila
var r = dsl.select(CLIENTE.PAIS,
sum(PEDIDO.TOTAL).as("total"),
count().as("pedidos"))
.from(PEDIDO).join(CLIENTE).on(CLIENTE.ID.eq(PEDIDO.CLIENTE_ID))
.where(PEDIDO.CREADO_EN.between(desde, hasta))
.groupBy(CLIENTE.PAIS)
.orderBy(sum(PEDIDO.TOTAL).desc())
.fetchInto(FacturacionPais.class);
// 4) Spring Data JDBC: el agregado completo, sin sesión ni perezosos
// (para informes se acaba usando JdbcClient igualmente)
@Query("select ... ") // SQL nativo directamente; no hay JPQL
List<FacturacionPais> facturacion(Instant desde, Instant hasta);
La respuesta madura a «¿JPA o SQL?»: las dos, en la misma aplicación. JPA para el flujo
transaccional (crear pedido, confirmar, cancelar) donde el dominio manda; JdbcClient o jOOQ
para las consultas de lectura complejas donde manda el SQL. Comparten el mismo DataSource y la
misma transacción de Spring, así que no hay ningún problema técnico en mezclarlos. Lo único que hay que
cuidar: si escribes con JPA y lees con SQL en la misma transacción, haz flush antes de leer
(sección 5.7).
1.7 El modelo de referencia que usaremos en todo el módulo
Todos los ejemplos del módulo usan la misma tienda, para que puedas seguir el hilo sin recontextualizar cada
vez. Es deliberadamente pequeño pero con todas las formas de relación: un @ManyToOne, un
@OneToMany con composición, un @OneToOne, un @ManyToMany con atributos
y una jerarquía de herencia.
┌─────────────┐ ┌──────────────┐ ┌────────────────┐
│ cliente │ 1 N │ pedido │ 1 N │ linea_pedido │
│─────────────│◄────────│──────────────│◄───────│────────────────│
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ email (UK) │ │ referencia │ │ pedido_id (FK) │
│ nombre │ │ cliente_id │ │ producto_id(FK)│
│ pais │ │ estado │ │ cantidad │
│ activo │ │ total │ │ precio_unit. │
│ creado_en │ │ version │ └───────┬────────┘
└─────────────┘ │ envio_* (emb)│ │ N
│ metadatos │ │
│ creado_en │ ▼ 1
└──────┬───────┘ ┌────────────────┐
│ 1 │ producto │
▼ 0..1 │────────────────│
┌──────────────┐ │ id (PK) │
│ pago │ │ sku (UK) │
│──────────────│ │ nombre │
│ id (PK) │ │ categoria_id │
│ pedido_id(UK)│ │ precio │
│ importe │ │ stock │
│ tipo (disc.) │ │ atributos jsonb│
│ ... │ └───────┬────────┘
└──────────────┘ │ N N
herencia: ▼
PagoTarjeta ┌────────────────┐
PagoTransferencia │producto_etiqueta│
│────────────────│
│ producto_id(FK)│
│ etiqueta_id(FK)│
│ añadida_en │
└────────────────┘
-- ============================================================================
-- V1__esquema_inicial.sql — el esquema que veremos mapeado durante el módulo.
-- PostgreSQL 16. Fíjate en que el esquema es de primera clase: lo escribimos
-- nosotros, NO lo genera Hibernate (motivos en la sección 2.4 y en la 11).
-- ============================================================================
create table cliente (
id bigint generated by default as identity primary key,
email varchar(255) not null unique,
nombre varchar(150) not null,
pais char(2) not null,
activo boolean not null default true,
creado_en timestamptz not null default now()
);
create sequence pedido_seq start with 1 increment by 50; -- ver sección 3.2
create table pedido (
id bigint primary key default nextval('pedido_seq'),
referencia varchar(36) not null unique,
cliente_id bigint not null references cliente (id),
estado varchar(20) not null,
total numeric(12,2) not null default 0,
moneda char(3) not null default 'EUR',
envio_calle varchar(200),
envio_cp varchar(10),
envio_ciudad varchar(100),
envio_pais char(2),
metadatos jsonb not null default '{}'::jsonb,
version bigint not null default 0,
creado_en timestamptz not null default now(),
actualizado_en timestamptz,
creado_por varchar(100),
actualizado_por varchar(100),
constraint ck_pedido_estado
check (estado in ('BORRADOR','CONFIRMADO','PAGADO','ENVIADO','ENTREGADO','CANCELADO')),
constraint ck_pedido_total_no_negativo check (total >= 0)
);
create index ix_pedido_cliente on pedido (cliente_id);
create index ix_pedido_estado_creado on pedido (estado, creado_en desc);
create index ix_pedido_creado_id on pedido (creado_en desc, id desc); -- paginación keyset
create table categoria (
id bigint generated by default as identity primary key,
nombre varchar(80) not null unique,
padre_id bigint references categoria (id)
);
create table producto (
id bigint generated by default as identity primary key,
sku varchar(32) not null unique,
nombre varchar(200) not null,
categoria_id bigint not null references categoria (id),
precio numeric(12,2) not null,
stock integer not null default 0,
activo boolean not null default true,
atributos jsonb not null default '{}'::jsonb,
version bigint not null default 0,
constraint ck_producto_stock_no_negativo check (stock >= 0)
);
create table linea_pedido (
id bigint generated by default as identity primary key,
pedido_id bigint not null references pedido (id) on delete cascade,
producto_id bigint not null references producto (id),
cantidad integer not null,
precio_unitario numeric(12,2) not null,
constraint ck_linea_cantidad_positiva check (cantidad > 0),
constraint uk_linea_pedido_producto unique (pedido_id, producto_id)
);
create index ix_linea_pedido on linea_pedido (pedido_id);
-- Herencia SINGLE_TABLE: una tabla, una columna discriminadora
create table pago (
id bigint generated by default as identity primary key,
pedido_id bigint not null unique references pedido (id),
tipo varchar(20) not null, -- TARJETA | TRANSFERENCIA
importe numeric(12,2) not null,
estado varchar(20) not null,
-- específicos de TARJETA
ultimos_cuatro char(4),
marca varchar(20),
-- específicos de TRANSFERENCIA
iban varchar(34),
fecha_valor date,
creado_en timestamptz not null default now()
);
create table etiqueta (
id bigint generated by default as identity primary key,
nombre varchar(50) not null unique
);
-- Relación N:M con atributo propio: por eso será una ENTIDAD, no un @ManyToMany
create table producto_etiqueta (
producto_id bigint not null references producto (id) on delete cascade,
etiqueta_id bigint not null references etiqueta (id),
anadida_en timestamptz not null default now(),
primary key (producto_id, etiqueta_id)
);
create index ix_producto_etiqueta_etiqueta on producto_etiqueta (etiqueta_id);
Detalle que ya cuenta una historia: pedido usa una secuencia con
increment by 50 y cliente usa identity. No es incoherencia: es la
decisión de la sección 3.2. Los pedidos se insertan en lotes grandes y necesitan batching; los
clientes se crean de uno en uno. Guarda la duda, la resolveremos con números.
Lo que tienes que llevarte de la sección 1
- JPA existe para eliminar el código ceremonial de JDBC y permitir un modelo de dominio real, no para «no escribir SQL».
- El precio es que el SQL pasa a ser implícito: si no lo miras, no sabes lo que hace tu aplicación.
- JPA es la especificación, Hibernate la implementación, Spring Data el generador de repositorios. Tres capas, tres sitios donde buscar.
- El N+1 es estructural: nace de fingir que navegar por objetos es gratis. Entenderlo es más útil que memorizar arreglos.
- La arquitectura ganadora en 2026 suele ser JPA para escribir + SQL directo para informes, no una u otra.
- Spring Data JDBC es la alternativa seria cuando el equipo sufre con la sesión de Hibernate; R2DBC ha perdido casi toda su razón de ser con los hilos virtuales.
2 · Configuración y arranque
La configuración de JPA en Spring Boot es engañosamente fácil: añades una dependencia y funciona. El problema
es que los valores por defecto están pensados para que arranque, no para que vaya bien en
producción. Esta sección es la lista de decisiones que sí tienes que tomar tú, cada una con su
justificación.
2.1 Dependencias: qué entra y qué no
<!-- pom.xml — Spring Boot 3.5.x, Java 21, PostgreSQL 16 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Trae: spring-data-jpa + hibernate-core 6.6 + spring-orm + HikariCP + jakarta.persistence-api -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Driver: en runtime basta, no lo necesitas en compilación -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Migraciones: el esquema lo controlas tú, no Hibernate -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- Métricas de Hikari e Hibernate expuestas por Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Tests con base de datos real. Nunca H2 para tests de integración (sección 13.2) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Y el equivalente en Gradle, con el plugin de gestión de dependencias de Boot y el
annotation processor de metamodelo, que te da clases Pedido_ tipadas para Criteria API y
Specification (secciones 6.5 y 6.6):
// build.gradle.kts
plugins {
java
id("org.springframework.boot") version "3.5.3"
id("io.spring.dependency-management") version "1.1.7"
}
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.flywaydb:flyway-core")
runtimeOnly("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
// Genera Pedido_, Cliente_… para Criteria API con seguridad de tipos
annotationProcessor("org.hibernate.orm:hibernate-jpamodelgen")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:postgresql")
}
Qué te da exactamente el starter: spring-data-jpa,
hibernate-core, spring-orm, spring-jdbc,
HikariCP, jakarta.persistence-api y jakarta.transaction-api. Ejecuta
mvn dependency:tree -Dincludes=org.hibernate* una vez en tu proyecto: saber qué versión exacta de
Hibernate estás usando importa, porque el comportamiento de @ManyToOne perezoso, de los tipos
JSON y del batching ha cambiado entre 5.x y 6.x.
2.2 application.yml comentado línea a línea
Este es el fichero que uso como punto de partida en cualquier servicio nuevo. Cada línea tiene un motivo, y
todas las que siguen se explican en las subsecciones posteriores. Cópialo, pero no lo copies a ciegas.
# =============================================================================
# application.yml — base común (perfil por defecto)
# =============================================================================
spring:
application:
name: tienda-pedidos
datasource:
url: jdbc:postgresql://localhost:5432/tienda
username: app_tienda
password: ${DB_PASSWORD} # nunca en el fichero; variable o secreto
driver-class-name: org.postgresql.Driver
hikari:
pool-name: tienda-pool # aparece en logs y métricas: ponle nombre
maximum-pool-size: 10 # fórmula y razonamiento en la sección 2.5
minimum-idle: 10 # = maximum en servicios con carga constante
connection-timeout: 3000 # ms esperando conexión libre → fallar rápido
validation-timeout: 2000
idle-timeout: 600000 # 10 min (irrelevante si min == max)
max-lifetime: 1740000 # 29 min: SIEMPRE menor que el timeout del servidor
keepalive-time: 300000 # ping cada 5 min para que no muera por inactividad
leak-detection-threshold: 20000 # avisa de conexiones no devueltas en 20 s
auto-commit: false # Hibernate gestiona el commit; evita un round-trip
data-source-properties:
ApplicationName: tienda-pedidos # se ve en pg_stat_activity. Oro puro al depurar
reWriteBatchedInserts: true # el driver agrupa INSERTs de verdad (sección 7.6)
tcpKeepAlive: true
socketTimeout: 30 # segundos: red muerta ≠ colgado para siempre
jpa:
open-in-view: false # NO NEGOCIABLE. Motivos en la sección 8.10
properties:
hibernate:
# --- SQL y esquema ---
format_sql: false # true solo si lees los logs a mano
highlight_sql: false
use_sql_comments: false # true en desarrollo: añade /* comentario */ al SQL
# --- Rendimiento de lectura ---
default_batch_fetch_size: 25 # mitiga N+1 en relaciones perezosas (sección 7.3)
# --- Rendimiento de escritura ---
jdbc.batch_size: 50 # agrupa INSERT/UPDATE en lotes
jdbc.batch_versioned_data: true # permite batching con @Version
order_inserts: true # reordena para que los lotes sean homogéneos
order_updates: true
# --- Tipos y comportamiento ---
jdbc.time_zone: UTC # la JVM y la BD hablan siempre en UTC
query.in_clause_parameter_padding: true # menos planes distintos en la BD
query.fail_on_pagination_over_collection_fetch: true # convierte el aviso en error
# --- Diagnóstico (ver sección 2.8) ---
generate_statistics: false # true solo en dev y en tests
session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 300
hibernate:
ddl-auto: validate # ver sección 2.4. NUNCA update
open-in-view: false
flyway:
enabled: true
locations: classpath:db/migration
validate-on-migrate: true
clean-disabled: true # por defecto ya es true en Flyway 10. Déjalo así
transaction:
default-timeout: 10 # segundos: red de seguridad global
threads:
virtual:
enabled: true # Boot 3.5 + Java 21. Ojo con el pool: sección 14.4
logging:
level:
org.hibernate.SQL: INFO # se sube a DEBUG solo en dev
org.hibernate.orm.jdbc.bind: INFO
org.hibernate.stat: INFO
management:
endpoints.web.exposure.include: health,info,metrics,prometheus
endpoint.health.show-details: when-authorized
metrics.enable.hikaricp: true
# =============================================================================
# Perfil de desarrollo: aquí sí quieres ver todo
# =============================================================================
---
spring:
config.activate.on-profile: dev
jpa:
properties:
hibernate:
format_sql: true
highlight_sql: true
use_sql_comments: true
generate_statistics: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
org.hibernate.stat: DEBUG
org.hibernate.SQL_SLOW: INFO
# =============================================================================
# Perfil de producción: silencio, pool ajustado y cero sorpresas
# =============================================================================
---
spring:
config.activate.on-profile: prod
datasource:
hikari:
maximum-pool-size: ${DB_POOL_SIZE:10}
minimum-idle: ${DB_POOL_SIZE:10}
jpa:
hibernate.ddl-auto: validate
properties:
hibernate:
generate_statistics: false
session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 500
El error de configuración más común: poner
spring.jpa.properties.hibernate.jdbc.batch_size creyendo que activa el batching y no
comprobar nunca que funciona. Con GenerationType.IDENTITY el batching de inserciones
está desactivado en silencio (sección 7.6) y con el driver de PostgreSQL sin
reWriteBatchedInserts=true se envían igualmente sentencias individuales. Configurar sin medir es
superstición.
2.3 Dialecto y versión del motor
El dialecto es la clase de Hibernate que sabe traducir a las particularidades de tu motor: cómo se
pagina, cómo se declara una secuencia, qué tipos existen. En Spring Boot 3 no hace falta
declararlo: se detecta a partir de los metadatos de la conexión al arrancar.
# Lo habitual: NO declares el dialecto. Boot lo detecta y acierta.
# Si lo declaras a mano, tarde o temprano el driver, el motor y el dialecto
# dejarán de estar de acuerdo y tendrás bugs sutilísimos.
# Solo hay dos motivos legítimos para tocarlo:
spring:
jpa:
properties:
hibernate:
# 1) Fijar la versión mínima del motor para que Hibernate use lo mejor
# disponible (16 habilita, por ejemplo, mejores UPSERT y merges).
dialect.postgresql.version: 16
# 2) Un dialecto propio para registrar funciones específicas del motor
# y poder llamarlas desde JPQL.
# dialect: com.ejemplo.tienda.jpa.PostgresConAcentos
// Cuándo SÍ tiene sentido un dialecto propio: registrar funciones del motor
// para poder usarlas desde JPQL sin bajar a SQL nativo.
public class PostgresConAcentos extends PostgreSQLDialect {
public PostgresConAcentos() { super(DatabaseVersion.make(16)); }
@Override
public void initializeFunctionRegistry(FunctionContributions fc) {
super.initializeFunctionRegistry(fc);
var registry = fc.getFunctionRegistry();
var tipos = fc.getTypeConfiguration().getBasicTypeRegistry();
// unaccent(texto) → búsquedas insensibles a acentos desde JPQL
registry.registerPattern("unaccent", "unaccent(?1)",
tipos.resolve(StandardBasicTypes.STRING));
// similitud trigram: order by similarity(nombre, :q) desc
registry.registerPattern("similarity", "similarity(?1, ?2)",
tipos.resolve(StandardBasicTypes.DOUBLE));
}
}
// Ahora esto es JPQL válido:
// select p from Producto p where unaccent(lower(p.nombre)) like unaccent(lower(:q))
Alternativa moderna y más sencilla: desde Hibernate 6 puedes usar la función genérica
function('unaccent', p.nombre) en JPQL sin dialecto propio. El dialecto solo aporta si vas a usar
la función en muchos sitios y quieres que el compilador de JPQL conozca su tipo de retorno.
2.4 ddl-auto: los cinco valores y por qué nunca update
| Valor | Qué hace al arrancar | Dónde es aceptable | Riesgo |
none |
Nada. El esquema es responsabilidad externa. |
Producción, si confías en Flyway/Liquibase y no quieres ni la validación. |
Ninguno, pero pierdes la red de seguridad de validate. |
validate |
Compara entidades con el esquema real y falla el arranque si falta una tabla o una columna. |
Producción y preproducción: esta es la respuesta correcta. |
Ninguno. Es la que convierte un error de mapeo en un fallo de despliegue en lugar de un 500 a las tres de la mañana. |
update |
Añade tablas, columnas e índices que faltan. Nunca borra ni modifica nada. |
En ningún entorno serio. Como mucho, un prototipo desechable de un día. |
Altísimo: ver el bloque de abajo. |
create |
Borra y recrea el esquema al arrancar. |
Tests unitarios de mapeo con base de datos desechable. |
Pérdida total de datos si apunta al sitio equivocado. |
create-drop |
Crea al arrancar y borra al parar. |
Tests. Es el valor por defecto con una base de datos embebida. |
Igual que create. |
Por qué ddl-auto=update es una de las peores ideas del ecosistema Java. No es que sea
«poco elegante»: es que hace exactamente lo que no quieres, de forma silenciosa. Seis razones concretas:
- Es aditivo y ciego. Si renombras un campo de
direccion a
direccionEnvio, crea la columna nueva y deja la vieja con todos los datos.
Nadie se enterará hasta que alguien pregunte por qué la tabla tiene 40 columnas y 12 están vacías.
- No cambia tipos ni restricciones. Pasas
varchar(50) a
varchar(200) en la entidad y en la base de datos sigue habiendo 50. El fallo aparece en
producción, con datos reales, al insertar el nombre 51.
- No hay migraciones de datos. Añades una columna
canal y necesitas
rellenarla con el valor de otra. update no sabe hacer eso. Nunca.
- No es reproducible. El esquema resultante depende del historial de arranques de esa
base de datos concreta. Preproducción y producción divergen y nadie puede decir en qué.
- Es una carrera en despliegues múltiples. Con tres réplicas arrancando a la vez, tres
procesos ejecutan DDL simultáneo. Los bloqueos de
ALTER TABLE y los errores de «relación ya
existe» son la consecuencia amable; una tabla a medias es la desagradable.
- Requiere un usuario con permisos de DDL en tiempo de ejecución. Es decir, la aplicación
que sirve tráfico puede hacer
CREATE y ALTER. Eso convierte cualquier inyección
SQL en un incidente mucho más grave.
# La configuración correcta y definitiva
spring:
jpa:
hibernate:
ddl-auto: validate # el esquema lo controla Flyway; esto solo comprueba
flyway:
enabled: true
# En tests de integración con Testcontainers: TAMBIÉN validate + Flyway.
# Así los tests verifican de paso que tus migraciones y tus entidades coinciden,
# que es uno de los bugs más caros de descubrir tarde (sección 13.6).
// Lo que ves cuando validate hace su trabajo. Este mensaje en el arranque de
// CI vale más que diez revisiones de código:
org.hibernate.tool.schema.spi.SchemaManagementException:
Schema-validation: missing column [canal] in table [pedido]
// Traducción: alguien añadió el campo a la entidad y olvidó la migración.
// El despliegue falla ANTES de recibir tráfico. Exactamente lo que queríamos.
Cómo usar Hibernate para escribir la migración (sin dejarle tocar la base de datos): genera el DDL a
un fichero, léelo, corrígelo y pégalo en tu migración de Flyway. El borrador te ahorra tiempo; la revisión
humana evita los desastres.
# Genera el DDL en un fichero SIN ejecutarlo. Úsalo como BORRADOR de la
# migración, nunca como migración final: le faltarán índices, comentarios,
# CHECK, nombres de restricción sensatos y CONCURRENTLY.
spring:
jpa:
properties:
jakarta.persistence.schema-generation:
scripts:
action: create
create-target: target/esquema-borrador.sql
create-source: metadata
2.5 HikariCP: el pool, con la fórmula y los timeouts
El pool de conexiones es, en la mayoría de los servicios Spring Boot, el límite real de
concurrencia de tu aplicación. Y casi siempre está mal configurado en la misma dirección: demasiado
grande. Este es el razonamiento completo.
Por qué un pool grande no acelera
Una base de datos relacional no ejecuta 200 consultas en paralelo por muchas conexiones que le abras: tiene
un número finito de núcleos y de canales de E/S. A partir de cierto punto, cada conexión extra solo añade
cambios de contexto, contención de latches, más memoria por sesión y colas más largas. El resultado
es que el rendimiento total baja y la latencia sube, que es lo contrario de lo que buscabas.
Fórmula de referencia (documentación de HikariCP, basada en el trabajo de
Oracle y en las mediciones de PostgreSQL):
conexiones = ((núcleos_de_CPU_de_la_BD × 2) + husos_de_disco_efectivos)
· 4 núcleos + SSD (1 "huso" efectivo) → (4 × 2) + 1 = 9 → usa 10
· 8 núcleos + SSD → (8 × 2) + 1 = 17 → usa 16–20
· 16 núcleos + NVMe → (16 × 2) + 1 = 33 → usa 32
Y ahora el reparto real, que es lo que se olvida:
conexiones_por_instancia = presupuesto_total / número_de_instancias
Postgres con max_connections = 100, y reservas:
- 5 para superusuario (superuser_reserved_connections)
- 5 para herramientas, migraciones y psql de urgencia
- 10 para otros servicios que comparten la BD
= 80 disponibles
Con 6 réplicas de tu servicio: 80 / 6 ≈ 13 → maximum-pool-size: 12
Comprobación mental: ¿cuántas peticiones simultáneas necesitan BD?
Si cada petición ocupa la conexión 5 ms y el pool tiene 10 conexiones,
el techo teórico es 10 / 0,005 = 2.000 peticiones/segundo.
Si tu tráfico pico son 300 rps, el pool NO es tu problema.
El dato que convence a los escépticos: el propio equipo de HikariCP publicó el caso de un cliente que
bajó de 2.048 a 96 conexiones y vio caer el tiempo de respuesta del percentil 99 de
100 ms a 2 ms. No es un truco de configuración: es dejar de pedirle a la base de datos que
haga malabares con trabajo que no puede paralelizar.
Cada timeout, qué significa y qué le pasa si te lo dejas mal
| Propiedad | Valor sugerido | Qué controla | Síntoma si está mal |
maximum-pool-size |
10 (ver fórmula) |
Conexiones máximas simultáneas de esta instancia. |
Demasiado alto: la BD se satura y todo va lento. Demasiado bajo: timeouts al pedir conexión con la BD ociosa. |
minimum-idle |
igual que el máximo |
Conexiones mantenidas abiertas sin uso. |
Si es menor, tendrás picos de latencia al abrir conexiones nuevas (TLS + autenticación son decenas de ms). |
connection-timeout |
3.000 ms |
Cuánto espera un hilo por una conexión libre antes de fallar. |
Alto (30 s por defecto): las peticiones se acumulan y el servicio se cuelga en vez de rechazar rápido. |
max-lifetime |
1.740.000 ms (29 min) |
Vida máxima de una conexión antes de reciclarse. |
Mayor que el timeout del servidor o del balanceador: errores esporádicos de «connection reset» imposibles de reproducir. |
idle-timeout |
600.000 ms |
Cuándo se cierra una conexión ociosa por encima del mínimo. |
Irrelevante si minimum-idle == maximum-pool-size. |
keepalive-time |
300.000 ms |
Ping periódico a conexiones ociosas. |
Sin él, cortafuegos y NAT matan conexiones «vivas» y el primer uso falla. |
leak-detection-threshold |
20.000 ms |
Avisa (con traza) si una conexión no se devuelve en ese tiempo. |
Sin él, una fuga de conexiones es un misterio; con él, el log te da la línea exacta. |
validation-timeout |
2.000 ms |
Tiempo máximo de la comprobación de validez. |
Debe ser menor que connection-timeout. |
auto-commit |
false |
Si la conexión llega en modo autocommit. |
Con true, Hibernate tiene que desactivarlo en cada transacción: un viaje extra a la BD por transacción. |
La cadena de timeouts tiene que ser coherente, de dentro hacia fuera.
Si un eslabón interior es mayor que el exterior, el exterior corta primero y
te queda una conexión ocupada trabajando para nadie:
socketTimeout (driver) 30 s ─┐
statement_timeout (Postgres) 10 s │ ← el más corto de los de BD
@Transactional(timeout = 8) 8 s │
connection-timeout (Hikari) 3 s │ (solo para OBTENER conexión)
timeout del cliente HTTP 5 s │
timeout del gateway / ingress 15 s ─┘
Regla: timeout_de_BD < timeout_de_la_petición < timeout_del_cliente.
Al revés, el cliente se rinde, reintenta, y multiplicas la carga en la BD
justo cuando está sufriendo. Así nacen las tormentas de reintentos.
# statement_timeout: la red de seguridad definitiva. Una consulta que se pasa
# de aquí es cancelada POR LA BASE DE DATOS, liberando la conexión.
spring:
datasource:
hikari:
connection-init-sql: >
SET statement_timeout = '10s';
SET idle_in_transaction_session_timeout = '30s';
SET lock_timeout = '3s'
# También se puede fijar por usuario, que es más robusto porque no depende
# de la configuración de la aplicación:
# ALTER ROLE app_tienda SET statement_timeout = '10s';
# ALTER ROLE app_tienda SET idle_in_transaction_session_timeout = '30s';
Hilos virtuales y el pool: con spring.threads.virtual.enabled=true desaparece el límite
de 200 hilos de Tomcat, así que ahora el pool es el único cuello de botella. Miles de hilos
virtuales pueden quedarse esperando una de tus 10 conexiones. Eso no es malo (mejor cola en la aplicación que
en la base de datos), pero tienes que ajustar connection-timeout para fallar rápido y añadir
control de admisión aguas arriba. Detalles en el módulo 03.
2.6 Un segundo DataSource de solo lectura
Cuando hay réplicas de lectura, lo natural es enviar allí los informes y los listados pesados. Con JPA hay dos
caminos: dos DataSource (más código, control total) o un enrutador que decide según el contexto
(menos código, magia que hay que entender). El segundo es más elegante y más frágil; empieza por el primero.
// OPCIÓN A (recomendada para empezar): dos DataSource explícitos.
// El de escritura es @Primary y todo lo demás sigue funcionando igual.
@Configuration
public class DataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource")
DataSourceProperties propiedadesEscritura() { return new DataSourceProperties(); }
@Bean
@Primary
DataSource dataSourceEscritura(DataSourceProperties p) {
return p.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean
@ConfigurationProperties("app.datasource-lectura")
DataSourceProperties propiedadesLectura() { return new DataSourceProperties(); }
@Bean
DataSource dataSourceLectura(@Qualifier("propiedadesLectura") DataSourceProperties p) {
HikariDataSource ds = p.initializeDataSourceBuilder().type(HikariDataSource.class).build();
ds.setPoolName("tienda-pool-lectura");
ds.setReadOnly(true); // el driver rechaza escrituras: red de seguridad
ds.setMaximumPoolSize(6);
return ds;
}
// Para las consultas de informe no necesitas otro EntityManagerFactory:
// JdbcClient sobre el DataSource de lectura es más simple y más rápido.
@Bean
JdbcClient jdbcClientLectura(@Qualifier("dataSourceLectura") DataSource ds) {
return JdbcClient.create(ds);
}
}
// OPCIÓN B: enrutamiento automático según @Transactional(readOnly = true).
// Elegante, pero recuerda: si lees inmediatamente después de escribir puedes
// leer datos obsoletos por el retraso de replicación (módulo 06).
public class DataSourceEnrutado extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "lectura" : "escritura";
}
}
@Bean
@Primary
DataSource dataSource(@Qualifier("dataSourceEscritura") DataSource escritura,
@Qualifier("dataSourceLectura") DataSource lectura) {
var enrutado = new DataSourceEnrutado();
enrutado.setTargetDataSources(Map.of("escritura", escritura, "lectura", lectura));
enrutado.setDefaultTargetDataSource(escritura);
// LazyConnectionDataSourceProxy es IMPRESCINDIBLE aquí: sin él, Spring pide la
// conexión al abrir la transacción, ANTES de saber si es de solo lectura.
return new LazyConnectionDataSourceProxy(enrutado);
}
La trampa del enrutamiento: sin LazyConnectionDataSourceProxy, el
JpaTransactionManager obtiene la conexión en el momento de abrir la transacción, cuando
determineCurrentLookupKey() todavía no sabe que es de solo lectura. Resultado: todo va a la
primaria y crees que has separado lecturas cuando no has separado nada. Compruébalo siempre mirando
pg_stat_activity en la réplica, no el código.
2.7 Ver el SQL de verdad: cuatro niveles
Esta es probablemente la subsección más rentable de todo el módulo. Si no ves el SQL, no estás
programando: estás adivinando. Hay cuatro formas, de menos a más potente.
Nivel 0: show-sql, que es lo que no hay que usar
spring:
jpa:
show-sql: true # ✗ NO
# Escribe con System.out.println: sin nivel, sin timestamp, sin categoría, sin
# correlación con la petición, no se puede filtrar y en producción es imposible
# de desactivar sin reiniciar. Existe solo por compatibilidad histórica.
Nivel 1: los loggers de Hibernate (lo mínimo aceptable)
logging:
level:
org.hibernate.SQL: DEBUG # la sentencia con ? en los parámetros
org.hibernate.orm.jdbc.bind: TRACE # los valores enlazados (Hibernate 6)
org.hibernate.orm.jdbc.extract: TRACE # los valores leídos (muy verboso)
org.hibernate.SQL_SLOW: INFO # consultas por encima del umbral
org.hibernate.stat: DEBUG # resumen de sesión al cerrarla
org.hibernate.cache: DEBUG # aciertos y fallos de caché L2
org.hibernate.orm.results: DEBUG # avisos como HHH90003004 (paginación)
spring:
jpa:
properties:
hibernate:
format_sql: true # SQL indentado y multilínea: legible
highlight_sql: true # colores ANSI en consola
use_sql_comments: true # añade /* select p from Pedido p */ antes del SQL
Lo que verás en la consola con esa configuración (Hibernate 6.6):
2026-03-14T10:22:41.118 DEBUG org.hibernate.SQL :
/* select p from Pedido p where p.estado = :estado */
select
p1_0.id, p1_0.cliente_id, p1_0.creado_en, p1_0.estado,
p1_0.referencia, p1_0.total, p1_0.version
from
pedido p1_0
where
p1_0.estado = ?
2026-03-14T10:22:41.121 TRACE org.hibernate.orm.jdbc.bind :
binding parameter (1:VARCHAR) <- [CONFIRMADO]
Limitación: la sentencia y los parámetros van en LÍNEAS DISTINTAS. Con 50
consultas concurrentes es un rompecabezas. Para eso están los niveles 2 y 3.
Nunca dejes org.hibernate.orm.jdbc.bind en TRACE en producción: escribe en
el log todos los valores que pasan por la base de datos. Eso incluye correos, DNI, teléfonos
y cualquier dato personal, lo cual es una fuga de datos según el RGPD y probablemente una infracción de la
política de tu empresa. Además, el coste de rendimiento de generar esos strings es considerable.
Nivel 2: datasource-proxy (SQL completo + contador de consultas)
<dependency>
<groupId>net.ttddyy</groupId>
<artifactId>datasource-proxy</artifactId>
<version>1.10</version>
<scope>test</scope> <!-- en tests siempre; en dev opcional -->
</dependency>
// Envuelve el DataSource: SQL con los parámetros YA sustituidos, tiempo de
// ejecución y tamaño del lote. Y, sobre todo, permite CONTAR consultas, que es
// la base de los tests anti-N+1 de la sección 7.2.
@Configuration
@Profile({"dev", "test"})
public class ProxyDataSourceConfig implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String nombre) {
if (bean instanceof DataSource ds && !(bean instanceof ProxyDataSource)) {
return ProxyDataSourceBuilder.create(ds)
.name("tienda")
.logQueryBySlf4j(SLF4JLogLevel.DEBUG, "sql.trazado")
.multiline()
.countQuery() // habilita QueryCountHolder
.logSlowQueryBySlf4j(300, MILLISECONDS)
.afterQuery((exec, info) -> {
// Gancho para métricas propias o para fallar el test
long ms = exec.getElapsedTime();
if (ms > 1000) log.warn("Consulta lentísima ({} ms): {}", ms,
info.getFirst().getQuery());
})
.build();
}
return bean;
}
}
Lo que verás (fíjate en "Params" y en "Batch"):
Name:tienda, Connection:7, Time:12, Success:True
Type:Prepared, Batch:False, QuerySize:1, BatchSize:0
Query:["select p1_0.id, p1_0.estado, p1_0.total from pedido p1_0 where p1_0.estado=?"]
Params:[(CONFIRMADO)]
Name:tienda, Connection:7, Time:41, Success:True
Type:Prepared, Batch:True, QuerySize:1, BatchSize:50 ← ¡el batching funciona!
Query:["insert into linea_pedido (cantidad,pedido_id,precio_unitario,producto_id,id) values (?,?,?,?,?)"]
Params:[(2,1,19.90,7,1),(1,1,45.00,9,2), … 50 juegos …]
Nivel 3: p6spy (formato configurable, apto para preproducción)
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
<version>1.10.0</version>
</dependency>
<!-- Este starter también trae datasource-proxy y flexy-pool: elige uno. -->
# src/main/resources/spy.properties
# Registra solo lo que aporta y con el formato que quieras.
appender=com.p6spy.engine.spy.appender.Slf4JLogger
logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
customLogMessageFormat=%(executionTime) ms | %(category) | %(sqlSingleLine)
# Filtra el ruido: nada de commits ni de sentencias de Flyway
excludecategories=info,debug,result,resultset,batch
# Umbral: solo lo que tarda más de 100 ms (en preproducción, imprescindible)
outagedetection=true
outagedetectioninterval=2
| Herramienta | SQL con parámetros | Tiempo | Contador de consultas | Coste | Dónde usarla |
show-sql | No | No | No | Alto (stdout) | Nunca |
| Loggers de Hibernate | En otra línea | Solo SQL_SLOW | Con generate_statistics | Medio | Desarrollo |
datasource-proxy | Sí, en la misma línea | Sí | Sí, y programable | Bajo | Desarrollo y tests |
p6spy | Sí | Sí | No directamente | Bajo-medio | Desarrollo y preproducción |
pg_stat_statements | Normalizado | Sí, agregado | Sí, agregado | Nulo para la app | Producción |
En producción no instrumentes la aplicación, instrumenta la base de datos.
pg_stat_statements te da las consultas más costosas agregadas, con llamadas, tiempo total y
medio, sin ningún coste en tu JVM y sin volcar datos personales al log. Combínalo con
ApplicationName en la URL de JDBC y con trazas de OpenTelemetry, y tendrás el mapa completo sin
tocar el código.
2.8 Estadísticas de Hibernate: el cuadro de mandos gratis
spring:
jpa:
properties:
hibernate:
generate_statistics: true # dev y tests. En prod tiene coste medible
session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 300
logging:
level:
org.hibernate.stat: DEBUG
Al cerrar cada sesión (es decir, al terminar cada transacción) verás:
Session Metrics {
1834900 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
442700 nanoseconds spent preparing 4 JDBC statements;
9871200 nanoseconds spent executing 4 JDBC statements; ← 4 consultas
0 nanoseconds spent executing 0 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
0 nanoseconds spent performing 0 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
1102400 nanoseconds spent executing 1 flushes (2 entities, 3 collections);
0 nanoseconds spent executing 0 partial-flushes
}
Cómo leerlo, que es lo que importa:
· "executing N JDBC statements" con N grande y creciente con los datos = N+1.
· "acquiring JDBC connections" alto = pool agotado o contención.
· "executing 0 JDBC batches" cuando esperabas lotes = batching desactivado.
· "flushes" > 1 en una transacción de lectura = algo está escribiendo.
· "L2C misses" ≫ "L2C hits" = tu caché de segundo nivel no sirve de nada.
// Acceso programático: la base de los tests de rendimiento (sección 13.5)
// y de un endpoint de diagnóstico interno.
@Component
@RequiredArgsConstructor
public class DiagnosticoJpa {
private final EntityManagerFactory emf;
public Statistics estadisticas() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
public Map<String, Object> resumen() {
Statistics s = estadisticas();
return Map.of(
"consultas", s.getQueryExecutionCount(),
"sentencias", s.getPrepareStatementCount(),
"consultaMasLenta", s.getQueryExecutionMaxTimeQueryString(),
"msConsultaMasLenta", s.getQueryExecutionMaxTime(),
"entidadesCargadas", s.getEntityLoadCount(),
"entidadesInsertadas", s.getEntityInsertCount(),
"entidadesActualizadas",s.getEntityUpdateCount(),
"coleccionesCargadas", s.getCollectionLoadCount(),
"aciertosL2", s.getSecondLevelCacheHitCount(),
"fallosL2", s.getSecondLevelCacheMissCount());
}
// Las 5 consultas más lentas por tiempo máximo: útil en un endpoint de admin
public List<String> masLentas(int limite) {
Statistics s = estadisticas();
return Arrays.stream(s.getQueries())
.sorted(Comparator.comparingLong(
q -> -s.getQueryStatistics(q).getExecutionMaxTime()))
.limit(limite)
.map(q -> "%d ms · %s".formatted(
s.getQueryStatistics(q).getExecutionMaxTime(), q))
.toList();
}
}
¿Y en producción? generate_statistics tiene un coste medible (crea objetos por consulta y
mantiene contadores con contención). Pero, si añades spring-boot-starter-actuator, Micrometer
expone automáticamente esas estadísticas como métricas hibernate.* cuando están activadas.
La decisión razonable: activadas en preproducción con carga realista, desactivadas en
producción, sustituyéndolas por pg_stat_statements y trazas distribuidas.
# Métricas que aparecen en /actuator/prometheus y qué vigilar de cada una
# --- Pool de conexiones (siempre disponibles, sin coste) ---
hikaricp_connections_active ← si == max de forma sostenida, hay problema
hikaricp_connections_pending ← > 0 sostenido: hilos esperando. ALERTA
hikaricp_connections_idle
hikaricp_connections_timeout_total ← cualquier incremento merece investigación
hikaricp_connections_usage_seconds ← p99 alto: transacciones demasiado largas
hikaricp_connections_acquire_seconds ← p99 alto: pool pequeño o BD saturada
hikaricp_connections_creation_seconds
# --- Hibernate (solo con generate_statistics=true) ---
hibernate_statements_total
hibernate_query_executions_seconds_max
hibernate_entities_loads_total ← divide por peticiones: ¿cuántas por request?
hibernate_collections_fetches_total ← creciente y desproporcionado = N+1
hibernate_cache_query_hit_total / miss_total
hibernate_sessions_open_total
hibernate_transactions_total{result="failure"} ← rollbacks: ¿esperados?
2.9 Fallar en el arranque, no en la primera petición
Un servicio que arranca «bien» pero se rompe en la primera petición es peor que uno que no arranca: pasa las
comprobaciones de readiness, recibe tráfico y falla delante del usuario. Estas cuatro medidas
convierten los errores de configuración en fallos de despliegue.
spring:
# 1) Comprobar la conexión al arrancar (por defecto es perezoso en algunos casos)
datasource:
hikari:
initialization-fail-timeout: 1 # > 0: si no puede abrir una conexión, no arranca
# 2) Validar el mapeo contra el esquema real
jpa:
hibernate.ddl-auto: validate
# 3) Crear el EntityManagerFactory en el arranque, no en el primer uso.
# Con true, un error de mapeo aparece en la primera petición del usuario.
# (El valor por defecto ya es false; asegúrate de no haberlo cambiado.)
data:
jpa:
repositories:
bootstrap-mode: default # default | deferred | lazy → usa default
# 4) Flyway antes que Hibernate: Boot ya ordena esta dependencia por ti, pero
# si defines EntityManagerFactory a mano tendrás que declararla con @DependsOn.
management:
endpoint.health.group.readiness.include: readinessState,db
# El grupo readiness incluye la BD: si la base de datos no responde,
# Kubernetes deja de mandarte tráfico en vez de devolver 500 a los usuarios.
// Comprobación explícita de arranque: verifica lo que validate no cubre.
// Ejemplo real: que la versión del motor sea la esperada y que las extensiones
// necesarias estén instaladas. Falla el arranque con un mensaje claro.
@Component
@RequiredArgsConstructor
class ComprobacionBaseDatos implements ApplicationRunner {
private final JdbcClient jdbc;
@Override
public void run(ApplicationArguments args) {
int version = jdbc.sql("show server_version_num").query(Integer.class).single();
if (version < 160000) {
throw new IllegalStateException(
"Se requiere PostgreSQL 16 o superior; encontrado " + version);
}
List<String> faltan = jdbc.sql("""
select e.nombre
from (values ('pg_stat_statements'), ('unaccent'), ('pg_trgm')) as e(nombre)
where not exists (select 1 from pg_extension x where x.extname = e.nombre)
""").query(String.class).list();
if (!faltan.isEmpty()) {
throw new IllegalStateException("Faltan extensiones de PostgreSQL: " + faltan);
}
}
}
Checklist de configuración: cópiala a tu revisión de código
spring.jpa.open-in-view: false. Sin excepciones.
ddl-auto: validate en todos los entornos, con Flyway al mando del esquema.
maximum-pool-size de un dígito o poco más, calculado y repartido entre instancias.
max-lifetime menor que el timeout del servidor y del balanceador.
leak-detection-threshold puesto: el día que haya una fuga, lo agradecerás.
connection-timeout de 2-3 s: fallar rápido es mejor que encolar.
statement_timeout e idle_in_transaction_session_timeout en el rol de la base de datos.
ApplicationName en las propiedades del driver: se ve en pg_stat_activity.
jdbc.time_zone: UTC y contenedores en UTC.
default_batch_fetch_size: 25 como red contra el N+1.
jdbc.batch_size + reWriteBatchedInserts + secuencias (no IDENTITY) si insertas en volumen.
- El SQL visible en desarrollo y medido en producción; nunca
show-sql.
- Métricas de Hikari en el dashboard, con alerta sobre
connections_pending y timeout_total.
3 · Mapeo de entidades
Mapear es traducir entre dos modelos que no encajan (sección 1.2). Cada anotación de esta sección es una
decisión con consecuencias en el SQL generado, en el rendimiento y en la evolución del esquema. Vamos una por
una, siempre con el «por qué».
3.1 @Entity, @Table y @Column
package com.ejemplo.tienda.pedidos;
@Entity
@Table(name = "pedido",
// Los índices aquí SOLO afectan a la generación de esquema (que no usamos).
// Se declaran igualmente porque documentan la intención en el mismo sitio
// donde se leen las consultas. El índice real lo crea Flyway.
indexes = {
@Index(name = "ix_pedido_cliente", columnList = "cliente_id"),
@Index(name = "ix_pedido_estado_creado", columnList = "estado, creado_en")
},
uniqueConstraints = @UniqueConstraint(name = "uk_pedido_referencia",
columnNames = "referencia"))
public class Pedido {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pedido_gen")
@SequenceGenerator(name = "pedido_gen", sequenceName = "pedido_seq", allocationSize = 50)
private Long id;
@Column(name = "referencia", nullable = false, length = 36, updatable = false)
private String referencia;
@Enumerated(EnumType.STRING) // jamás ORDINAL: sección 3.4
@Column(nullable = false, length = 20)
private EstadoPedido estado = EstadoPedido.BORRADOR;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal total = BigDecimal.ZERO;
@Column(nullable = false, length = 3, columnDefinition = "char(3)")
private String moneda = "EUR";
@Column(name = "creado_en", nullable = false, updatable = false)
private Instant creadoEn;
@Version
private long version;
// JPA exige un constructor sin argumentos con visibilidad al menos protected.
// protected (no public) evita que el código de negocio cree pedidos inválidos.
protected Pedido() { }
public Pedido(String referencia, Cliente cliente) {
this.referencia = Objects.requireNonNull(referencia);
this.cliente = Objects.requireNonNull(cliente);
this.creadoEn = Instant.now();
}
}
| Atributo | Qué hace de verdad | Consejo |
@Column(nullable = false) |
Añade not null solo si Hibernate genera el esquema. Si no, únicamente activa una comprobación antes del insert. |
Ponlo igualmente: la comprobación previa da un error claro en vez de un ConstraintViolationException del driver. |
length |
Igual: informativo con validate. Sí se usa en la validación de esquema. |
Mantenlo sincronizado con la migración, o validate te lo recordará. |
insertable / updatable |
Sí tienen efecto real: excluyen la columna del INSERT o del UPDATE. |
updatable = false en columnas inmutables (referencia, fecha de creación) es una protección real y barata. |
precision / scale |
Para BigDecimal. Afecta a la generación y a la validación de esquema. |
Siempre explícitos en dinero. Sin ellos, el valor por defecto es 19,2 y te puede sorprender. |
columnDefinition |
SQL literal para esa columna. Rompe la portabilidad. |
Úsalo solo para tipos que Hibernate no infiere bien (char(3), citext, tstzrange). |
@Table(schema = ...) |
Fija el esquema en el SQL generado. |
Mejor no fijarlo: usa currentSchema en la URL de JDBC o search_path. Así el mismo binario sirve para multitenencia por esquema. |
Nombres de tabla y columna: no dejes que los invente Hibernate. La estrategia por defecto
(CamelCaseToUnderscoresNamingStrategy) convierte creadoEn en creado_en,
lo cual está bien… hasta que alguien renombra un campo de Java y, sin darse cuenta, renombra una columna.
Declara @Column(name = ...) explícitamente en todo lo que no sea trivial: el coste es una línea y
el beneficio es que refactorizar Java nunca rompa el SQL.
# Si prefieres nombres explícitos en todo, puedes desactivar la conversión
# automática y obligar a declarar cada nombre. Es una decisión de equipo válida.
spring:
jpa:
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
3.2 Claves primarias y estrategias de generación
Esta es la primera decisión de mapeo y la que más impacto tiene en el rendimiento de escritura,
y casi nadie la toma a conciencia: se copia el GenerationType.IDENTITY del primer tutorial y se
arrastra durante años. Veamos qué SQL genera cada una.
IDENTITY: sencillo y silenciosamente lento en lotes
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Qué pasa en la base de datos al persistir 3 entidades:
INSERT INTO cliente (...) VALUES (...) RETURNING id; ← viaje 1
INSERT INTO cliente (...) VALUES (...) RETURNING id; ← viaje 2
INSERT INTO cliente (...) VALUES (...) RETURNING id; ← viaje 3
El problema NO es que sean tres INSERT: es que tienen que ser TRES VIAJES.
Razonamiento: JPA garantiza que después de persist() la entidad tiene id
(porque el id es su identidad en el contexto de persistencia). Con IDENTITY el
id lo asigna la base de datos al insertar. Por tanto Hibernate está OBLIGADO a
ejecutar el INSERT inmediatamente, en persist(), y NO PUEDE agruparlo en un
lote con los siguientes. El batching de inserciones queda desactivado.
Y encima el INSERT sale de la cola del flush ordenado: pierdes también
order_inserts.
SEQUENCE: la opción correcta en PostgreSQL y Oracle
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pedido_gen")
@SequenceGenerator(name = "pedido_gen",
sequenceName = "pedido_seq",
allocationSize = 50) // ← la clave está aquí
private Long id;
-- La secuencia DEBE tener el mismo incremento que allocationSize.
-- Si no coinciden, Hibernate detecta el desajuste y desactiva el optimizador
-- (o, peor, en configuraciones antiguas, generaba ids repetidos).
create sequence pedido_seq start with 1 increment by 50;
-- Comprobación rápida de que están de acuerdo:
select increment_by from pg_sequences where sequencename = 'pedido_seq'; -- 50
Qué pasa al persistir 150 pedidos con allocationSize = 50:
SELECT nextval('pedido_seq'); ← viaje 1: devuelve 50
→ Hibernate se reserva los ids 1..50 EN MEMORIA (optimizador "pooled")
→ los 50 primeros persist() no hacen NINGÚN viaje a la base de datos
SELECT nextval('pedido_seq'); ← viaje 2: devuelve 100 → ids 51..100
SELECT nextval('pedido_seq'); ← viaje 3: devuelve 150 → ids 101..150
Y en el flush, un ÚNICO lote:
INSERT INTO pedido (...) VALUES (...), (...), … 50 juegos de valores …
INSERT INTO pedido (...) VALUES (...), (...), … 50 juegos …
INSERT INTO pedido (...) VALUES (...), (...), … 50 juegos …
Total: 3 viajes para los ids + 3 lotes de inserción = 6 viajes.
Con IDENTITY habrían sido 150 viajes. En una red con 1 ms de latencia,
150 ms frente a 6 ms. Con 100.000 filas, la diferencia es de minutos.
| Estrategia | Cómo obtiene el id | Permite batching | Huecos en la secuencia | Portabilidad | Veredicto |
IDENTITY |
La base de datos, en el INSERT (serial, identity, AUTO_INCREMENT). |
No |
Solo por transacciones abortadas. |
Alta (MySQL solo tiene esto). |
Aceptable si insertas de uno en uno. Obligatorio en MySQL. |
SEQUENCE |
Una secuencia consultada antes del INSERT, con reserva en bloque. |
Sí |
Sí, y es normal: al reiniciar la aplicación se descarta el bloque reservado. |
PostgreSQL, Oracle, H2, SQL Server 2012+. No MySQL. |
La mejor opción por defecto en PostgreSQL. |
TABLE |
Una tabla auxiliar con un contador, actualizada con bloqueo. |
Sí, pero… |
Sí. |
Total: funciona en cualquier motor. |
Evítala. Serializa todas las inserciones sobre una fila: es el peor cuello de botella imaginable en concurrencia. |
AUTO |
Hibernate elige. En Hibernate 6 sobre PostgreSQL elige SEQUENCE (con hibernate_sequence compartida si no la declaras). |
Depende |
Depende |
Alta |
Funciona, pero sé explícito: no quieres que un cambio de versión te cambie la estrategia. |
UUID (@GeneratedValue sin estrategia sobre UUID) |
Generado en la JVM, antes del INSERT. |
Sí |
No aplica. |
Total. |
Útil en sistemas distribuidos. Ojo con la fragmentación del índice: usa UUIDv7. |
Los «huecos» en la secuencia y por qué no importan
Objeción clásica: «con allocationSize = 50 se pierden ids al reiniciar». Es cierto: si la
aplicación se reinicia tras usar 3 de los 50 ids reservados, los 47 restantes se pierden para siempre. Y no
importa, por tres razones: (1) un bigint tiene 9,2 × 1018 valores, así que
perder millones al día durante siglos no lo agota; (2) una clave primaria subrogada no es un número
de factura: no debe ser consecutiva ni tener significado de negocio; (3) si necesitas una numeración
sin huecos (por obligación legal, como la numeración de facturas en España), eso es un
identificador de negocio y se genera aparte, con su propia lógica transaccional
(sección 12.7). Mezclar las dos cosas es el error de diseño, no los huecos.
Claves naturales frente a subrogadas
| Criterio | Clave natural (email, sku, ISBN) | Clave subrogada (bigint, UUID) |
| Estabilidad | Frágil: los clientes cambian de correo, los SKU se reorganizan, los NIF se corrigen. | Total: no significa nada, así que nunca hay motivo para cambiarla. |
| Tamaño en índices ajenos | Grande: cada FK replica el texto completo en cada índice. | 8 bytes. Índices pequeños y densos. |
| Legibilidad | Alta: ves el dato en la URL y en los logs. | Nula por sí sola. |
| Con JPA | Requiere @Id asignado a mano y Persistable para que save sepa si es nuevo (sección 5.8). | Encaja de forma natural. |
| Veredicto | La clave natural va en un índice único, no en la PK. | PK subrogada + restricción única sobre la clave natural. Lo mejor de los dos mundos. |
// El patrón correcto: id técnico para las relaciones, referencia pública
// estable para el mundo exterior, y la clave natural con índice único.
@Entity
@Table(name = "pedido")
public class Pedido {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pedido_gen")
@SequenceGenerator(name = "pedido_gen", sequenceName = "pedido_seq", allocationSize = 50)
private Long id; // interno: FKs, JOINs, nunca sale en la API
@Column(nullable = false, length = 36, updatable = false, unique = true)
private String referencia; // público: /api/pedidos/{referencia}
// Fábrica: la referencia se genera aquí, no la asigna quien llama
public static Pedido nuevo(Cliente cliente) {
Pedido p = new Pedido();
p.referencia = "PED-" + Instant.now().getEpochSecond() + "-"
+ UUID.randomUUID().toString().substring(0, 8);
p.cliente = cliente;
return p;
}
}
Por qué no exponer el id numérico en la API: es un identificador secuencial y adivinable.
Regala información («llevan 48.213 pedidos») y facilita los ataques de enumeración (IDOR: probar
/pedidos/1, /pedidos/2…). No es que el id secuencial sea inseguro por sí mismo —la
seguridad la da la autorización, módulo 10—, pero una referencia opaca elimina toda una clase de
problemas por diseño y no cuesta nada.
UUID: cuándo sí, y cuál
// Hibernate 6: UUID aleatorio (v4). Funciona, pero fragmenta el índice.
@Id
@GeneratedValue // sobre un campo UUID, Hibernate usa UuidGenerator
private UUID id;
// Mejor: UUID v7, ordenado por tiempo. Mantiene la localidad del B-tree.
@Id
@GeneratedValue
@UuidGenerator(style = UuidGenerator.Style.TIME) // versión 7 en Hibernate 6.6
@Column(columnDefinition = "uuid")
private UUID id;
// Y si el id lo genera el cliente o un servicio externo, no hay generación:
@Id
@Column(columnDefinition = "uuid", updatable = false)
private UUID id; // asignado a mano → ver Persistable, sección 5.8
| Tipo de id | Bytes | Ordenado | Fragmentación del índice | Generable sin la BD | Cuándo usarlo |
bigint + secuencia | 8 | Sí | Mínima | Sí (con reserva en bloque) | Por defecto. Lo mejor para el 90 % de los casos. |
uuid v4 | 16 | No | Alta: inserciones aleatorias por todo el árbol, más divisiones de página, índices el doble de grandes | Sí | Solo si necesitas unicidad global y no te importa el coste. |
uuid v7 / ULID | 16 | Sí (por tiempo) | Baja | Sí | Sistemas distribuidos, ids generados por el cliente, fusión de datos de varias fuentes. |
varchar con UUID en texto | 36 | No | Altísima | Sí | Nunca. Ocupa el doble y compara más lento. |
Claves compuestas: @EmbeddedId y @IdClass
// OPCIÓN 1: @EmbeddedId (preferible: la clave es un objeto de verdad)
@Embeddable
public record ProductoEtiquetaId(
@Column(name = "producto_id") Long productoId,
@Column(name = "etiqueta_id") Long etiquetaId) implements Serializable {
// Un record ya trae equals/hashCode correctos: perfecto para una clave.
// Requisito de JPA: Serializable y equals/hashCode válidos. Sin excepción.
}
@Entity
@Table(name = "producto_etiqueta")
public class ProductoEtiqueta {
@EmbeddedId
private ProductoEtiquetaId id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@MapsId("productoId") // reutiliza la columna de la clave: no duplica
@JoinColumn(name = "producto_id")
private Producto producto;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@MapsId("etiquetaId")
@JoinColumn(name = "etiqueta_id")
private Etiqueta etiqueta;
@Column(name = "anadida_en", nullable = false, updatable = false)
private Instant anadidaEn = Instant.now();
}
// OPCIÓN 2: @IdClass (más antigua, campos duplicados en la entidad)
@Entity
@IdClass(ProductoEtiquetaPk.class)
public class ProductoEtiquetaAlt {
@Id private Long productoId;
@Id private Long etiquetaId;
// La clase Pk debe tener los mismos nombres de campo. Frágil y verboso.
}
// Cómo se consulta con @EmbeddedId:
var id = new ProductoEtiquetaId(7L, 3L);
Optional<ProductoEtiqueta> pe = repositorio.findById(id);
// En JPQL: where pe.id.productoId = :p and pe.id.etiquetaId = :e
Consejo sobre claves compuestas: úsalas solo cuando la tabla es una relación N:M pura o cuando
el esquema es heredado y no puedes cambiarlo. Si la tabla tiene vida propia (atributos, ciclo de vida,
referencias entrantes), dale un bigint subrogado y pon la clave compuesta como restricción única.
Te ahorrarás fricción en repositorios, en la API y en las relaciones.
3.3 @Embeddable: objetos de valor
Un embebible es un grupo de columnas de la misma tabla tratado como un objeto. Es la herramienta que
convierte una entidad con 30 campos planos en un modelo de dominio legible, y no cuesta ni una consulta extra.
@Embeddable
public record Direccion(
@Column(name = "calle", length = 200) String calle,
@Column(name = "cp", length = 10) String codigoPostal,
@Column(name = "ciudad", length = 100) String ciudad,
@Column(name = "pais", length = 2) String pais) {
// Un record como embebible: inmutable, con equals/hashCode por valor.
// Requisito en Hibernate 6.2+: se admiten records si todos los componentes
// se pueden mapear. Para versiones anteriores, clase con constructor vacío.
public Direccion {
if (pais != null && pais.length() != 2)
throw new IllegalArgumentException("El país debe ser ISO 3166-1 alfa-2");
}
public boolean esNacional() { return "ES".equals(pais); }
}
@Entity
public class Pedido {
@Embedded
@AttributeOverrides({ // renombra las columnas para este uso concreto
@AttributeOverride(name = "calle", column = @Column(name = "envio_calle")),
@AttributeOverride(name = "codigoPostal", column = @Column(name = "envio_cp")),
@AttributeOverride(name = "ciudad", column = @Column(name = "envio_ciudad")),
@AttributeOverride(name = "pais", column = @Column(name = "envio_pais"))
})
private Direccion direccionEnvio;
// El MISMO tipo embebido, otra vez, con otro prefijo. Imposible con campos planos.
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "calle", column = @Column(name = "fact_calle")),
@AttributeOverride(name = "codigoPostal", column = @Column(name = "fact_cp")),
@AttributeOverride(name = "ciudad", column = @Column(name = "fact_ciudad")),
@AttributeOverride(name = "pais", column = @Column(name = "fact_pais"))
})
private Direccion direccionFacturacion;
}
// En JPQL se navega con punto, como si fuera una relación (pero sin JOIN):
// select p from Pedido p where p.direccionEnvio.pais = 'ES'
// SQL generado: ... where p1_0.envio_pais = 'ES' ← una sola tabla
// Objeto de valor con lógica: dinero. Un caso donde el embebible brilla.
@Embeddable
public record Dinero(
@Column(name = "importe", precision = 12, scale = 2) BigDecimal importe,
@Column(name = "moneda", length = 3) String moneda) {
public static final Dinero CERO_EUR = new Dinero(BigDecimal.ZERO, "EUR");
public Dinero {
Objects.requireNonNull(importe);
importe = importe.setScale(2, RoundingMode.HALF_UP); // normaliza SIEMPRE
}
public Dinero mas(Dinero otro) {
exigirMismaMoneda(otro);
return new Dinero(importe.add(otro.importe), moneda);
}
public Dinero por(int cantidad) {
return new Dinero(importe.multiply(BigDecimal.valueOf(cantidad)), moneda);
}
private void exigirMismaMoneda(Dinero otro) {
if (!moneda.equals(otro.moneda))
throw new IllegalArgumentException(
"No se pueden sumar %s y %s".formatted(moneda, otro.moneda));
}
}
// Ventaja concreta: es IMPOSIBLE sumar euros con dólares por accidente.
// Con dos BigDecimal sueltos, es cuestión de tiempo que ocurra.
Colecciones de embebibles: @ElementCollection guarda una lista de objetos de valor en una
tabla aparte, sin que sean entidades. Es cómodo, pero tiene una trampa importante: al modificar
cualquier elemento, Hibernate borra todas las filas de la colección y las reinserta. Con 3
elementos es irrelevante; con 500 es un desastre. Si la colección es grande o cambia mucho, conviértela en una
entidad con su propio id.
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "pedido_nota",
joinColumns = @JoinColumn(name = "pedido_id"),
indexes = @Index(name = "ix_pedido_nota", columnList = "pedido_id"))
@OrderColumn(name = "orden") // sin esto, el orden de la lista no se conserva
private List<Nota> notas = new ArrayList<>();
// SQL al añadir una nota a una lista de 3:
// delete from pedido_nota where pedido_id = ? ← borra las 3
// insert into pedido_nota (pedido_id, orden, ...) ... ← inserta 4
// Con @OrderColumn y una lista grande, esto se vuelve caro muy rápido.
3.4 Tipos: enums, fechas, dinero, JSON y binarios
Enumerados: STRING siempre
public enum EstadoPedido { BORRADOR, CONFIRMADO, PAGADO, ENVIADO, ENTREGADO, CANCELADO }
// ✓ CORRECTO
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private EstadoPedido estado;
// ✗ EL PEOR BUG SILENCIOSO DE JPA
@Enumerated(EnumType.ORDINAL) // o sin anotación: ORDINAL es el DEFECTO
private EstadoPedido estado;
Por qué ORDINAL es una bomba de relojería. Guarda la posición del valor en el
enum: BORRADOR=0, CONFIRMADO=1, PAGADO=2… El día que alguien
añada un estado nuevo en medio (BORRADOR, PENDIENTE_VALIDACION, CONFIRMADO, …), todos los pedidos
que estaban CONFIRMADO pasan a ser PENDIENTE_VALIDACION en silencio, sin
error, sin log y sin forma de recuperarlos más que revisando el histórico de Git y reconstruyendo a
mano. Además, con ORDINAL los datos son ilegibles en la base de datos: nadie sabe qué es un
estado = 3 al depurar un incidente. El único argumento a favor es que ocupa menos, y ahorrar 15
bytes por fila nunca ha compensado corromper los datos.
-- Ventaja adicional de STRING: puedes poner un CHECK en la base de datos.
-- Con ORDINAL, la integridad depende exclusivamente del código Java.
alter table pedido add constraint ck_pedido_estado
check (estado in ('BORRADOR','CONFIRMADO','PAGADO','ENVIADO','ENTREGADO','CANCELADO'));
-- Y las consultas son legibles para cualquiera:
select estado, count(*) from pedido group by estado;
-- CONFIRMADO | 4213
-- ENTREGADO | 91002 ← frente a "1 | 4213", "4 | 91002"
// Si necesitas un código corto y estable e independiente del nombre Java,
// no uses ORDINAL: usa un AttributeConverter con códigos explícitos.
public enum EstadoPedido {
BORRADOR("BOR"), CONFIRMADO("CNF"), PAGADO("PAG"),
ENVIADO("ENV"), ENTREGADO("ENT"), CANCELADO("CAN");
private final String codigo;
EstadoPedido(String codigo) { this.codigo = codigo; }
public String codigo() { return codigo; }
public static EstadoPedido desdeCodigo(String c) {
return Arrays.stream(values()).filter(e -> e.codigo.equals(c)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Estado desconocido: " + c));
}
}
@Converter(autoApply = true)
public class EstadoPedidoConverter implements AttributeConverter<EstadoPedido, String> {
@Override public String convertToDatabaseColumn(EstadoPedido e) {
return e == null ? null : e.codigo();
}
@Override public EstadoPedido convertToEntityAttribute(String c) {
return c == null ? null : EstadoPedido.desdeCodigo(c);
}
}
// Ahora renombrar el enum en Java NO afecta a los datos. Robusto de verdad.
Fechas y horas: java.time y nada más
| Tipo Java | Columna PostgreSQL | Qué representa | Cuándo usarlo |
Instant | timestamptz | Un punto exacto en la línea temporal, en UTC. | Por defecto para todo lo técnico: creación, modificación, eventos, auditoría. |
OffsetDateTime | timestamptz | Instante + desplazamiento original. | Cuando importa el desplazamiento con el que llegó el dato (integraciones externas). |
LocalDate | date | Una fecha del calendario sin hora ni zona. | Fecha de nacimiento, fecha de factura, fecha de vencimiento. |
LocalTime | time | Una hora del reloj. | Horario de apertura de una tienda. |
LocalDateTime | timestamp | Fecha y hora sin zona. Ambiguo por naturaleza. | Casi nunca. Es la causa número uno de bugs de «una hora de diferencia». |
ZonedDateTime | timestamptz | Instante + zona con reglas de horario de verano. | Cuando de verdad necesitas la zona (calendarios, recurrencias «todos los lunes a las 9 en Madrid»). |
Duration | interval o bigint | Una cantidad de tiempo. | Con @Column normal se guarda en nanosegundos como bigint: suele bastar. |
java.util.Date, Calendar, Timestamp | — | Legado. | Nunca en código nuevo. Mutables, con meses base 0 y sin zona clara. |
@Entity
public class Pedido {
// Punto exacto en el tiempo: lo que casi siempre quieres
@Column(name = "creado_en", nullable = false, updatable = false)
private Instant creadoEn;
// Marcas automáticas de Hibernate (no son de JPA estándar, pero muy cómodas)
@CreationTimestamp(source = SourceType.DB) // now() de la BD: un solo reloj
@Column(updatable = false)
private Instant creadoEnAuto;
@UpdateTimestamp(source = SourceType.DB)
private Instant actualizadoEn;
// Fecha de calendario: sin hora, sin zona, sin ambigüedad
@Column(name = "fecha_factura")
private LocalDate fechaFactura;
}
La configuración que evita el 90 % de los bugs de fechas: pon la JVM, el contenedor y la sesión de
JDBC en UTC, y usa timestamptz + Instant. Convierte a la zona del usuario
solo al presentar, nunca al almacenar. Un dato guardado en «hora local» sin zona es un dato
con información perdida, y no hay forma de recuperarla después.
spring:
jpa:
properties:
hibernate:
jdbc.time_zone: UTC # la sesión JDBC habla UTC
jackson:
time-zone: UTC # y la serialización JSON también
# Y en el Dockerfile / manifiesto de Kubernetes:
# ENV TZ=UTC
# JAVA_TOOL_OPTIONS="-Duser.timezone=UTC"
Dinero: BigDecimal con escala explícita
// ✓ CORRECTO
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal total;
// ✗ NUNCA jamás
private double total; // 0.1 + 0.2 = 0.30000000000000004 → la contabilidad no cuadra
private float precio; // peor todavía
// Y en la base de datos:
// ✓ numeric(12,2)
// ✗ real, double precision, money (el tipo money de Postgres depende del locale)
// Tres reglas de oro con BigDecimal en entidades:
// 1) Compara con compareTo, NUNCA con equals.
// new BigDecimal("10.00").equals(new BigDecimal("10.0")) → false (¡escala!)
// new BigDecimal("10.00").compareTo(new BigDecimal("10.0")) == 0 → true
if (total.compareTo(BigDecimal.ZERO) > 0) { … }
// 2) Normaliza la escala al construir, para que lo que guardas sea predecible.
this.total = importe.setScale(2, RoundingMode.HALF_UP);
// 3) Cuidado con el redondeo al dividir: sin escala explícita, ArithmeticException.
BigDecimal unitario = total.divide(BigDecimal.valueOf(cantidad), 2, RoundingMode.HALF_UP);
// Alternativa aún más segura para dominios financieros: guardar céntimos como
// long y no tener nunca decimales. Menos elegante, cero sorpresas de redondeo.
@Column(name = "total_centimos", nullable = false)
private long totalCentimos;
JSON con @JdbcTypeCode: la forma moderna
// Hibernate 6 mapea JSON de forma nativa: ya NO necesitas hypersistence-utils
// ni un UserType propio para el caso normal.
public record MetadatosPedido(
String canal, // WEB | APP | TELEFONO
String campana,
String cupon,
Map<String, String> utm) { }
@Entity
public class Pedido {
@JdbcTypeCode(SqlTypes.JSON) // jsonb en PostgreSQL
@Column(name = "metadatos", nullable = false, columnDefinition = "jsonb")
private MetadatosPedido metadatos = new MetadatosPedido(null, null, null, Map.of());
// También funciona con Map y con List
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> atributosLibres = new HashMap<>();
}
-- Consultar dentro del jsonb requiere SQL nativo (JPQL no sabe de jsonb).
-- Con índice GIN, estas consultas son rápidas de verdad.
create index ix_pedido_metadatos on pedido using gin (metadatos jsonb_path_ops);
select * from pedido where metadatos @> '{"canal":"APP"}';
select * from pedido where metadatos ->> 'campana' = 'navidad2026';
-- Índice específico si SIEMPRE filtras por la misma clave (más pequeño y rápido):
create index ix_pedido_canal on pedido ((metadatos ->> 'canal'));
La trampa del jsonb: el dirty checking no ve dentro. Si modificas el contenido de
un Map mapeado como JSON sin reemplazar la referencia, Hibernate compara la referencia
con la instantánea, ve el mismo objeto y decide que no ha cambiado nada. El cambio no se
guarda. La solución es tratar el campo como inmutable: crea un objeto nuevo y asígnalo. Por eso el
ejemplo usa un record: hace imposible el error.
// ✗ El cambio se pierde en silencio
pedido.getAtributosLibres().put("cupon", "VERANO10");
// ✓ Reemplaza la referencia
var nuevos = new HashMap<>(pedido.getAtributosLibres());
nuevos.put("cupon", "VERANO10");
pedido.setAtributosLibres(nuevos);
// ✓ O, mejor todavía, con un record inmutable no hay forma de equivocarse
pedido.setMetadatos(pedido.getMetadatos().conCupon("VERANO10"));
Cuándo NO usar jsonb: si filtras, agrupas u ordenas por una clave del JSON en más de dos
consultas, esa clave quiere ser una columna. El jsonb es para lo verdaderamente variable
(atributos por categoría de producto, respuestas de proveedores externos, banderas de experimentos), no para
ahorrarte migraciones. Un jsonb convertido en esquema de facto es deuda técnica con intereses:
sin restricciones, sin claves foráneas, sin tipos y con consultas que nadie puede optimizar.
Binarios y LOB
// Binario pequeño (< 1 MB): bytea directamente
@Column(name = "miniatura")
private byte[] miniatura; // se carga SIEMPRE con la entidad: cuidado
// Binario grande: @Lob + carga perezosa explícita
@Lob
@Basic(fetch = FetchType.LAZY) // requiere instrumentación de bytecode para
@Column(name = "documento") // funcionar de verdad (ver aviso abajo)
private byte[] documento;
// Texto largo
@Lob
@JdbcTypeCode(SqlTypes.LONGVARCHAR) // text en PostgreSQL
private String descripcionLarga;
La verdad sobre @Basic(fetch = LAZY): sin bytecode enhancement (el plugin de
Gradle/Maven de Hibernate con enableLazyInitialization), Hibernate ignora la
petición de carga perezosa en atributos básicos y carga el LOB con la entidad. Resultado: un
select * from documento que trae 40 MB porque alguien listó documentos. La solución robusta no es
la instrumentación, es el diseño: saca los binarios de la entidad principal. Ponlos en una
entidad aparte con relación @OneToOne perezosa, o —mejor— guárdalos en almacenamiento de objetos
(S3, MinIO) y deja en la base de datos solo la clave y los metadatos.
// El patrón correcto para archivos: la BD guarda metadatos, el objeto vive fuera
@Entity
@Table(name = "documento")
public class Documento {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;
@Column(nullable = false) private String nombre;
@Column(nullable = false) private String tipoMime;
@Column(nullable = false) private long tamanoBytes;
@Column(nullable = false, length = 64) private String sha256;
@Column(nullable = false) private String claveAlmacen; // s3://bucket/2026/03/uuid
@Column(nullable = false, updatable = false) private Instant subidoEn;
// Cero bytes de contenido en la base de datos: los listados son instantáneos,
// las copias de seguridad son pequeñas y el CDN puede servir el archivo.
}
Tipos personalizados: AttributeConverter y UserType
// AttributeConverter: para tipos que se guardan en UNA columna. Cubre el 95%.
@Converter(autoApply = true) // se aplica a todos los campos de ese tipo
public class MonedaConverter implements AttributeConverter<Currency, String> {
@Override public String convertToDatabaseColumn(Currency c) {
return c == null ? null : c.getCurrencyCode();
}
@Override public Currency convertToEntityAttribute(String s) {
return s == null ? null : Currency.getInstance(s);
}
}
// Otro caso frecuentísimo: una lista corta como texto separado por comas.
// (Si la lista crece o se consulta, usa una tabla. Esto es para 3-4 valores fijos.)
@Converter
public class ListaStringConverter implements AttributeConverter<List<String>, String> {
@Override public String convertToDatabaseColumn(List<String> lista) {
return lista == null || lista.isEmpty() ? null : String.join(",", lista);
}
@Override public List<String> convertToEntityAttribute(String s) {
return s == null || s.isBlank() ? new ArrayList<>()
: new ArrayList<>(List.of(s.split(",")));
}
}
@Convert(converter = ListaStringConverter.class)
@Column(name = "canales_notificacion")
private List<String> canales = new ArrayList<>();
| Mecanismo | Columnas | API | Cuándo usarlo |
AttributeConverter | Una | JPA estándar, dos métodos | Casi siempre: enums con código, Currency, tipos de dominio simples, cifrado de columna. |
@JdbcTypeCode | Una | Hibernate 6 | JSON, arrays de PostgreSQL, inet, tipos nativos del motor. |
@Embeddable | Varias | JPA estándar | Objetos de valor con varios campos: dirección, dinero, rango. |
UserType | Varias, con control total | API de Hibernate | Solo si necesitas control del ResultSet: tipos de rango, geometrías, comparación personalizada. |
Arrays nativos de PostgreSQL
// Hibernate 6 mapea arrays nativos sin nada extra
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "etiquetas", columnDefinition = "text[]")
private String[] etiquetas = new String[0];
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(columnDefinition = "integer[]")
private int[] valoraciones;
-- Consultas con SQL nativo e índice GIN
create index ix_producto_etiquetas on producto using gin (etiquetas);
select * from producto where etiquetas @> array['oferta']; -- contiene
select * from producto where etiquetas && array['oferta','nuevo']; -- intersecta
-- Cuándo array y cuándo tabla:
-- array → lista corta, sin atributos, sin FK, se consulta como conjunto
-- tabla → hay atributos (fecha de alta), hay FK, hay que contar o agrupar
3.5 @Transient, @Access y columnas generadas
@Entity
public class Pedido {
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal total;
// ---- @Transient: campo Java que NO se persiste ----
@Transient // ¡jakarta.persistence.Transient!
private BigDecimal totalConIva; // calculado, no almacenado
// Ojo: `transient` de Java (palabra clave) afecta a la serialización, NO a JPA.
// Son cosas distintas y se confunden constantemente.
// ---- Columna calculada por la base de datos ----
// @Generated: Hibernate NO la escribe y la relee después de insertar/actualizar.
@Generated(event = { EventType.INSERT, EventType.UPDATE })
@Column(name = "total_con_iva", insertable = false, updatable = false)
private BigDecimal totalConIvaCalculado;
// ---- @Formula: subconsulta SQL evaluada en cada SELECT ----
// Cómoda y peligrosa: se ejecuta SIEMPRE que cargues la entidad.
@Formula("(select count(*) from linea_pedido l where l.pedido_id = id)")
private int numeroLineas;
// ---- @ColumnDefault: valor por defecto en la generación de esquema ----
@ColumnDefault("'EUR'")
@Column(nullable = false, length = 3)
private String moneda;
}
-- La columna generada, en la migración de Flyway:
alter table pedido add column total_con_iva numeric(12,2)
generated always as (round(total * 1.21, 2)) stored;
-- Ventaja: la calcula la base de datos, es coherente para todos los clientes
-- (incluidos informes y otros servicios) y se puede indexar.
create index ix_pedido_total_con_iva on pedido (total_con_iva);
@Formula: úsala con moderación. Convierte cada select de la entidad en un
select con subconsulta correlacionada. Cargar 500 pedidos con una @Formula que cuenta
líneas ejecuta 500 subconsultas dentro de la misma sentencia: el plan puede ser desastroso. Y no se puede
desactivar por consulta. Alternativas mejores: una proyección DTO con la agregación, una columna
desnormalizada mantenida por la aplicación, o una vista materializada.
@Access: campos o propiedades
// Por defecto, el tipo de acceso lo determina DÓNDE pones @Id:
// @Id en el campo → AccessType.FIELD (Hibernate lee/escribe el campo)
// @Id en el getter → AccessType.PROPERTY (Hibernate llama a los métodos)
// Usa FIELD siempre: es lo esperado y evita efectos secundarios en los getters.
@Entity
@Access(AccessType.FIELD) // explícito, para que no haya dudas
public class Producto {
@Id @GeneratedValue private Long id;
@Column(name = "precio", nullable = false, precision = 12, scale = 2)
private BigDecimal precio;
// Caso legítimo de PROPERTY: la representación en BD difiere del campo Java
@Access(AccessType.PROPERTY)
@Column(name = "nombre_normalizado", nullable = false)
public String getNombreNormalizado() {
return nombre == null ? null
: Normalizer.normalize(nombre, Normalizer.Form.NFD)
.replaceAll("\\p{M}", "").toLowerCase();
}
protected void setNombreNormalizado(String ignorado) { /* derivado */ }
}
El peligro de AccessType.PROPERTY: Hibernate llamará a tus getters y
setters en momentos que no controlas (al cargar, al hacer flush, al comparar para el
dirty checking). Si un getter tiene lógica —inicializa perezosamente, lanza una excepción si
el estado es inválido, registra un log, incrementa un contador— tendrás comportamientos imposibles de depurar.
Con FIELD, Hibernate escribe directamente el campo por reflexión y tus métodos quedan intactos.
3.6 Herencia: cuatro estrategias, una recomendación
El modelo relacional no tiene herencia, así que JPA ofrece cuatro formas de simularla. Ninguna es gratis. La
jerarquía de nuestro ejemplo: Pago abstracto con PagoTarjeta y
PagoTransferencia.
SINGLE_TABLE: una tabla con discriminador
@Entity
@Table(name = "pago")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "tipo", discriminatorType = DiscriminatorType.STRING, length = 20)
public abstract class Pago {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "pedido_id", unique = true)
private Pedido pedido;
@Column(nullable = false, precision = 12, scale = 2) private BigDecimal importe;
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20)
private EstadoPago estado;
public abstract String descripcionParaRecibo();
}
@Entity
@DiscriminatorValue("TARJETA")
public class PagoTarjeta extends Pago {
// Estas columnas NO pueden ser NOT NULL en la tabla: las filas de
// TRANSFERENCIA las tendrán a null. Es la limitación clave de SINGLE_TABLE.
@Column(name = "ultimos_cuatro", length = 4) private String ultimosCuatro;
@Column(name = "marca", length = 20) private String marca;
@Override public String descripcionParaRecibo() {
return "Tarjeta %s ****%s".formatted(marca, ultimosCuatro);
}
}
@Entity
@DiscriminatorValue("TRANSFERENCIA")
public class PagoTransferencia extends Pago {
@Column(name = "iban", length = 34) private String iban;
@Column(name = "fecha_valor") private LocalDate fechaValor;
@Override public String descripcionParaRecibo() {
return "Transferencia desde " + iban.substring(iban.length() - 4);
}
}
-- SQL generado. Fíjate en lo eficiente que es:
-- select p from Pago p where p.estado = 'PENDIENTE'
select p1_0.id, p1_0.tipo, p1_0.importe, p1_0.estado, p1_0.pedido_id,
p1_0.ultimos_cuatro, p1_0.marca, p1_0.iban, p1_0.fecha_valor
from pago p1_0
where p1_0.estado = 'PENDIENTE'; -- ¡una sola tabla!
-- select t from PagoTarjeta t
select ... from pago p1_0 where p1_0.tipo = 'TARJETA'; -- filtro automático
-- Y como no se pueden poner NOT NULL, se compensa con un CHECK condicional:
alter table pago add constraint ck_pago_tarjeta
check (tipo <> 'TARJETA' or (ultimos_cuatro is not null and marca is not null));
alter table pago add constraint ck_pago_transferencia
check (tipo <> 'TRANSFERENCIA' or iban is not null);
-- Así recuperas la integridad que SINGLE_TABLE te quita. Poca gente lo hace.
JOINED: una tabla por clase, unidas por la PK
@Entity
@Table(name = "pago")
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Pago { … }
@Entity
@Table(name = "pago_tarjeta")
@PrimaryKeyJoinColumn(name = "pago_id")
public class PagoTarjeta extends Pago {
@Column(nullable = false, length = 4) private String ultimosCuatro; // ¡sí puede ser NOT NULL!
@Column(nullable = false, length = 20) private String marca;
}
-- Consultar la clase concreta: un JOIN
select p1_0.id, p1_0.importe, p1_0.estado, t1_0.ultimos_cuatro, t1_0.marca
from pago p1_0 join pago_tarjeta t1_0 on p1_0.id = t1_0.pago_id;
-- Consultar la clase padre polimórfica: LEFT JOIN a TODAS las hijas
select p1_0.id, p1_0.importe,
case when t1_0.pago_id is not null then 1
when r1_0.pago_id is not null then 2
when p1_0.id is not null then 0 end as clazz_,
t1_0.marca, t1_0.ultimos_cuatro, r1_0.iban, r1_0.fecha_valor
from pago p1_0
left join pago_tarjeta t1_0 on p1_0.id = t1_0.pago_id
left join pago_transferencia r1_0 on p1_0.id = r1_0.pago_id;
-- Con 3 subclases son 3 LEFT JOIN. Con 12, doce. Ahí se nota.
TABLE_PER_CLASS: una tabla independiente por clase
-- Consultar la clase padre genera un UNION ALL de todas las hijas.
-- El optimizador no puede empujar filtros ni índices con eficacia.
select * from (
select id, importe, estado, ultimos_cuatro, marca, null as iban, null as fecha_valor, 1 as clazz_
from pago_tarjeta
union all
select id, importe, estado, null, null, iban, fecha_valor, 2 as clazz_
from pago_transferencia
) as union_subquery
where estado = 'PENDIENTE';
-- Y además: no puede usar IDENTITY (los ids tienen que ser únicos entre tablas),
-- las claves foráneas hacia el padre son imposibles y el rendimiento polimórfico
-- es malo por diseño.
@MappedSuperclass: reutilizar campos sin polimorfismo
// No es una estrategia de herencia: es reutilización de código.
// No hay tabla para la superclase, no se puede consultar polimórficamente
// ("select a from Auditable a" NO compila), y no admite relaciones hacia ella.
// Es EXACTAMENTE lo que quieres para campos de auditoría.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class EntidadAuditable {
@CreatedDate @Column(name = "creado_en", nullable = false, updatable = false)
private Instant creadoEn;
@CreatedBy @Column(name = "creado_por", updatable = false, length = 100)
private String creadoPor;
@LastModifiedDate @Column(name = "actualizado_en")
private Instant actualizadoEn;
@LastModifiedBy @Column(name = "actualizado_por", length = 100)
private String actualizadoPor;
@Version private long version;
}
@Entity
@Table(name = "pedido")
public class Pedido extends EntidadAuditable {
// hereda los cinco campos, sin tabla intermedia y sin coste de consulta
}
| Criterio | SINGLE_TABLE | JOINED | TABLE_PER_CLASS | @MappedSuperclass |
| Número de tablas | 1 | 1 + una por subclase | Una por subclase concreta | Una por entidad, sin relación |
| Consulta de la subclase | Rapidísima (filtro por discriminador) | 1 JOIN | 1 tabla | 1 tabla |
| Consulta polimórfica del padre | Rapidísima | LEFT JOIN a todas las hijas | UNION ALL de todas (lento) | No existe |
NOT NULL en campos de subclase | Imposible (solo con CHECK condicional) | Sí, natural | Sí | Sí |
| Claves foráneas hacia el padre | Sí | Sí | Imposible | No aplica |
| Inserción | 1 INSERT | 2 INSERT (padre + hija) | 1 INSERT | 1 INSERT |
| Espacio desperdiciado | Sí: columnas nulas | No | Sí: columnas del padre repetidas | Igual |
| Añadir una subclase | ALTER TABLE ADD COLUMN (barato, pero la tabla crece) | CREATE TABLE (limpio) | CREATE TABLE | Nada |
Con IDENTITY | Sí | Sí | No | Sí |
| Veredicto | Por defecto si hay pocas subclases y pocos campos propios | Cuando la integridad importa y hay muchos campos específicos | Evítala | Úsala para campos comunes sin polimorfismo |
Mi recomendación, por orden: (1) Pregúntate si necesitas herencia. A menudo un enum
tipo más un jsonb con los datos específicos, o simplemente dos entidades separadas,
resuelve mejor el problema con la mitad de complejidad. (2) Si la necesitas de verdad y hay 2-4 subclases con
pocos campos propios, SINGLE_TABLE con CHECK condicionales. (3) Si hay muchas
subclases con muchos campos y necesitas NOT NULL, JOINED. (4)
TABLE_PER_CLASS, nunca. (5) Para campos comunes de auditoría, @MappedSuperclass
siempre.
3.7 Lombok en entidades: qué se puede y qué no
| Anotación | ¿En entidades? | Por qué |
@Getter | ✓ Sí | Inofensivo. |
@Setter | ~ Con cuidado | Mejor por campo. Un setter público para cada campo destruye los invariantes del dominio: cualquiera puede poner un total negativo. |
@NoArgsConstructor(access = PROTECTED) | ✓ Sí | Es exactamente lo que JPA exige. |
@Builder | ~ Con cuidado | Necesita @AllArgsConstructor. Y si el builder permite construir una entidad inválida, has perdido el control del dominio. |
@EqualsAndHashCode | ✗ No, salvo onlyExplicitlyIncluded | Por defecto usa todos los campos: dispara relaciones perezosas y compara colecciones enteras. Sección 5.10. |
@ToString | ✗ No, salvo @ToString.Exclude en todas las relaciones | Recorre el grafo: consultas ocultas y, en relaciones bidireccionales, recursión infinita y StackOverflowError al escribir un log. |
@Data | ✗ JAMÁS | Es @Getter + @Setter + @EqualsAndHashCode + @ToString + @RequiredArgsConstructor: acumula todos los problemas anteriores a la vez. |
@Value | ✗ No | Hace la clase final e inmutable: Hibernate no puede crear proxies ni escribir campos. |
// Lombok en una entidad, hecho bien
@Entity
@Table(name = "pedido")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // el que exige JPA
@ToString(onlyExplicitlyIncluded = true) // nada por defecto
public class Pedido {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pedido_gen")
@ToString.Include
private Long id;
@ToString.Include
@Column(nullable = false, length = 36, updatable = false)
private String referencia;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "cliente_id")
private Cliente cliente; // NO incluido en toString: LAZY
@OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, orphanRemoval = true)
private List<LineaPedido> lineas = new ArrayList<>(); // NO en toString
// Sin @Setter: los cambios de estado pasan por métodos con significado.
// Así el dominio protege sus invariantes y el código se lee como el negocio.
public void confirmar() {
if (estado != EstadoPedido.BORRADOR)
throw new EstadoInvalidoException("Solo se confirma un borrador");
if (lineas.isEmpty())
throw new EstadoInvalidoException("Un pedido sin líneas no se puede confirmar");
this.estado = EstadoPedido.CONFIRMADO;
this.total = calcularTotal();
}
}
La historia real del StackOverflowError: Pedido con
@Data y lineas; LineaPedido con @Data y
pedido. Alguien escribe log.debug("Guardando {}", pedido). El
toString() del pedido recorre las líneas, el de cada línea recorre el pedido, que recorre las
líneas… El servicio se cae con StackOverflowError en una línea de log que solo se activa en el
perfil de depuración, así que pasa todos los tests y explota el día que alguien sube el nivel de log en
producción para investigar otra cosa.
Resumen de decisiones de mapeo
| Decisión | Elige | Motivo en una frase |
| Generación de id en PostgreSQL | SEQUENCE con allocationSize = 50 | IDENTITY desactiva el batching de inserciones sin avisar. |
| Clave primaria | Subrogada (bigint) + único sobre la clave natural | Las claves naturales cambian; las subrogadas no significan nada, así que no cambian. |
| Identificador público | Referencia opaca, no el id | Evita enumeración y no filtra el volumen de negocio. |
| Enumerados | EnumType.STRING | ORDINAL corrompe los datos al reordenar el enum. |
| Fechas | Instant + timestamptz + todo en UTC | LocalDateTime pierde la zona y nunca se recupera. |
| Dinero | BigDecimal con precision/scale, o céntimos en long | double no representa 0,1 exactamente. |
| JSON | @JdbcTypeCode(SqlTypes.JSON) con un tipo inmutable | El dirty checking no ve dentro de un Map mutable. |
| Binarios | Fuera de la base de datos | @Basic(fetch = LAZY) no funciona sin instrumentación. |
| Herencia | SINGLE_TABLE, o mejor evitarla | TABLE_PER_CLASS genera UNION ALL; JOINED, LEFT JOIN por subclase. |
| Campos de auditoría | @MappedSuperclass | Reutilización sin coste de consulta ni polimorfismo innecesario. |
| Tipo de acceso | FIELD | Hibernate no ejecutará la lógica de tus getters en momentos imprevistos. |
| Lombok | @Getter + @NoArgsConstructor(PROTECTED) | @Data trae equals, hashCode y toString tóxicos. |
4 · Relaciones entre entidades
Las relaciones son donde JPA da más productividad y más disgustos. La razón es la fricción de la
sección 1.2: en el modelo relacional una clave foránea es una cosa sin dirección,
mientras que en objetos hay dos referencias que hay que mantener coherentes a mano. Todo lo que sigue —lado
propietario, mappedBy, métodos helper, cascadas— existe para gestionar esa diferencia.
Regla que resuelve el 80 % de las decisiones de esta sección: el lado @ManyToOne es
el que tiene la clave foránea y el que manda. Todo lo demás es opcional y hay que justificarlo. Si te
preguntas «¿pongo también el @OneToMany?», la respuesta por defecto es no, y la
pregunta correcta es «¿necesito navegar de padre a hijos en el dominio?».
4.1 @ManyToOne: la relación fundamental
Es la única relación que se mapea directamente a lo que existe en la base de datos: una columna con una clave
foránea. Todas las demás se construyen a partir de ella.
@Entity
@Table(name = "pedido")
public class Pedido {
@ManyToOne(fetch = FetchType.LAZY, // ← SIEMPRE explícito: el defecto es EAGER
optional = false) // ← se traduce en inner join, no left join
@JoinColumn(name = "cliente_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_pedido_cliente"))
private Cliente cliente;
}
| Atributo | Defecto | Qué hace | Recomendación |
fetch |
EAGER |
Si EAGER, cargar un Pedido carga siempre su Cliente, aunque nunca lo uses. |
LAZY siempre, sin excepciones. Sección 4.10. |
optional |
true |
Con true, Hibernate usa left join al hacer fetch y no puede saber si la relación existe sin consultarla. |
false cuando la FK es NOT NULL: genera mejor SQL y permite un proxy sin comprobación. |
@JoinColumn(name) |
<campo>_<pk> |
Nombre de la columna FK. |
Explícito siempre. |
@JoinColumn(insertable/updatable) |
true |
Si la columna participa en INSERT/UPDATE. |
updatable = false si la relación es inmutable (un pedido no cambia de cliente). |
@ForeignKey |
generado |
Nombre de la restricción en la generación de esquema. |
Nómbrala tú: cuando falle en producción, el mensaje será legible. |
El truco de getReferenceById para asignar relaciones
// ✗ Carga el cliente completo para no usar más que su id
@Transactional
public Pedido crear(Long clienteId) {
Cliente cliente = clienteRepo.findById(clienteId).orElseThrow(); // 1 SELECT
return pedidoRepo.save(Pedido.nuevo(cliente)); // 1 INSERT
}
// ✓ Un proxy: no hay SELECT, solo se usa el id para la FK
@Transactional
public Pedido crear(Long clienteId) {
Cliente cliente = clienteRepo.getReferenceById(clienteId); // 0 consultas
return pedidoRepo.save(Pedido.nuevo(cliente)); // 1 INSERT
}
// Contrapartida honesta: si clienteId no existe, no lo sabes aquí. Fallará el
// INSERT con una violación de clave foránea en el flush, con un mensaje del
// driver menos amable. Es un intercambio razonable cuando el id viene de una
// fuente de confianza (otro registro de tu BD); si viene del usuario, valida.
@ManyToOne(fetch = LAZY) y el proxy que no siempre es proxy: para que Hibernate pueda
devolver un proxy, la clase debe ser no final y tener constructor sin argumentos accesible. Si
Cliente es final (por ejemplo, porque alguien puso @Value de Lombok),
Hibernate ignora el LAZY en silencio y carga la entidad. Lo mismo pasa con
@OneToOne en el lado no propietario (sección 4.3).
4.2 @OneToMany: las tres formas y sus consecuencias
// FORMA 1 · Bidireccional con mappedBy — la CORRECTA en la mayoría de los casos.
// La FK la gestiona el lado @ManyToOne. El @OneToMany es solo una vista de lectura
// más las cascadas.
@Entity
public class Pedido {
@OneToMany(mappedBy = "pedido", // nombre del campo en LineaPedido
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private List<LineaPedido> lineas = new ArrayList<>();
}
@Entity
public class LineaPedido {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "pedido_id", nullable = false)
private Pedido pedido; // ← el lado PROPIETARIO
}
// FORMA 2 · Unidireccional con @JoinColumn — funciona, pero peor.
// Sin el @ManyToOne al otro lado, Hibernate no puede poner la FK en el INSERT
// de la línea: inserta con pedido_id nulo y luego hace un UPDATE.
@Entity
public class Pedido {
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "pedido_id") // sin mappedBy
private List<LineaPedido> lineas = new ArrayList<>();
}
// FORMA 3 · Unidireccional SIN @JoinColumn — el error clásico.
// Hibernate crea una TABLA DE UNIÓN que nadie pidió: pedido_linea_pedido.
@Entity
public class Pedido {
@OneToMany(cascade = CascadeType.ALL)
private List<LineaPedido> lineas = new ArrayList<>(); // ✗ tabla intermedia
}
Comparación del SQL al guardar un pedido con 3 líneas:
FORMA 1 (bidireccional con mappedBy) → 4 sentencias
insert into pedido (...) values (...)
insert into linea_pedido (pedido_id, producto_id, cantidad, precio_unitario, id) values (...) ×3
↑ la FK va en el INSERT: perfecto
FORMA 2 (unidireccional con @JoinColumn) → 7 sentencias
insert into pedido (...) values (...)
insert into linea_pedido (producto_id, cantidad, precio_unitario, id) values (...) ×3
update linea_pedido set pedido_id = ? where id = ? ×3
↑ un UPDATE extra por línea. Con 100 líneas, 100 UPDATE.
FORMA 3 (unidireccional sin @JoinColumn) → 7 sentencias + una tabla de más
insert into pedido ...
insert into linea_pedido ... ×3
insert into pedido_linea_pedido (pedido_id, lineas_id) values (?,?) ×3
↑ y ahora hay una tabla de unión para un 1:N
Regla de oro del @OneToMany: si lo pones, que sea siempre con
mappedBy y con el @ManyToOne correspondiente al otro lado. Y pregúntate antes si lo
necesitas: un @OneToMany es una invitación a cargar una colección entera. Para «las líneas de este
pedido» tiene todo el sentido (son parte del agregado, son pocas y se guardan juntas). Para «los pedidos de
este cliente» no: pueden ser 10.000 y nunca los querrás todos. Eso es una consulta paginada en
el repositorio de pedidos, no una colección en Cliente.
La colección que tumba el servicio: cliente.getPedidos().size() parece inocente y ejecuta
select * from pedido where cliente_id = ? sin LIMIT. Con un cliente mayorista de
50.000 pedidos, eso es medio gigabyte en memoria y un OutOfMemoryError. Los @OneToMany
solo son seguros cuando la colección está acotada por diseño (líneas de un pedido, direcciones
de un cliente, roles de un usuario). Si puede crecer sin límite, no la mapees.
4.3 @OneToOne: la relación con la trampa del LAZY
// Lado PROPIETARIO: tiene la FK. El LAZY aquí funciona perfectamente.
@Entity
@Table(name = "pago")
public class Pago {
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "pedido_id", unique = true)
private Pedido pedido;
}
// Lado INVERSO: NO tiene columna. Aquí el LAZY *no funciona* si es opcional.
@Entity
public class Pedido {
@OneToOne(mappedBy = "pedido", fetch = FetchType.LAZY)
private Pago pago; // ← Hibernate ejecutará un SELECT igualmente
}
Por qué el LAZY no funciona en el lado inverso de un @OneToOne. Para devolver
un proxy, Hibernate necesita saber que la relación existe: un proxy no puede ser null.
En el lado propietario lo sabe porque mira su columna FK (si es nula, no hay relación). En el lado inverso no
hay columna, así que la única forma de saber si hay un pago para ese pedido es consultarlo. Por
eso Hibernate ejecuta un SELECT aunque hayas puesto LAZY. Con 200 pedidos en un
listado, son 200 consultas extra que no habías pedido: un N+1 perfecto y silencioso.
// SOLUCIÓN 1 (la mejor): no mapees el lado inverso.
// Si necesitas el pago de un pedido, consúltalo en su repositorio.
public interface PagoRepository extends Repository<Pago, Long> {
Optional<Pago> findByPedidoId(Long pedidoId);
}
// SOLUCIÓN 2: declarar optional = false y prometer que siempre existe.
// Hibernate ya puede devolver un proxy sin comprobar nada... pero si algún día
// hay un pedido sin pago, obtendrás una EntityNotFoundException al acceder.
@OneToOne(mappedBy = "pedido", fetch = FetchType.LAZY, optional = false)
private Pago pago;
// SOLUCIÓN 3: PK compartida. La más elegante cuando la relación es 1:1 real:
// pago.id == pedido.id, así que no hace falta columna extra ni consulta.
@Entity
public class Pago {
@Id private Long id; // el MISMO id que el pedido
@MapsId // la PK se toma de la relación
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "id")
private Pedido pedido;
}
// Ahora pedido.getPago() puede ser un proxy: Hibernate sabe que el id coincide.
// SOLUCIÓN 4: si de verdad necesitas navegar y quieres una sola consulta,
// cárgalo con join fetch o @EntityGraph cuando lo vayas a usar (sección 7).
Cuándo usar @OneToOne en lugar de meter los campos en la misma tabla: solo hay tres motivos
buenos. (1) La relación es opcional y poco frecuente: 5 % de los pedidos tienen
devolución, no vas a añadir 8 columnas nulas a la tabla principal. (2) Los datos son grandes
(un documento, un texto enorme) y quieres que no lastren la tabla caliente. (3) Los datos tienen
seguridad o ciclo de vida distintos (datos de tarjeta, datos médicos) y quieres poder darles
permisos o retención diferentes. Si no es ninguno de los tres, usa un @Embedded en la misma tabla
y ahórrate el JOIN.
4.4 @ManyToMany y por qué casi siempre es un error
// Lo que enseñan los tutoriales
@Entity
public class Producto {
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "producto_etiqueta",
joinColumns = @JoinColumn(name = "producto_id"),
inverseJoinColumns = @JoinColumn(name = "etiqueta_id"))
private Set<Etiqueta> etiquetas = new HashSet<>();
}
@Entity
public class Etiqueta {
@ManyToMany(mappedBy = "etiquetas")
private Set<Producto> productos = new HashSet<>();
}
Los cinco problemas del @ManyToMany, en orden de gravedad.
- No puedes añadir atributos a la relación. El día que el negocio pida «¿cuándo se
etiquetó?» o «¿quién lo etiquetó?» o «¿es la etiqueta principal?», tienes que rehacer el mapeo entero. Y ese
día llega siempre.
- Con
List, cada cambio borra y reinserta toda la tabla de unión (semántica
de bag, sección 4.9). Añadir una etiqueta a un producto con 40 ejecuta 1 DELETE
masivo y 41 INSERT.
- Las cascadas son peligrosas. Con
CascadeType.REMOVE en un
@ManyToMany, borrar un producto borra las etiquetas… que otros productos estaban usando. Es un
camino directo a la pérdida de datos.
- La bidireccionalidad es frágil. Hay que actualizar los dos
Set a mano y, si
te olvidas de uno, el estado en memoria y el de la base de datos difieren hasta el siguiente arranque.
- Es imposible paginar u ordenar la relación con eficacia, y las consultas sobre ella
requieren bajar a la tabla de unión que el mapeo pretendía ocultar.
// LA FORMA CORRECTA: la tabla de unión es una ENTIDAD con nombre propio.
// Más código, cero sorpresas, y crecer no cuesta nada.
@Entity
@Table(name = "producto_etiqueta")
public class ProductoEtiqueta {
@EmbeddedId
private ProductoEtiquetaId id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@MapsId("productoId")
@JoinColumn(name = "producto_id")
private Producto producto;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@MapsId("etiquetaId")
@JoinColumn(name = "etiqueta_id")
private Etiqueta etiqueta;
// Y ahora sí: atributos propios de la relación, sin rehacer nada
@Column(name = "anadida_en", nullable = false, updatable = false)
private Instant anadidaEn = Instant.now();
@Column(name = "anadida_por", length = 100, updatable = false)
private String anadidaPor;
@Column(name = "principal", nullable = false)
private boolean principal = false;
protected ProductoEtiqueta() { }
public ProductoEtiqueta(Producto producto, Etiqueta etiqueta, String usuario) {
this.id = new ProductoEtiquetaId(producto.getId(), etiqueta.getId());
this.producto = producto;
this.etiqueta = etiqueta;
this.anadidaPor = usuario;
}
}
// El repositorio da todo lo que el @ManyToMany no daba, con SQL predecible
public interface ProductoEtiquetaRepository
extends Repository<ProductoEtiqueta, ProductoEtiquetaId> {
List<ProductoEtiqueta> findByProductoIdOrderByAnadidaEnDesc(Long productoId);
Page<ProductoEtiqueta> findByEtiquetaId(Long etiquetaId, Pageable pageable);
void deleteByProductoIdAndEtiquetaId(Long productoId, Long etiquetaId);
boolean existsByProductoIdAndEtiquetaId(Long productoId, Long etiquetaId);
long countByEtiquetaId(Long etiquetaId);
}
Cuándo @ManyToMany sí es aceptable: cuando la relación es un puro conjunto sin atributos,
pequeña, estable y no se consulta desde el otro lado. El ejemplo canónico es Usuario ↔
Rol: cinco roles fijos, sin metadatos, nadie pregunta «¿cuándo se le dio este rol?». En ese caso
usa Set (nunca List), cascade = {PERSIST, MERGE} (nunca
REMOVE) y mapea solo el lado que necesites navegar. En cuanto huelas un atributo en la relación,
conviértela en entidad.
4.5 Lado propietario, mappedBy y @JoinTable
El lado propietario es el que tiene la clave foránea y, por tanto, el único cuyos cambios
Hibernate traduce a SQL. El lado inverso (mappedBy) es una vista: puedes cambiarlo todo lo que
quieras y no pasará nada en la base de datos. Este es el malentendido que produce más «he guardado y no se ha
guardado» del ecosistema.
@Transactional
public void demostrarLadoPropietario(Long pedidoId, Long productoId) {
Pedido pedido = pedidoRepo.findById(pedidoId).orElseThrow();
LineaPedido linea = new LineaPedido(productoRepo.getReferenceById(productoId), 2,
new BigDecimal("19.90"));
// ✗ CASO 1: solo el lado inverso. NO SE GUARDA NADA.
pedido.getLineas().add(linea);
// Hibernate mira LineaPedido.pedido (el lado propietario) → es null.
// Si hay cascade = PERSIST, insertará la línea con pedido_id = null → error
// de NOT NULL. Si no hay cascada, ni eso: la línea es transitoria y se ignora.
// ✓ CASO 2: solo el lado propietario. SÍ SE GUARDA.
linea.setPedido(pedido);
// Hibernate ve la FK y genera el INSERT correcto. La colección en memoria
// queda desactualizada hasta que se recargue el pedido, pero la BD está bien.
// ✓✓ CASO 3: los dos lados. Lo correcto: la BD y la memoria coinciden.
pedido.getLineas().add(linea);
linea.setPedido(pedido);
// Y eso es exactamente lo que hace un método helper (sección 4.6).
}
| Relación | Quién es propietario | Cómo se declara el inverso |
@ManyToOne / @OneToMany | Siempre el @ManyToOne: tiene la FK. | @OneToMany(mappedBy = "campoDelManyToOne") |
@OneToOne | El que declara @JoinColumn. | @OneToOne(mappedBy = "campo") |
@ManyToMany | El que declara @JoinTable. Elección arbitraria. | @ManyToMany(mappedBy = "coleccion") |
@OneToMany con @JoinColumn (unidireccional) | El @OneToMany, excepcionalmente. | No hay inverso. |
// @JoinColumn vs @JoinTable en un @OneToMany: cuándo cada uno
// @JoinColumn: la FK está en la tabla hija. Lo normal.
@OneToMany(mappedBy = "pedido")
private List<LineaPedido> lineas; // linea_pedido.pedido_id
// @JoinTable en un 1:N: solo tiene sentido si NO PUEDES tocar la tabla hija.
// Caso real: la tabla `documento` es de otro sistema y no admite una columna nueva.
@OneToMany
@JoinTable(name = "pedido_documento",
joinColumns = @JoinColumn(name = "pedido_id"),
inverseJoinColumns = @JoinColumn(name = "documento_id", unique = true))
private List<Documento> documentos = new ArrayList<>();
// El unique = true en la columna inversa es lo que lo convierte en 1:N y no en N:M.
4.6 Métodos helper: la coherencia en un solo sitio
@Entity
public class Pedido {
@OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, orphanRemoval = true)
private List<LineaPedido> lineas = new ArrayList<>();
// 1) La colección se expone SOLO como lectura. Nadie puede añadir por su cuenta.
public List<LineaPedido> getLineas() {
return Collections.unmodifiableList(lineas);
}
// 2) Los cambios pasan por métodos que mantienen los DOS lados y los invariantes
public LineaPedido anadirLinea(Producto producto, int cantidad) {
exigirBorrador();
LineaPedido existente = buscarLinea(producto);
if (existente != null) { // regla de negocio: se agrupa
existente.incrementar(cantidad);
recalcularTotal();
return existente;
}
LineaPedido linea = new LineaPedido(this, producto, cantidad, producto.getPrecio());
lineas.add(linea); // lado inverso
linea.setPedido(this); // lado propietario ← los dos, siempre
recalcularTotal();
return linea;
}
public void quitarLinea(LineaPedido linea) {
exigirBorrador();
if (lineas.remove(linea)) {
linea.setPedido(null); // con orphanRemoval = true, esto la BORRA
recalcularTotal();
}
}
public void vaciar() {
exigirBorrador();
lineas.forEach(l -> l.setPedido(null));
lineas.clear(); // con orphanRemoval, un DELETE por línea
recalcularTotal();
}
private void recalcularTotal() {
this.total = lineas.stream()
.map(LineaPedido::importe)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
}
private void exigirBorrador() {
if (estado != EstadoPedido.BORRADOR)
throw new EstadoInvalidoException(
"No se pueden modificar las líneas de un pedido " + estado);
}
private LineaPedido buscarLinea(Producto producto) {
return lineas.stream()
.filter(l -> l.getProducto().getId().equals(producto.getId()))
.findFirst().orElse(null);
}
}
Por qué esto es tan valioso: el total no puede quedar desincronizado, no puede haber una línea sin
pedido, no se pueden tocar las líneas de un pedido ya confirmado y no hay que acordarse de nada al llamar. Los
invariantes viven dentro del agregado en lugar de estar repartidos por cinco servicios. Esta es la
diferencia práctica entre una entidad anémica y un modelo de dominio: no son las anotaciones, son los métodos.
4.7 Cascadas: las seis, una por una
Una cascada propaga una operación del contexto de persistencia desde una entidad a las asociadas. No es una
cascada de SQL (ON DELETE CASCADE es otra cosa, la hace la base de datos): es Hibernate
recorriendo el grafo de objetos.
| Cascada | Se dispara con | Qué propaga | Cuándo usarla |
PERSIST |
em.persist(), save() de una entidad nueva |
Inserta también las entidades nuevas asociadas. |
De padre a hijos en una composición: guardar el pedido guarda sus líneas. |
MERGE |
em.merge(), save() de una entidad separada |
Copia el estado de los asociados separados al contexto. |
Junto con PERSIST, cuando trabajas con entidades separadas. |
REMOVE |
em.remove(), delete() |
Borra también los asociados. |
Solo en composición estricta (el hijo no tiene sentido sin el padre). |
REFRESH |
em.refresh() |
Relee los asociados desde la base de datos. |
Raro. Tras una operación externa que ha cambiado los datos. |
DETACH |
em.detach() |
Separa también los asociados. |
Raro. Al preparar un grafo para serializarlo o enviarlo. |
ALL |
Todas las anteriores |
Todo. |
Solo en composición: Pedido → LineaPedido. Nunca hacia arriba. |
// ✓ CORRECTO: composición. Las líneas no existen sin el pedido.
@Entity
public class Pedido {
@OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, orphanRemoval = true)
private List<LineaPedido> lineas = new ArrayList<>();
}
// ✗ CATASTRÓFICO: cascada hacia una entidad compartida.
@Entity
public class LineaPedido {
@ManyToOne(cascade = CascadeType.ALL) // ← BORRAR UNA LÍNEA BORRA EL PRODUCTO
private Producto producto;
}
// Borras una línea de un pedido y desaparece el producto del catálogo, con las
// otras 4.000 líneas que lo referenciaban. En el mejor caso falla por FK; en el
// peor, con ON DELETE CASCADE en la BD, se lleva media base de datos por delante.
// ✗ TAMBIÉN MAL: cascada de un hijo hacia su padre.
@Entity
public class LineaPedido {
@ManyToOne(cascade = CascadeType.REMOVE) // borrar una línea borra EL PEDIDO
private Pedido pedido;
}
// ✓ Lo correcto en el lado @ManyToOne: NINGUNA cascada.
@Entity
public class LineaPedido {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "producto_id", nullable = false)
private Producto producto; // sin cascade. Punto.
}
La regla mnemotécnica de las cascadas: las cascadas solo van del agregado hacia sus
partes, nunca al contrario ni entre agregados distintos. Si al borrar A tiene sentido que desaparezca
B porque B no existe sin A, cascada. Si B tiene vida propia y otros lo referencian, ninguna cascada. En la
práctica: CascadeType.ALL en los @OneToMany de composición y nada en
todos los @ManyToOne. Una cascada mal puesta es el bug más caro de esta sección porque destruye
datos, y los datos destruidos no se recuperan con un rollback del despliegue.
4.8 orphanRemoval frente a CascadeType.REMOVE
| Escenario | cascade = REMOVE | orphanRemoval = true |
Se borra el padre (delete(pedido)) | Borra los hijos | Borra los hijos |
Se quita un hijo de la colección (lineas.remove(l)) | No hace nada. La línea sigue en la BD con su pedido_id | Borra la línea. Es la diferencia clave |
Se reemplaza la colección (setLineas(nuevas)) | Las viejas quedan huérfanas en la BD | Las viejas se borran |
Se pone hijo.setPadre(null) | Nada | Borra el hijo en el flush |
// Demostración de la diferencia, con el SQL al lado
@Transactional
public void quitarPrimeraLinea(Long pedidoId) {
Pedido pedido = repo.findById(pedidoId).orElseThrow();
LineaPedido primera = pedido.getLineas().get(0);
pedido.quitarLinea(primera);
}
// Con @OneToMany(mappedBy="pedido", cascade = ALL) SIN orphanRemoval:
// → ninguna sentencia. La línea sigue en la BD, huérfana pero presente.
// Y al recargar el pedido, ¡vuelve a aparecer! "El borrado no funciona".
// Con @OneToMany(mappedBy="pedido", cascade = ALL, orphanRemoval = true):
// → delete from linea_pedido where id = ?
// Correcto: la línea deja de existir.
Cuidado con orphanRemoval y colecciones grandes: pedido.getLineas().clear()
con 5.000 líneas genera 5.000 DELETE individuales, uno por línea, porque
Hibernate tiene que ejecutar el ciclo de vida de cada entidad (callbacks, caché de segundo nivel,
Envers). Si necesitas borrar en volumen, usa una consulta masiva
(delete from LineaPedido l where l.pedido.id = :id) y limpia el contexto, aceptando que te saltas
los callbacks (sección 6.3).
-- Y una capa más de seguridad, independiente de la aplicación:
-- ON DELETE CASCADE en la base de datos. Protege de código que no pasa por JPA
-- (scripts, otro servicio, un DBA con prisa).
alter table linea_pedido
drop constraint linea_pedido_pedido_id_fkey,
add constraint fk_linea_pedido
foreign key (pedido_id) references pedido (id) on delete cascade;
-- Las dos capas no se contradicen: orphanRemoval mantiene coherente el contexto
-- de persistencia y dispara los callbacks; ON DELETE CASCADE es la red de
-- seguridad para todo lo que no pase por Hibernate.
4.9 Colecciones: List, Set, Map y las bags
Esta subsección explica uno de los comportamientos más desconcertantes de Hibernate: por qué añadir un elemento
a una lista puede generar 41 sentencias.
Qué es una bag
Hibernate distingue tres tipos de colección, y el tipo de Java que declaras
determina cuál usa:
BAG → List sin @OrderColumn. Sin orden y con duplicados permitidos.
LIST → List CON @OrderColumn. Orden mantenido en una columna índice.
SET → Set. Sin orden y sin duplicados.
El problema de la BAG: Hibernate no puede identificar un elemento concreto
dentro de ella (no hay índice ni unicidad), así que cuando la colección cambia
y no puede razonar sobre el cambio, aplica la estrategia bruta:
DELETE todas las filas de la colección
INSERT todas las filas actuales
Con @OneToMany(mappedBy) esto NO ocurre: la FK está en el hijo y Hibernate
gestiona cada hijo individualmente. El estrago se produce en:
· @ManyToMany con List
· @OneToMany unidireccional con @JoinColumn y List
· @ElementCollection con List
// ✗ El caso patológico: @ManyToMany con List
@ManyToMany
@JoinTable(name = "producto_etiqueta", …)
private List<Etiqueta> etiquetas = new ArrayList<>();
producto.getEtiquetas().add(nueva); // producto con 40 etiquetas
// SQL:
// delete from producto_etiqueta where producto_id = ? ← borra las 40
// insert into producto_etiqueta (producto_id, etiqueta_id) values (?,?) ×41
// Total: 42 sentencias para añadir una etiqueta.
// ✓ Con Set: Hibernate identifica los elementos y hace lo mínimo
@ManyToMany
@JoinTable(name = "producto_etiqueta", …)
private Set<Etiqueta> etiquetas = new HashSet<>();
producto.getEtiquetas().add(nueva);
// SQL:
// insert into producto_etiqueta (producto_id, etiqueta_id) values (?,?) ×1
// Total: 1 sentencia. Cuarenta y una menos.
| Tipo | Duplicados | Orden | Coste de modificar | Necesita equals/hashCode | Cuándo usarlo |
List con @OneToMany(mappedBy) |
Sí (en memoria) |
Sin garantía, salvo @OrderBy |
Mínimo: la FK está en el hijo |
No |
El caso normal para 1:N. Es lo que quieres para las líneas de un pedido. |
Set |
No |
Ninguno (o SortedSet + @SortNatural) |
Mínimo |
Sí, y bien (sección 5.10) |
Obligatorio en @ManyToMany. Recomendable si no quieres duplicados. |
List + @OrderColumn |
Sí |
Sí, persistido |
Alto: reordenar reescribe los índices de todas las filas afectadas |
No |
Solo cuando el orden es un dato del negocio y se puede modificar (una playlist). |
List sin nada (bag) en N:M |
Sí |
No |
Terrible: borra e reinserta todo |
No |
Nunca. |
Map |
Claves únicas |
Por clave |
Bajo |
En la clave |
Cuando la colección se accede por una clave natural (traducciones por idioma). |
// @OrderBy: ordena AL CARGAR, con un ORDER BY en el SQL. No persiste nada.
@OneToMany(mappedBy = "pedido")
@OrderBy("creadoEn asc, id asc") // JPQL, no SQL: usa nombres de campo
private List<LineaPedido> lineas = new ArrayList<>();
// SQL: select ... from linea_pedido where pedido_id = ? order by creado_en, id
// @OrderColumn: PERSISTE la posición en una columna. Cuidado con el coste.
@OneToMany(mappedBy = "lista", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderColumn(name = "posicion")
private List<Cancion> canciones = new ArrayList<>();
// Insertar en la posición 0 de una lista de 100 → 100 UPDATE de la columna
// posicion + 1 INSERT. Reordenar es caro por diseño.
// @MapKey / @MapKeyColumn / @MapKeyEnumerated: colecciones indexadas
@OneToMany(mappedBy = "producto", cascade = CascadeType.ALL, orphanRemoval = true)
@MapKey(name = "idioma") // clave = campo de la entidad hija
private Map<String, DescripcionProducto> descripciones = new HashMap<>();
// producto.getDescripciones().get("es").getTexto() ← sin recorrer la lista
@ElementCollection
@CollectionTable(name = "producto_precio_pais",
joinColumns = @JoinColumn(name = "producto_id"))
@MapKeyColumn(name = "pais")
@Column(name = "precio", precision = 12, scale = 2)
private Map<String, BigDecimal> preciosPorPais = new HashMap<>();
// SortedSet con orden natural, sin ORDER BY explícito
@OneToMany(mappedBy = "pedido")
@SortNatural // requiere que LineaPedido sea Comparable
private SortedSet<LineaPedido> lineasOrdenadas = new TreeSet<>();
Cómo decidir en 10 segundos: ¿es un @OneToMany(mappedBy) de composición? →
List, y si necesitas orden, @OrderBy. ¿Es un @ManyToMany? →
Set, sin dudarlo. ¿Es un @ElementCollection? → Set si puedes; si
necesitas orden, acepta el coste y usa @OrderColumn. ¿Se accede por clave? → Map. Y
recuerda: la única colección que puede aparecer dos veces en un join fetch es un
Set; con dos List obtendrás MultipleBagFetchException
(sección 7.4).
4.10 FetchType: por qué EAGER es una trampa
| Relación | fetch por defecto | Qué deberías poner |
@ManyToOne | EAGER ⚠️ | LAZY |
@OneToOne | EAGER ⚠️ | LAZY (y ojo con el lado inverso, sección 4.3) |
@OneToMany | LAZY ✓ | LAZY |
@ManyToMany | LAZY ✓ | LAZY |
@ElementCollection | LAZY ✓ | LAZY |
@Basic / @Lob | EAGER | EAGER (el LAZY no funciona sin instrumentación) |
Los cuatro daños de EAGER, con números.
- Cargas lo que no usas, siempre. Un endpoint que devuelve
referencia y
total de 50 pedidos, con cliente en EAGER, hace 50 consultas extra o un
JOIN que trae 50 clientes completos a memoria para tirarlos.
- El efecto es transitivo y explosivo.
Pedido→Cliente EAGER,
Cliente→Direcciones EAGER, Direccion→Pais EAGER: leer un
pedido carga media base de datos. Y el desarrollador que añadió el tercer EAGER no tenía forma
de saber el efecto en el primero.
- No se puede desactivar por consulta.
LAZY se puede convertir en carga
inmediata cuando quieras (join fetch, @EntityGraph); EAGER
no se puede convertir en perezoso. Es una decisión global e irreversible tomada en el sitio
con menos información.
- Rompe la paginación. Un
@OneToMany(fetch = EAGER) con
Pageable obliga a Hibernate a paginar en memoria: trae todas las filas y descarta
(sección 7.5).
// Demostración del coste transitivo
@Entity class Pedido { @ManyToOne Cliente cliente; } // EAGER
@Entity class Cliente { @OneToMany(fetch = EAGER) List<Direccion> direcciones; }
@Entity class Direccion { @ManyToOne Pais pais; } // EAGER
// pedidoRepo.findById(1L) genera:
// select * from pedido where id = 1
// select * from cliente where id = ?
// select * from direccion where cliente_id = ? (3 direcciones)
// select * from pais where id = ? ×3
// 6 consultas para leer una referencia y un total. Y con findAll() de 100
// pedidos: entre 100 y 600 consultas, dependiendo de la caché de primer nivel.
// La estrategia correcta: TODO perezoso + carga explícita donde la necesitas.
// Así cada caso de uso decide qué necesita, con la información completa.
// Caso 1: solo necesito el pedido → 1 consulta
Pedido p = repo.findById(id).orElseThrow();
// Caso 2: necesito pedido + cliente → 1 consulta con JOIN
@Query("select p from Pedido p join fetch p.cliente where p.id = :id")
Optional<Pedido> findByIdConCliente(Long id);
// Caso 3: necesito el agregado completo para confirmarlo → 1 consulta
@EntityGraph(attributePaths = {"cliente", "lineas", "lineas.producto"})
Optional<Pedido> findWithDetailById(Long id);
// Caso 4: solo necesito mostrar un listado → proyección, ni una entidad
@Query("""
select new com.ejemplo.tienda.pedidos.ResumenPedido(
p.referencia, c.nombre, p.estado, p.total, p.creadoEn)
from Pedido p join p.cliente c
where p.estado = :estado
""")
Page<ResumenPedido> resumenes(EstadoPedido estado, Pageable pageable);
Cómo cazar los EAGER que ya tienes: busca en el proyecto @ManyToOne y
@OneToOne sin fetch explícito. Cada uno es un EAGER. Un
grep de cinco segundos te dirá cuántos hay:
rg -U '@(ManyToOne|OneToOne)\([^)]*\)' --stats src/main/java | rg -v 'LAZY'. En proyectos que
nunca han revisado esto, el resultado suele ser incómodo. Y hay una alternativa mejor que el grep: un
test de arquitectura que falle si aparece un EAGER nuevo (sección 13.5).
Las diez reglas de las relaciones
- Todo
LAZY, explícitamente. @ManyToOne y @OneToOne son EAGER por defecto y eso nunca es lo que quieres.
@ManyToOne es la relación fundamental. Empieza siempre por ella y añade el inverso solo si lo necesitas.
- Si mapeas el
@OneToMany, hazlo con mappedBy. Sin él aparecen UPDATE extra o tablas de unión inesperadas.
- No mapees colecciones sin límite. «Los pedidos de un cliente» es una consulta paginada, no una colección.
- El lado inverso de un
@OneToOne no es perezoso de verdad. Usa @MapsId o no lo mapees.
- Sustituye
@ManyToMany por una entidad intermedia salvo en conjuntos triviales y estables.
- Cascadas solo hacia abajo, del agregado a sus partes. Ninguna en los
@ManyToOne.
orphanRemoval para que quitar de la colección borre de verdad. cascade = REMOVE no hace eso.
Set en @ManyToMany, List en @OneToMany(mappedBy). Las bags borran y reinsertan.
- Métodos helper para los dos lados y colecciones expuestas como inmutables. Los invariantes van dentro del agregado.
5 · El contexto de persistencia
Si solo pudieras leer una sección de este módulo, sería esta. El contexto de persistencia es la pieza que
explica todos los comportamientos «raros» de JPA: por qué no hace falta save(),
por qué dos consultas devuelven el mismo objeto, por qué un cambio se guarda «solo», por qué una excepción
salta en el commit y no donde la esperabas, y por qué falla al acceder a una relación fuera de la
transacción. Sin este modelo mental, JPA es magia; con él, es predecible.
5.1 EntityManager, sesión y transacción
El EntityManager (en Hibernate, Session) es un mapa de trabajo con
memoria: guarda las entidades que has tocado, recuerda cómo estaban al cargarlas y, al final, calcula
y ejecuta el SQL mínimo necesario. Es de vida corta y no es seguro para varios hilos.
Ciclo de vida en una petición HTTP con Spring:
Petición ──► Controlador ──► @Transactional en el servicio
│
├─ 1. JpaTransactionManager abre transacción
├─ 2. Pide una conexión al pool (Hikari)
├─ 3. Crea el EntityManager y lo ata al HILO
│ (TransactionSynchronizationManager)
│
├─ 4. Tu código: find, consultas, cambios
│ Todo va al MISMO EntityManager,
│ aunque llames a 5 repositorios
│ distintos: es el mismo hilo.
│
├─ 5. flush(): calcula y ejecuta el SQL
├─ 6. commit()
├─ 7. Cierra el EntityManager → todas las
│ entidades pasan a SEPARADAS
└─ 8. Devuelve la conexión al pool
│
Respuesta ◄─── serialización JSON ─┘ ← AQUÍ ya no hay sesión.
Tocar un LAZY = LazyInitializationException
Claves:
· Un EntityManager por transacción, atado al hilo.
· Sin @Transactional, cada llamada al repositorio abre y cierra el suyo:
no hay caché compartida, no hay dirty checking entre llamadas y cada
operación es su propia transacción. Casi nunca es lo que quieres.
// Las tres formas de acceder al EntityManager, de más a menos recomendable
// 1) Indirectamente, a través de Spring Data. El 95 % del código.
private final PedidoRepository repo;
// 2) Inyectado, para lo que Spring Data no cubre.
// @PersistenceContext inyecta un PROXY que resuelve el EntityManager del hilo
// actual en cada llamada. Por eso es seguro tenerlo como campo de un singleton.
@PersistenceContext
private EntityManager em;
// 3) La API de Hibernate, para lo específico (estadísticas, StatelessSession,
// filtros, setReadOnly).
Session session = em.unwrap(Session.class);
Nunca inyectes un EntityManager con @Autowired directamente en lugar de
@PersistenceContext. Con @Autowired obtienes el EntityManager compartido
de Spring, que funciona, pero pierdes claridad sobre lo que estás pidiendo; y si algún día alguien crea un
EntityManager con createEntityManager() y lo declara como bean, tendrás un
EntityManager compartido entre hilos, que es una fuente de corrupción de datos difícil de
diagnosticar.
5.2 Los cuatro estados del ciclo de vida
new Pedido(...)
│
▼
┌─────────────────┐
│ TRANSITORIO │ sin id, desconocido para JPA,
│ (transient) │ los cambios no se guardan
└────────┬────────┘
│ persist() / save()
▼
┌───────────────────────────────────────┐
│ GESTIONADO │ en el contexto,
┌───►│ (managed) │ los cambios SE GUARDAN
│ └───┬──────────┬──────────┬─────────────┘ solos en el flush
│ │ │ │
│ detach│ remove│ cierre│ de la sesión
│ clear │ │ commit│
│ ▼ ▼ ▼
│ ┌──────────┐ ┌─────────┐ ┌──────────┐
└──┤ SEPARADO │ │ELIMINADO│ │ SEPARADO │
merge│(detached)│ │(removed)│ │(detached)│
└──────────┘ └────┬────┘ └──────────┘
tiene id, │ flush → DELETE
fuera del ▼
contexto TRANSITORIO (ya no existe en la BD)
Y una transición menos conocida: refresh() sobre una entidad gestionada
descarta los cambios en memoria y relee de la base de datos.
| Estado | Tiene id | Está en el contexto | Los cambios se guardan | Cómo se llega |
| Transitorio | No (normalmente) | No | No | new Pedido() |
| Gestionado | Sí | Sí | Sí, automáticamente | persist, find, merge, una consulta, o getReference |
| Separado | Sí | No | No | Fin de la transacción, detach, clear, o deserializar de JSON |
| Eliminado | Sí | Sí, marcada para borrar | Se borra en el flush | remove, delete |
@Transactional
public void recorridoPorLosEstados() {
// TRANSITORIO: un objeto Java normal, JPA no sabe que existe
Pedido pedido = Pedido.nuevo(clienteRepo.getReferenceById(1L));
assertNull(pedido.getId());
assertFalse(em.contains(pedido));
// → GESTIONADO
em.persist(pedido);
assertNotNull(pedido.getId()); // con SEQUENCE, el id ya está (sin INSERT aún)
assertTrue(em.contains(pedido));
// Gestionado: los cambios se detectan sin llamar a nada
pedido.setMoneda("USD"); // se guardará en el flush
// → SEPARADO
em.detach(pedido);
assertFalse(em.contains(pedido));
pedido.setMoneda("GBP"); // ✗ este cambio NO se guardará
// → GESTIONADO otra vez, pero ATENCIÓN: merge devuelve OTRA instancia
Pedido gestionado = em.merge(pedido);
assertNotSame(pedido, gestionado); // ← el error clásico está aquí
assertTrue(em.contains(gestionado));
assertFalse(em.contains(pedido)); // el original sigue separado
// → ELIMINADO
em.remove(gestionado);
assertTrue(em.contains(gestionado)); // sigue en el contexto, marcada
// El DELETE se ejecuta en el flush, no en remove()
em.flush();
assertFalse(em.contains(gestionado));
}
5.3 Identidad de entidad frente a identidad de objeto
@Transactional
public void garantiaDeIdentidad(Long id) {
Pedido a = repo.findById(id).orElseThrow();
Pedido b = repo.findById(id).orElseThrow();
Pedido c = em.find(Pedido.class, id);
Pedido d = em.createQuery("select p from Pedido p where p.id = :id", Pedido.class)
.setParameter("id", id).getSingleResult();
// Las CUATRO son EXACTAMENTE la misma instancia en memoria
assertSame(a, b);
assertSame(a, c);
assertSame(a, d); // incluso viniendo de una consulta JPQL
// Esta es la garantía del contexto de persistencia:
// dentro de una sesión, una fila de la base de datos
// se corresponde con UNA SOLA instancia Java.
// Por eso == funciona con entidades gestionadas de la misma sesión.
}
@Transactional
public void loQueRompeLaGarantia(Long id) {
Pedido a = repo.findById(id).orElseThrow();
em.clear(); // ← se vacía el contexto
Pedido b = repo.findById(id).orElseThrow(); // nueva consulta, nueva instancia
assertNotSame(a, b); // ✗ ya no son la misma
assertEquals(a.getId(), b.getId()); // pero representan la misma fila
// Y aquí es donde importa tener un equals() correcto (sección 5.10)
}
La consecuencia práctica que sorprende: una consulta JPQL que devuelve una entidad ya cargada
no sobrescribe los cambios que tengas en memoria. Hibernate ejecuta el SELECT,
ve que la fila ya está en el contexto y descarta los datos leídos devolviendo la instancia que
ya tenía, con tus modificaciones intactas. Es lo correcto (si no, perderías tus cambios), pero explica por qué
a veces «la consulta devuelve datos viejos». Si quieres el estado de la base de datos, usa
em.refresh(entidad).
5.4 La caché de primer nivel
El contexto de persistencia es la caché de primer nivel. No se configura, no se desactiva y siempre
está ahí. Sus características son consecuencia directa de lo anterior:
| Característica | Detalle |
| Alcance | Una transacción / un EntityManager. Nunca se comparte entre peticiones ni entre hilos. |
| Clave | El par (clase de entidad, identificador). |
| Qué evita | Un segundo SELECT por id de la misma fila. |
| Qué NO evita | Nada más. Una consulta JPQL siempre va a la base de datos, incluso si la entidad ya está en caché. Solo el resultado se sustituye por las instancias ya gestionadas. |
| Coste | Memoria: cada entidad guarda además una instantánea de su estado original para el dirty checking. Cargar 100.000 entidades ocupa el doble de lo que crees. |
@Transactional
public void queEvitaYQueNo(Long id) {
repo.findById(id); // SELECT ← va a la base de datos
repo.findById(id); // (nada) ← caché de primer nivel
repo.findById(id); // (nada)
// Pero una consulta SIEMPRE se ejecuta:
repo.findByReferencia("PED-1"); // SELECT (aunque el pedido ya esté en caché)
repo.findByReferencia("PED-1"); // SELECT otra vez
// Y esto es importante en bucles: la caché crece sin límite
for (long i = 1; i <= 500_000; i++) {
repo.findById(i); // 500.000 entidades + 500.000 instantáneas
} // → OutOfMemoryError garantizado
}
// La solución en procesos por lotes: vaciar periódicamente
@Transactional
public void procesarPorLotes() {
int lote = 0;
for (Long id : ids) {
procesar(repo.findById(id).orElseThrow());
if (++lote % 500 == 0) {
em.flush(); // envía los cambios pendientes
em.clear(); // libera el contexto (y las entidades se SEPARAN)
}
}
}
5.5 find frente a getReference
| findById / em.find | getReferenceById / em.getReference |
| Consulta a la BD | Inmediata | Ninguna, hasta que accedas a un campo distinto del id |
| Devuelve | Optional<T> (Spring Data) o null (JPA) | Un proxy de la entidad, nunca null |
| Si el id no existe | Optional.empty() | El proxy se crea igual; al acceder salta EntityNotFoundException |
| Fuera de la transacción | Los datos ya están cargados | LazyInitializationException al acceder |
| Uso ideal | Necesitas los datos | Solo necesitas la referencia para una FK, o borrar por id |
@Transactional
public void asignarSinCargar(Long pedidoId, Long clienteId) {
// ✓ Cero consultas: solo se usa el id del proxy para la columna FK
Pedido pedido = repo.findById(pedidoId).orElseThrow();
pedido.reasignarCliente(clienteRepo.getReferenceById(clienteId));
// SQL: update pedido set cliente_id = ? where id = ?
// (sin ningún select de cliente)
}
@Transactional
public void borrarSinCargar(Long id) {
// ✗ Dos viajes: un SELECT que no necesitas y un DELETE
repo.delete(repo.findById(id).orElseThrow());
// ✓ Un viaje... si no hay cascadas ni @OneToMany que gestionar.
// Si hay cascadas, Hibernate TIENE que cargar la entidad y sus hijos
// para ejecutar sus ciclos de vida, así que el SELECT vuelve.
repo.deleteById(id);
}
// Trampa del proxy: el id se puede leer SIN disparar la carga...
Cliente proxy = clienteRepo.getReferenceById(7L);
Long id = proxy.getId(); // 0 consultas (si @Id está en el campo)
String nombre = proxy.getNombre(); // 1 consulta AQUÍ
// ...pero solo si el acceso al id no pasa por un getter interceptado.
// Con @Access(PROPERTY) o con el id en un getter, incluso leer el id carga.
// Con Hibernate.getId(proxy) o proxy.getId() sobre un campo, no.
Cuidado con getReferenceById y instanceof: un proxy es una subclase generada,
no la clase original. proxy.getClass() devuelve algo como
Cliente$HibernateProxy$aB3xY, así que getClass() == Cliente.class es
false. Por eso equals en entidades debe usar instanceof (o
Hibernate.getClass()) y nunca getClass(). Sección 5.10.
5.6 Dirty checking: cómo Hibernate sabe qué ha cambiado
Al cargar una entidad, Hibernate guarda dos cosas en el contexto:
1. La instancia Java que te devuelve.
2. Una INSTANTÁNEA: un array Object[] con el valor original de cada
propiedad persistente, tal como venía de la base de datos.
En el flush, para cada entidad gestionada:
estado_actual = [42, "PED-1", CONFIRMADO, 119.00, "EUR", …]
instantánea = [42, "PED-1", BORRADOR, 119.00, "EUR", …]
▲
└─ difiere → hay que hacer UPDATE
Y genera: update pedido set estado = ? where id = ? and version = ?
Con @DynamicUpdate, solo las columnas que cambiaron.
Sin él (el defecto), TODAS las columnas: la sentencia es siempre la misma,
así que se reutiliza el plan y se puede agrupar en lotes. Ver sección 12.9.
Coste: O(número de entidades gestionadas × número de propiedades) en cada
flush. Con 50.000 entidades cargadas y varios flush, es tiempo de CPU real.
Por eso importa @Transactional(readOnly = true): desactiva esto por completo.
// La consecuencia práctica: NO HACE FALTA save()
@Transactional
public void subirPrecio(Long id, BigDecimal porcentaje) {
Producto p = repo.findById(id).orElseThrow(); // GESTIONADA
p.setPrecio(p.getPrecio().multiply(porcentaje));
// No hay save(). No hace falta. El dirty checking lo detecta en el commit.
// SQL: update producto set precio = ?, version = ? where id = ? and version = ?
}
// Y esto también funciona, aunque parezca que falta algo:
@Transactional
public void confirmar(String referencia) {
repo.findByReferencia(referencia).orElseThrow().confirmar();
// El método de dominio cambia el estado y el total; ambos se persisten.
}
¿Y si quiero que un cambio NO se guarde? Tres opciones: (1) haz la operación fuera de la transacción, con
la entidad separada; (2) trabaja sobre un DTO en lugar de la entidad; (3)
@Transactional(readOnly = true), que pone la sesión en FlushMode.MANUAL y hace que el
dirty checking no se ejecute. La opción 3 es la que usarás en todos los métodos de consulta, y no es
solo una optimización: es una protección contra escrituras accidentales.
5.7 Flush: cuándo, en qué orden y por qué importa
Flush es el momento en que Hibernate traduce el estado del contexto a SQL. No es lo mismo que
commit: puede haber varios flush en una transacción, y un flush sin commit
se puede deshacer.
Cuándo ocurre un flush automático
| Momento | Se hace flush | Por qué |
Antes del commit | Siempre | Es el único modo de que los cambios lleguen a la base de datos. |
| Antes de ejecutar una consulta JPQL o Criteria | Sí, si la consulta afecta a tablas con cambios pendientes | Para que la consulta vea tus propios cambios (read-your-writes). |
| Antes de una consulta nativa | No, salvo que declares las tablas afectadas | Hibernate no sabe qué tablas toca tu SQL. Causa de bugs. |
em.flush() explícito | Sí | Cuando necesitas el id generado o quieres provocar el error antes. |
Al obtener un id con IDENTITY | INSERT inmediato | Es la única forma de conocer el id. |
Con FlushMode.MANUAL (readOnly = true) | Nunca automáticamente | Ahorra trabajo y evita escrituras accidentales. |
// El bug del SQL nativo que no ve tus cambios
@Transactional
public void bugDelSqlNativo(Long pedidoId) {
Pedido p = repo.findById(pedidoId).orElseThrow();
p.setEstado(EstadoPedido.PAGADO); // cambio pendiente, sin flush
// ✗ Consulta nativa: Hibernate NO hace flush porque no sabe qué tablas toca.
// Esta consulta cuenta el pedido como CONFIRMADO, no como PAGADO.
Long pagados = jdbc.sql("select count(*) from pedido where estado = 'PAGADO'")
.query(Long.class).single();
// ✓ Solución A: flush explícito antes
em.flush();
// ✓ Solución B: declarar los espacios de sincronización (API de Hibernate)
var query = em.createNativeQuery("select count(*) from pedido where estado = 'PAGADO'")
.unwrap(NativeQuery.class)
.addSynchronizedEntityClass(Pedido.class); // ahora sí hace flush
}
El orden de las sentencias en el flush (y por qué te va a morder)
Hibernate NO ejecuta las sentencias en el orden en que tú hiciste los cambios.
Sigue un orden fijo, pensado para respetar las claves foráneas:
1. INSERT de entidades, en el orden en que se hizo persist()
2. UPDATE de entidades
3. DELETE de elementos de colecciones
4. INSERT/UPDATE de elementos de colecciones
5. DELETE de entidades
Consecuencia práctica que rompe código: los DELETE van AL FINAL, después de
los INSERT. Así que este código, que parece obviamente correcto, falla:
// Restricción única: uk_linea_pedido_producto (pedido_id, producto_id)
pedido.quitarLinea(lineaDelProductoA); // DELETE... al final
pedido.anadirLinea(productoA, 5); // INSERT... primero
// → ERROR: duplicate key value violates unique constraint
Porque el orden real es:
insert into linea_pedido (pedido_id, producto_id, ...) values (1, 7, ...) ← ¡choca!
delete from linea_pedido where id = 42
Solución: un flush explícito entre las dos operaciones.
pedido.quitarLinea(lineaDelProductoA);
em.flush(); // ahora sí: DELETE ejecutado
pedido.anadirLinea(productoA, 5);
Cuándo llamar a flush() a mano (son pocos casos, pero reales): (1) necesitas el id generado
antes de terminar la transacción, por ejemplo para construir una URL o una referencia; (2) quieres que una
violación de restricción salte aquí y no en el commit, para poder capturarla y dar un
error de negocio decente; (3) el orden del flush te está causando un conflicto de restricción única,
como arriba; (4) estás procesando por lotes y haces flush + clear cada N elementos.
Fuera de esos casos, no lo llames: interfiere con el batching y con el orden que Hibernate ha calculado.
5.8 persist, merge y save
| persist(e) | merge(e) | save(e) de Spring Data |
| Devuelve | void | Otra instancia (la gestionada) | La instancia gestionada |
| Modifica el argumento | Sí: le pone el id y lo hace gestionado | No: el argumento sigue separado | Depende de la rama que tome |
| Si la entidad es transitoria | INSERT | INSERT (copiando el estado) | Llama a persist |
| Si la entidad es separada | Lanza PersistenceException | SELECT + UPDATE | Llama a merge |
| Consultas extra | Ninguna | Un SELECT para cargar el estado actual | El de merge, si aplica |
| Con entidad gestionada | Nada (ya está) | Nada | Innecesario |
// Esto es literalmente lo que hace SimpleJpaRepository.save():
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
}
return em.merge(entity); // ← ojo: SELECT + UPDATE, y devuelve otra instancia
}
// ¿Cómo decide isNew()? Por orden:
// 1. Si la entidad implementa Persistable → usa isNew()
// 2. Si hay un campo @Version de tipo objeto (Long, no long) → es nueva si es null
// 3. Si no → es nueva si el @Id es null (o 0 para primitivos)
El save() innecesario, el antipatrón más extendido de Spring Data.
@Transactional
public void malo(Long id) {
Pedido p = repo.findById(id).orElseThrow(); // GESTIONADA
p.confirmar();
repo.save(p); // ✗ Innecesario. Y potencialmente dañino.
}
No es solo redundante: si la entidad está gestionada,
save() ejecuta
merge(), que
puede provocar un SELECT extra y, sobre todo, transmite una idea falsa de cómo
funciona JPA. El programador que lo escribe cree que sin
save() no se guarda, y de esa creencia
nacen bugs peores: «he puesto
save() y no se guarda» (porque estaba fuera de la transacción) o «he
quitado el
save() y ahora se guarda de más» (porque nunca era el
save()).
save() se usa solo para entidades nuevas o separadas.
// Los tres casos, resueltos correctamente
// CASO 1 · Entidad nueva → save() (o persist)
@Transactional
public Pedido crear(CrearPedidoDto dto) {
Pedido pedido = Pedido.nuevo(clienteRepo.getReferenceById(dto.clienteId()));
dto.lineas().forEach(l ->
pedido.anadirLinea(productoRepo.getReferenceById(l.productoId()), l.cantidad()));
return repo.save(pedido); // ✓ es nueva: persist
}
// CASO 2 · Entidad ya gestionada → NADA
@Transactional
public void confirmar(String referencia) {
repo.findByReferencia(referencia).orElseThrow().confirmar(); // ✓ sin save
}
// CASO 3 · Entidad separada (viene de fuera, deserializada de JSON) → merge.
// Pero esto casi nunca es lo que quieres hacer: ver el aviso de abajo.
@Transactional
public Pedido actualizarDesdeFuera(Pedido separado) {
return repo.save(separado); // merge: SELECT + UPDATE de TODAS las columnas
}
Por qué no debes recibir entidades desde la API y hacerles merge: el
merge copia todos los campos del objeto que llega. Si el cliente omite un campo,
llegará null y merge lo pondrá a null en la base de datos. Es decir, un
PUT parcial borra datos silenciosamente. Y si el cliente envía estado: "PAGADO" en un
endpoint de edición de dirección, acaba de cambiar el estado del pedido sin pasar por ninguna regla de negocio.
Recibe un DTO, valida, carga la entidad y aplica los cambios con métodos de dominio. Siempre.
// El patrón correcto para actualizar desde una petición HTTP
@Transactional
public void actualizarDireccion(String referencia, DireccionDto dto) {
Pedido pedido = repo.findByReferencia(referencia)
.orElseThrow(() -> new PedidoNoEncontradoException(referencia));
pedido.cambiarDireccionEnvio(new Direccion(
dto.calle(), dto.codigoPostal(), dto.ciudad(), dto.pais()));
// Sin save, sin merge, sin riesgo de sobrescribir nada que no toque.
}
Ids asignados a mano: el problema del SELECT inútil
// Si el id lo asignas tú (UUID del cliente, código de negocio), isNew() cree
// que la entidad NO es nueva porque el id no es null. Resultado: save() hace
// merge → un SELECT que sabemos que no va a encontrar nada → INSERT.
// Una consulta desperdiciada en cada inserción.
@Entity
public class Evento implements Persistable<UUID> {
@Id
@Column(columnDefinition = "uuid", updatable = false)
private UUID id;
@Transient // ← no se persiste: solo vive en memoria
private boolean nuevo = true;
@Override public UUID getId() { return id; }
@Override public boolean isNew() { return nuevo; }
// Tras cargar de la BD o tras persistir, ya no es nueva
@PostPersist
@PostLoad
void yaNoEsNueva() { this.nuevo = false; }
protected Evento() { }
public Evento(UUID id) { this.id = Objects.requireNonNull(id); }
}
// Ahora save() llama a persist() directamente: un INSERT, cero SELECT.
5.9 detach, clear y refresh
// detach(e): saca UNA entidad del contexto. Los cambios pendientes se descartan.
@Transactional
public void ejemploDetach(Long id) {
Pedido p = repo.findById(id).orElseThrow();
p.setMoneda("USD");
em.detach(p); // el cambio a USD se PIERDE: nunca llegará al flush
}
// clear(): vacía TODO el contexto. Imprescindible en procesos por lotes.
@Transactional
public void ejemploClear(List<Long> ids) {
int n = 0;
for (Long id : ids) {
Producto p = repo.findById(id).orElseThrow();
p.aplicarDescuento(10);
if (++n % 500 == 0) { em.flush(); em.clear(); } // ← el orden importa:
} // flush ANTES de clear
}
// refresh(e): descarta los cambios en memoria y relee de la base de datos.
@Transactional
public void ejemploRefresh(Long id) {
Producto p = repo.findById(id).orElseThrow();
p.setStock(999);
em.refresh(p); // SELECT: el stock vuelve al valor real de la BD
// Útil tras una operación masiva con @Modifying, o tras un procedimiento
// almacenado que ha cambiado la fila por detrás.
}
// refresh con bloqueo: relee y bloquea en el mismo viaje
em.refresh(p, LockModeType.PESSIMISTIC_WRITE); // select ... for update
clear() sin flush() pierde datos. clear() descarta el contexto
y todos los cambios pendientes, sin avisar, sin excepción y sin log. Si en el bucle anterior
inviertes el orden (em.clear(); em.flush();), los 500 descuentos de ese lote no se guardan y el
proceso termina «con éxito». Es uno de los bugs más difíciles de detectar porque nada falla: simplemente faltan
datos.
5.10 equals y hashCode en entidades
Aquí hay una contradicción real que hay que entender antes de elegir: JPA exige que equals y
hashCode sean estables durante todo el ciclo de vida de la entidad (porque los
Set y los Map los usan), pero el id generado cambia de
null a un valor cuando se persiste. Cualquier implementación basada en el id generado viola la
estabilidad.
// EL BUG, paso a paso
@Entity
public class Pedido {
@Id @GeneratedValue private Long id;
@Override public boolean equals(Object o) {
return o instanceof Pedido p && Objects.equals(id, p.id);
}
@Override public int hashCode() { return Objects.hash(id); } // ← el problema
}
@Transactional
public void demostracionDelBug() {
Set<Pedido> conjunto = new HashSet<>();
Pedido p = Pedido.nuevo(cliente);
conjunto.add(p); // id == null → hashCode() = 0
// se guarda en el cubo del hash 0
em.persist(p); // ← id pasa a ser 42, hashCode() cambia
conjunto.contains(p); // ✗ FALSE. Busca en el cubo del hash de 42,
// pero el objeto está en el cubo del 0.
conjunto.remove(p); // ✗ no lo encuentra: no lo borra
conjunto.size(); // 1, con un elemento inalcanzable
// El Set está corrupto: contiene un objeto que dice no contener.
}
| Estrategia | Estable | Correcta entre sesiones | Cuándo usarla |
| No implementarlos (identidad de objeto) |
Sí |
No: dos instancias de la misma fila no son iguales |
Válida y a menudo la mejor. El contexto de persistencia ya garantiza una instancia por fila dentro de una sesión. |
Clave de negocio inmutable (referencia, sku, email) |
Sí |
Sí |
La mejor opción cuando existe una clave natural que se asigna al construir y no cambia. |
| UUID asignado en el constructor |
Sí |
Sí |
Cuando no hay clave natural. Un UUID generado en Java, no por la base de datos. |
Id generado, con hashCode constante |
Sí (el hashCode no cambia) |
Sí |
Compromiso aceptable: funciona, pero todos los elementos caen en el mismo cubo (rendimiento O(n) en HashSet grandes). |
Id generado con Objects.hash(id) |
No |
Sí |
Nunca. Es el bug de arriba. |
Todos los campos (@Data, @EqualsAndHashCode) |
No |
No |
Nunca. Además dispara la carga de relaciones perezosas. |
// ✓ OPCIÓN RECOMENDADA 1: clave de negocio inmutable
@Entity
public class Pedido {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pedido_gen")
private Long id;
@Column(nullable = false, length = 36, updatable = false, unique = true)
private String referencia; // asignada en el constructor, nunca cambia
@Override
public boolean equals(Object o) {
if (this == o) return true;
// instanceof, NO getClass(): un proxy de Hibernate es una subclase
if (!(o instanceof Pedido otro)) return false;
return referencia != null && referencia.equals(otro.referencia);
}
@Override
public int hashCode() {
return Objects.hashCode(referencia); // estable desde el constructor
}
}
// ✓ OPCIÓN RECOMENDADA 2: sin clave natural, un UUID generado en Java
@Entity
public class Producto {
@Id @GeneratedValue private Long id;
@Column(name = "uid", nullable = false, updatable = false, unique = true,
columnDefinition = "uuid")
private UUID uid = UUID.randomUUID(); // ← ya existe antes de persistir
@Override public boolean equals(Object o) {
return o instanceof Producto p && uid.equals(p.uid);
}
@Override public int hashCode() { return uid.hashCode(); }
}
// ✓ OPCIÓN 3: id generado, hashCode constante. Compromiso de la comunidad.
@Entity
public class Cliente {
@Id @GeneratedValue private Long id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Cliente otro)) return false;
// Dos entidades sin id NUNCA son iguales salvo que sean la misma instancia
return id != null && id.equals(otro.getId());
}
@Override
public int hashCode() {
return getClass().hashCode(); // constante: nunca cambia. Correcto,
} // aunque degrada los HashSet grandes.
}
Detalle importante sobre proxies en equals: usa instanceof o
Hibernate.getClass(o), nunca getClass() != o.getClass(). Con proxies,
getClass() devuelve la subclase generada y la comparación falla. Y llama al getter
(otro.getId()) en lugar de acceder al campo (otro.id): si el otro objeto es un proxy no
inicializado, el acceso directo al campo devuelve null mientras el getter devuelve el
valor correcto. Estos dos detalles son la diferencia entre un equals que funciona y uno que falla
solo con relaciones perezosas, es decir, solo en producción.
Las diez verdades del contexto de persistencia
- Un
EntityManager por transacción, atado al hilo. Sin @Transactional, cada llamada al repositorio abre y cierra el suyo.
- Cuatro estados: transitorio, gestionado, separado, eliminado. Solo los cambios en entidades gestionadas se guardan.
- Dentro de una sesión, una fila = una instancia. Por eso
== funciona y por eso una consulta no sobrescribe tus cambios.
- La caché de primer nivel evita el segundo
SELECT por id, y nada más. Las consultas siempre van a la base de datos.
getReferenceById devuelve un proxy sin consulta: perfecto para asignar una FK.
- El dirty checking compara con una instantánea y genera el
UPDATE: no hace falta save().
- El flush ordena las sentencias a su manera y los
DELETE van al final. De ahí los choques con restricciones únicas.
- Una consulta nativa no dispara flush: puede no ver tus cambios pendientes.
save() es persist o merge según isNew(). Con entidades gestionadas es innecesario; con entidades que llegan de la API, peligroso.
equals/hashCode con clave de negocio inmutable o no implementarlos. Con el id generado y Objects.hash(id), corrompes los Set.
6 · Consultas: de los métodos derivados a Querydsl
Spring Data ofrece siete formas distintas de consultar, y elegir mal se paga en legibilidad o en rendimiento.
Esta sección las recorre todas con el criterio de cuándo usar cada una, más las proyecciones y la paginación,
que son donde se juega la mitad del rendimiento de una API.
6.1 Métodos derivados del nombre
Spring Data analiza el nombre del método y construye la consulta. Es cómodo y seguro (el nombre se valida al
arrancar, no en tiempo de ejecución) mientras el nombre siga siendo legible.
Anatomía de un nombre de método derivado:
find By EstadoAndTotalGreaterThan OrderByCreadoEnDesc
──┬── ─┬─ ──────────┬────────────── ─────────┬─────────
sujeto separador predicado orden
Sujetos válidos (todos equivalentes en efecto, elige por legibilidad):
find…By read…By get…By query…By search…By stream…By
count…By → long
exists…By → boolean
delete…By / remove…By → void o long (⚠ carga y borra una a una)
Modificadores entre el sujeto y By:
findDistinctBy… → select distinct
findFirstBy… / findTopBy… → limit 1
findFirst5By… / findTop10By… → limit 5 / 10
| Palabra clave | Ejemplo | JPQL generado |
Is, Equals, (nada) | findByEstado | where estado = ?1 |
Not | findByEstadoNot | where estado <> ?1 |
And, Or | findByEstadoAndMoneda | where estado = ?1 and moneda = ?2 |
Between | findByCreadoEnBetween | where creado_en between ?1 and ?2 |
LessThan, LessThanEqual | findByTotalLessThan | where total < ?1 |
GreaterThan, GreaterThanEqual | findByTotalGreaterThanEqual | where total >= ?1 |
After, Before | findByCreadoEnAfter | where creado_en > ?1 |
IsNull, IsNotNull | findByActualizadoEnIsNull | where actualizado_en is null |
Like, NotLike | findByNombreLike | where nombre like ?1 (los % los pones tú) |
StartingWith, EndingWith, Containing | findByNombreContaining | where nombre like '%' || ?1 || '%' |
IgnoreCase | findByEmailIgnoreCase | where lower(email) = lower(?1) |
In, NotIn | findByEstadoIn(List<…>) | where estado in (?1) |
True, False | findByActivoTrue | where activo = true |
OrderBy…Asc/Desc | findByEstadoOrderByTotalDesc | order by total desc |
Navegación con _ | findByCliente_Pais | join cliente c where c.pais = ?1 |
public interface PedidoRepository extends Repository<Pedido, Long> {
// Legibles y correctos
Optional<Pedido> findByReferencia(String referencia);
List<Pedido> findByEstadoAndCreadoEnAfter(EstadoPedido estado, Instant desde);
Page<Pedido> findByClienteIdOrderByCreadoEnDesc(Long clienteId, Pageable pageable);
boolean existsByReferencia(String referencia);
long countByEstado(EstadoPedido estado);
Optional<Pedido> findFirstByClienteIdOrderByCreadoEnDesc(Long clienteId);
List<Pedido> findTop10ByEstadoOrderByTotalDesc(EstadoPedido estado);
// Navegación por la relación: genera un JOIN automáticamente
List<Pedido> findByClientePais(String pais); // funciona
List<Pedido> findByCliente_Pais(String pais); // más explícito, mejor
// ✗ El punto donde hay que parar y usar @Query
List<Pedido> findByEstadoInAndTotalGreaterThanEqualAndClientePaisAndCreadoEnBetweenOrderByTotalDescCreadoEnAsc(
List<EstadoPedido> estados, BigDecimal minimo, String pais,
Instant desde, Instant hasta);
// Nadie puede leer eso, nadie puede modificarlo sin miedo, y el orden de los
// parámetros es una trampa: intercambia `desde` y `hasta` y compila igual.
}
Regla de las tres condiciones: hasta tres condiciones, método derivado. A partir de la cuarta, o si
aparecen dos parámetros del mismo tipo seguidos (donde intercambiarlos compila pero cambia el significado), pasa
a @Query con parámetros nombrados. El nombre explícito de la consulta y los parámetros con nombre
hacen el código mucho más resistente a los cambios.
deleteBy… no es un DELETE: Spring Data implementa
deleteByEstado(estado) como un SELECT de todas las entidades que cumplen la condición
y un DELETE por cada una, para poder ejecutar las cascadas y los callbacks. Con 100.000
filas, son 100.001 sentencias y todas las entidades en memoria. Si quieres un borrado masivo real, usa
@Modifying con @Query (sección 6.3) y asume que te saltas el ciclo de vida.
6.2 @Query con JPQL
public interface PedidoRepository extends Repository<Pedido, Long> {
// Parámetros NOMBRADOS: legibles y resistentes al reordenamiento.
// Con -parameters en el compilador, @Param es opcional; ponlo igualmente
// para que no dependa de una opción de compilación.
@Query("""
select p from Pedido p
join fetch p.cliente c
where p.estado in :estados
and p.total >= :minimo
and c.pais = :pais
order by p.total desc
""")
List<Pedido> buscarGrandes(@Param("estados") Collection<EstadoPedido> estados,
@Param("minimo") BigDecimal minimo,
@Param("pais") String pais);
// Parámetros POSICIONALES: más cortos, más frágiles. Evítalos.
@Query("select p from Pedido p where p.estado = ?1 and p.total >= ?2")
List<Pedido> buscarPosicional(EstadoPedido estado, BigDecimal minimo);
// Filtro opcional dentro del propio JPQL: el truco del parámetro nulo.
// Evita tres métodos distintos y funciona bien con 2-3 filtros opcionales.
// Con más, usa Specification (sección 6.6) o el índice no se aprovechará.
@Query("""
select p from Pedido p
where (:estado is null or p.estado = :estado)
and (:pais is null or p.cliente.pais = :pais)
and (:desde is null or p.creadoEn >= :desde)
""")
Page<Pedido> buscar(@Param("estado") EstadoPedido estado,
@Param("pais") String pais,
@Param("desde") Instant desde,
Pageable pageable);
// Actualización de un solo campo cuando cargar la entidad no aporta nada
@Modifying
@Query("update Pedido p set p.estado = :nuevo where p.referencia = :ref")
int cambiarEstado(@Param("ref") String referencia, @Param("nuevo") EstadoPedido nuevo);
}
| Aspecto | JPQL | SQL nativo |
| Sobre qué opera | Entidades y sus campos Java | Tablas y columnas |
| Se valida | Al arrancar la aplicación | En tiempo de ejecución |
| Portable entre motores | Sí | No |
Funciones de ventana, CTE, jsonb, ON CONFLICT | No | Sí |
join fetch | Sí | No (hay que usar SqlResultSetMapping) |
| Devuelve entidades gestionadas | Sí | Sí, si lo pides con Class o mapping |
| Dispara flush automático | Sí | No (sección 5.7) |
| Participa en la caché de consultas | Sí | Solo con synchronized spaces |
Diferencias de JPQL que sorprenden: (1) join sin on: la condición sale del
mapeo. (2) join fetch no existe en SQL: le dice a Hibernate que además inicialice la
relación. (3) select p from Pedido p devuelve entidades, no filas. (4) No hay limit:
la paginación va por Pageable o setMaxResults. (5) Los nombres son
sensibles a mayúsculas y son los de Java, no los de la base de datos:
p.creadoEn, no p.creado_en.
6.3 @Modifying y por qué desincroniza el contexto
public interface PedidoRepository extends Repository<Pedido, Long> {
// Los dos atributos son casi obligatorios. Explicación debajo.
@Modifying(flushAutomatically = true, // vuelca los cambios pendientes ANTES
clearAutomatically = true) // vacía el contexto DESPUÉS
@Query("""
update Pedido p
set p.estado = :nuevo,
p.actualizadoEn = :ahora
where p.estado = :viejo
and p.creadoEn < :limite
""")
int caducarBorradores(@Param("viejo") EstadoPedido viejo,
@Param("nuevo") EstadoPedido nuevo,
@Param("limite") Instant limite,
@Param("ahora") Instant ahora);
// Borrado masivo real: una sola sentencia
@Modifying(clearAutomatically = true)
@Query("delete from LineaPedido l where l.pedido.id = :pedidoId")
int borrarLineas(@Param("pedidoId") Long pedidoId);
}
POR QUÉ @Modifying DESINCRONIZA EL CONTEXTO
Una consulta @Modifying se ejecuta DIRECTAMENTE en la base de datos. Hibernate
no la interpreta ni actualiza las entidades que ya tiene en memoria. Resultado:
Pedido p = repo.findById(1L).get(); // estado = BORRADOR (en el contexto)
repo.caducarBorradores(BORRADOR, CANCELADO, limite, ahora); // en la BD: CANCELADO
p.getEstado(); // ✗ devuelve BORRADOR: obsoleto
p.setMoneda("USD"); // ✗ y en el commit se hará
// update pedido set estado='BORRADOR',
// moneda='USD' ... ¡DESHACIENDO la
// actualización masiva!
Y hay un segundo problema, el inverso:
p.setEstado(CONFIRMADO); // cambio pendiente, sin flush
repo.caducarBorradores(...); // ⚠ el UPDATE masivo se ejecuta
// ANTES de tu cambio pendiente
// → resultado impredecible
Por eso:
flushAutomatically = true → tus cambios pendientes se escriben primero
clearAutomatically = true → tras la consulta, el contexto se vacía y
cualquier lectura posterior va a la BD
Y por eso, ADEMÁS:
· @Modifying NO incrementa @Version (el bloqueo optimista se salta).
· No dispara @PreUpdate / @PostUpdate ni la auditoría de Spring Data.
· No actualiza la caché de segundo nivel (hay que invalidarla a mano).
· No aplica cascadas ni orphanRemoval.
Es decir: es SQL disfrazado de JPQL. Rapidísimo y sin red de seguridad.
Cuándo usar @Modifying y cuándo no: úsalo para operaciones que afectan a muchas filas y
donde la lógica es trivial (caducar borradores, marcar como leídas las notificaciones, incrementar un contador).
No lo uses cuando haya reglas de negocio, auditoría obligatoria o bloqueo optimista: en esos
casos carga las entidades, aunque sea más lento, o baja a SQL puro y gestiona la auditoría a mano y de forma
consciente. Y ponlo siempre en un método @Transactional propio, corto, y con el
clearAutomatically puesto.
6.4 Consultas nativas y SqlResultSetMapping
public interface PedidoRepository extends Repository<Pedido, Long> {
// 1) Nativa que devuelve una proyección por interfaz: los nombres de las
// columnas tienen que coincidir con los getters (alias con as).
@Query(value = """
select c.pais as pais,
count(*) as pedidos,
sum(p.total) as total,
percentile_cont(0.5) within group (order by p.total) as mediana
from pedido p
join cliente c on c.id = p.cliente_id
where p.creado_en >= :desde
group by c.pais
order by sum(p.total) desc
""", nativeQuery = true)
List<ResumenPais> resumenPorPais(@Param("desde") Instant desde);
// 2) Nativa con paginación: OBLIGA a declarar countQuery
@Query(value = """
select p.* from pedido p
where p.metadatos @> cast(:filtro as jsonb)
""",
countQuery = """
select count(*) from pedido p
where p.metadatos @> cast(:filtro as jsonb)
""",
nativeQuery = true)
Page<Pedido> buscarPorMetadatos(@Param("filtro") String filtroJson, Pageable pageable);
// 3) Nativa con función de ventana: imposible en JPQL
@Query(value = """
select * from (
select p.*,
row_number() over (partition by p.cliente_id
order by p.creado_en desc) as rn
from pedido p
where p.estado = :estado
) t where t.rn <= :porCliente
""", nativeQuery = true)
List<Pedido> ultimosPorCliente(@Param("estado") String estado,
@Param("porCliente") int porCliente);
// 4) UPSERT: lo que JPQL no puede hacer y la aplicación necesita a menudo
@Modifying
@Query(value = """
insert into producto_stock (producto_id, reservado)
values (:productoId, :cantidad)
on conflict (producto_id)
do update set reservado = producto_stock.reservado + :cantidad
""", nativeQuery = true)
void reservar(@Param("productoId") Long productoId, @Param("cantidad") int cantidad);
}
// La proyección por interfaz: Spring Data crea un proxy que lee del resultado
public interface ResumenPais {
String getPais();
long getPedidos();
BigDecimal getTotal();
BigDecimal getMediana();
}
// SqlResultSetMapping: cuando la nativa debe devolver ENTIDADES GESTIONADAS
// más columnas calculadas. Es verboso, pero es la única forma estándar.
@SqlResultSetMapping(
name = "PedidoConLineasMapping",
entities = {
@EntityResult(entityClass = Pedido.class,
fields = {
@FieldResult(name = "id", column = "p_id"),
@FieldResult(name = "referencia", column = "p_referencia"),
@FieldResult(name = "estado", column = "p_estado"),
@FieldResult(name = "total", column = "p_total"),
@FieldResult(name = "version", column = "p_version"),
@FieldResult(name = "cliente", column = "p_cliente_id"),
@FieldResult(name = "creadoEn", column = "p_creado_en")
})
},
columns = @ColumnResult(name = "num_lineas", type = Integer.class))
@NamedNativeQuery(
name = "Pedido.conNumeroDeLineas",
query = """
select p.id as p_id,
p.referencia as p_referencia,
p.estado as p_estado,
p.total as p_total,
p.version as p_version,
p.cliente_id as p_cliente_id,
p.creado_en as p_creado_en,
count(l.id) as num_lineas
from pedido p
left join linea_pedido l on l.pedido_id = p.id
where p.estado = :estado
group by p.id
""",
resultSetMapping = "PedidoConLineasMapping")
@Entity
public class Pedido { … }
// Uso: devuelve Object[] con {Pedido gestionado, Integer numLineas}
List<Object[]> filas = em.createNamedQuery("Pedido.conNumeroDeLineas")
.setParameter("estado", "CONFIRMADO")
.getResultList();
Antes de escribir un SqlResultSetMapping, pregúntate si necesitas la entidad gestionada.
Casi nunca. Si solo vas a leer y devolver datos, un DTO con JdbcClient es diez veces más corto y
más rápido, y no ensucia el contexto de persistencia. El SqlResultSetMapping se justifica cuando
vas a modificar esas entidades después.
6.5 Criteria API: consultas construidas con código
// Criteria API es verbosa, pero es la única forma ESTÁNDAR y con seguridad de
// tipos de construir consultas dinámicas. Con el metamodelo generado
// (hibernate-jpamodelgen) se comprueba en compilación.
@Repository
@RequiredArgsConstructor
public class BuscadorPedidos {
private final EntityManager em;
public List<Pedido> buscar(FiltroPedidos filtro) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Pedido> cq = cb.createQuery(Pedido.class);
Root<Pedido> pedido = cq.from(Pedido.class);
// El join fetch se declara aquí, y en Criteria hay que castear
Fetch<Pedido, Cliente> fetchCliente = pedido.fetch(Pedido_.cliente, JoinType.INNER);
Join<Pedido, Cliente> cliente = (Join<Pedido, Cliente>) fetchCliente;
List<Predicate> condiciones = new ArrayList<>();
if (filtro.estados() != null && !filtro.estados().isEmpty())
condiciones.add(pedido.get(Pedido_.estado).in(filtro.estados()));
if (filtro.minimo() != null)
condiciones.add(cb.greaterThanOrEqualTo(pedido.get(Pedido_.total), filtro.minimo()));
if (filtro.pais() != null)
condiciones.add(cb.equal(cliente.get(Cliente_.pais), filtro.pais()));
if (filtro.textoLibre() != null)
condiciones.add(cb.or(
cb.like(cb.lower(pedido.get(Pedido_.referencia)),
"%" + filtro.textoLibre().toLowerCase() + "%"),
cb.like(cb.lower(cliente.get(Cliente_.nombre)),
"%" + filtro.textoLibre().toLowerCase() + "%")));
cq.where(cb.and(condiciones.toArray(Predicate[]::new)))
.orderBy(cb.desc(pedido.get(Pedido_.creadoEn)));
return em.createQuery(cq)
.setHint(AvailableHints.HINT_READ_ONLY, true) // sin instantáneas
.setMaxResults(100) // límite duro SIEMPRE
.getResultList();
}
}
El metamodelo (Pedido_.estado) merece la pena. Con
hibernate-jpamodelgen como annotation processor, el compilador genera clases
Pedido_, Cliente_… con un atributo estático por cada campo persistente. Renombrar
estado en la entidad rompe la compilación de la consulta, en lugar de fallar en tiempo de
ejecución con un IllegalArgumentException sobre un atributo desconocido. Es el 90 % del valor
de Criteria API por el 5 % del esfuerzo.
6.6 Specification: filtros combinables
// Specification es un envoltorio funcional sobre Criteria API que hace las
// condiciones COMPONIBLES y REUTILIZABLES. Es la mejor herramienta para
// búsquedas con muchos filtros opcionales.
public final class PedidoSpecs {
private PedidoSpecs() { }
public static Specification<Pedido> conEstado(EstadoPedido estado) {
return estado == null ? null
: (raiz, cq, cb) -> cb.equal(raiz.get(Pedido_.estado), estado);
}
public static Specification<Pedido> enEstados(Collection<EstadoPedido> estados) {
return estados == null || estados.isEmpty() ? null
: (raiz, cq, cb) -> raiz.get(Pedido_.estado).in(estados);
}
public static Specification<Pedido> totalAlMenos(BigDecimal minimo) {
return minimo == null ? null
: (raiz, cq, cb) -> cb.greaterThanOrEqualTo(raiz.get(Pedido_.total), minimo);
}
public static Specification<Pedido> creadoEntre(Instant desde, Instant hasta) {
return (raiz, cq, cb) -> {
if (desde == null && hasta == null) return cb.conjunction();
if (desde == null) return cb.lessThan(raiz.get(Pedido_.creadoEn), hasta);
if (hasta == null) return cb.greaterThanOrEqualTo(raiz.get(Pedido_.creadoEn), desde);
return cb.and(cb.greaterThanOrEqualTo(raiz.get(Pedido_.creadoEn), desde),
cb.lessThan(raiz.get(Pedido_.creadoEn), hasta));
};
}
public static Specification<Pedido> delPais(String pais) {
return pais == null ? null : (raiz, cq, cb) ->
cb.equal(raiz.join(Pedido_.cliente).get(Cliente_.pais), pais);
}
// Truco importante: evitar el join fetch en la consulta de COUNT.
// Sin esta comprobación, Hibernate falla al paginar ("query specified join
// fetching, but the owner of the fetched association was not present").
public static Specification<Pedido> conClienteCargado() {
return (raiz, cq, cb) -> {
if (Long.class != cq.getResultType() && long.class != cq.getResultType()) {
raiz.fetch(Pedido_.cliente, JoinType.INNER);
}
return cb.conjunction();
};
}
// Y una específica del dominio, que es donde Specification brilla:
// encapsula una regla de negocio con nombre.
public static Specification<Pedido> pendientesDeEnvioDesdeHace(Duration tiempo) {
Instant limite = Instant.now().minus(tiempo);
return (raiz, cq, cb) -> cb.and(
raiz.get(Pedido_.estado).in(EstadoPedido.PAGADO),
cb.lessThan(raiz.get(Pedido_.creadoEn), limite));
}
}
// Composición en el servicio: se lee casi como una frase
@Service
@RequiredArgsConstructor
public class BusquedaPedidosService {
private final PedidoRepository repo; // extends JpaSpecificationExecutor<Pedido>
@Transactional(readOnly = true)
public Page<ResumenPedido> buscar(FiltroPedidos f, Pageable pageable) {
Specification<Pedido> spec = Specification
.allOf(PedidoSpecs.conClienteCargado(),
PedidoSpecs.enEstados(f.estados()),
PedidoSpecs.totalAlMenos(f.minimo()),
PedidoSpecs.delPais(f.pais()),
PedidoSpecs.creadoEntre(f.desde(), f.hasta()));
// allOf ignora los null: por eso las specs devuelven null cuando el
// filtro no viene. Muy cómodo (Spring Data 3.1+).
return repo.findAll(spec, pageable).map(ResumenPedido::desde);
}
// Combinaciones con or() y not()
@Transactional(readOnly = true)
public List<Pedido> problematicos() {
return repo.findAll(
PedidoSpecs.pendientesDeEnvioDesdeHace(Duration.ofDays(3))
.or(PedidoSpecs.conEstado(EstadoPedido.CONFIRMADO)
.and(PedidoSpecs.creadoEntre(null, Instant.now().minus(Duration.ofDays(7)))))
.and(Specification.not(PedidoSpecs.delPais("XX"))));
}
}
| Herramienta | Seguridad de tipos | Legibilidad | Filtros dinámicos | Coste de entrada | Cuándo usarla |
| Métodos derivados | Media (el nombre se valida al arrancar) | Muy alta hasta 3 condiciones | No | Cero | El 70 % de las consultas |
@Query con JPQL | Baja (texto) | Alta | Con el truco del null, hasta 3 | Cero | Consultas fijas complejas, join fetch |
| SQL nativo | Ninguna | Alta si sabes SQL | No | Cero | Ventanas, CTE, jsonb, upsert |
| Criteria API | Alta con metamodelo | Baja | Sí | Medio | Cuando Specification no llega (subconsultas complejas) |
Specification | Alta con metamodelo | Media-alta | Sí, y componibles | Bajo | Búsquedas con muchos filtros opcionales |
| Querydsl | Muy alta | Alta | Sí | Alto (generación de código) | Proyectos grandes con consultas dinámicas por todas partes |
JdbcClient | Ninguna | Muy alta | A mano | Cero | Informes y lecturas de solo lectura |
6.7 Querydsl: la opción con mejor ergonomía
// build.gradle.kts — Querydsl 5 con Jakarta
dependencies {
implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
}
// Genera QPedido, QCliente… a partir de las entidades @Entity
// La misma búsqueda de antes, con Querydsl. Compara la legibilidad.
@Repository
@RequiredArgsConstructor
public class BuscadorQuerydsl {
private final JPAQueryFactory query; // new JPAQueryFactory(entityManager)
public Page<Pedido> buscar(FiltroPedidos f, Pageable pageable) {
QPedido p = QPedido.pedido;
QCliente c = QCliente.cliente;
// BooleanBuilder acumula condiciones ignorando los null: exactamente
// lo que necesitas para filtros opcionales, sin ceremonia.
BooleanBuilder donde = new BooleanBuilder();
if (f.estados() != null && !f.estados().isEmpty()) donde.and(p.estado.in(f.estados()));
if (f.minimo() != null) donde.and(p.total.goe(f.minimo()));
if (f.pais() != null) donde.and(c.pais.eq(f.pais()));
if (f.desde() != null) donde.and(p.creadoEn.goe(f.desde()));
if (f.hasta() != null) donde.and(p.creadoEn.lt(f.hasta()));
List<Pedido> contenido = query.selectFrom(p)
.join(p.cliente, c).fetchJoin()
.where(donde)
.orderBy(p.creadoEn.desc(), p.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// El count va en su propia consulta, sin el fetch join
Long total = query.select(p.count()).from(p).join(p.cliente, c)
.where(donde).fetchOne();
return new PageImpl<>(contenido, pageable, total == null ? 0 : total);
}
// Proyección tipada a un record: sin entidades, sin sorpresas
public List<ResumenPedido> resumenes(EstadoPedido estado) {
QPedido p = QPedido.pedido;
return query.select(Projections.constructor(ResumenPedido.class,
p.referencia, p.cliente.nombre, p.estado, p.total, p.creadoEn))
.from(p).join(p.cliente)
.where(p.estado.eq(estado))
.fetch();
}
}
¿Specification o Querydsl? Si ya tienes Spring Data y solo necesitas filtros opcionales en tres o cuatro
pantallas, Specification: no añade dependencias ni generación de código. Si la aplicación es
grande, con consultas dinámicas por todas partes, subconsultas, case when y proyecciones complejas,
Querydsl compensa con creces el coste de la generación de código: es notablemente más legible y detecta más
errores en compilación. Y en 2026 sigue vivo y mantenido tras el cambio de gobernanza de 2024, aunque conviene
fijar la versión y vigilar el proyecto.
6.8 @EntityGraph: declarar qué se carga
public interface PedidoRepository extends JpaRepository<Pedido, Long> {
// FETCH: los atributos listados se cargan con JOIN; TODO LO DEMÁS queda LAZY,
// incluso lo que esté declarado EAGER en la entidad. Es lo que quieres.
@EntityGraph(attributePaths = {"cliente"}, type = EntityGraph.EntityGraphType.FETCH)
Page<Pedido> findByEstado(EstadoPedido estado, Pageable pageable);
// LOAD: los listados se cargan; el resto usa lo declarado en la entidad.
@EntityGraph(attributePaths = {"cliente"}, type = EntityGraph.EntityGraphType.LOAD)
List<Pedido> findByMoneda(String moneda);
// Navegación en profundidad con puntos
@EntityGraph(attributePaths = {"cliente", "lineas", "lineas.producto"})
Optional<Pedido> findWithDetailByReferencia(String referencia);
// Se combina con @Query
@EntityGraph(attributePaths = {"cliente"})
@Query("select p from Pedido p where p.total >= :minimo")
List<Pedido> grandes(@Param("minimo") BigDecimal minimo);
}
// Grafo con nombre, declarado en la entidad y reutilizable
@Entity
@NamedEntityGraph(
name = "Pedido.completo",
attributeNodes = {
@NamedAttributeNode("cliente"),
@NamedAttributeNode(value = "lineas", subgraph = "lineasConProducto")
},
subgraphs = @NamedSubgraph(
name = "lineasConProducto",
attributeNodes = @NamedAttributeNode("producto")))
public class Pedido { … }
// Uso del grafo con nombre
@EntityGraph("Pedido.completo")
Optional<Pedido> findByReferencia(String referencia);
// O con la API programática, cuando el grafo depende de una condición
EntityGraph<Pedido> grafo = em.createEntityGraph(Pedido.class);
grafo.addAttributeNodes("cliente");
if (necesitoLineas) grafo.addSubgraph("lineas").addAttributeNodes("producto");
Pedido p = em.find(Pedido.class, id,
Map.of("jakarta.persistence.fetchgraph", grafo));
| join fetch en JPQL | @EntityGraph |
| Dónde se declara | Dentro del texto de la consulta | Fuera, como anotación |
| Con métodos derivados | No se puede | Sí |
Con Pageable | Sí, con el aviso de paginación en memoria | Igual, mismo problema |
| Reutilizable en varias consultas | No: hay que repetirlo | Sí, con @NamedEntityGraph |
Permite left join fetch | Sí, explícito | Sí: usa left automáticamente |
| Permite condiciones sobre lo cargado | Sí (join fetch p.lineas l where l.cantidad > 1) | No |
| Legibilidad | Alta: el SQL está a la vista | Alta: separa «qué busco» de «qué cargo» |
Ojo con @EntityGraph sobre findAll heredado. Si anotas un
findAll(Pageable) sobrescrito con un grafo que incluye una colección, tendrás paginación en memoria
sin darte cuenta en todos los sitios que llamen a findAll. Declara métodos con nombre
propio (findWithDetailBy…) para que en el punto de llamada quede claro que la consulta es más
costosa.
6.9 Proyecciones: la optimización más rentable
Una proyección trae solo las columnas que necesitas y no crea entidades gestionadas: sin
instantánea para el dirty checking, sin comprobaciones en el flush, sin memoria desperdiciada.
En listados y APIs de lectura es, con diferencia, la mejora de rendimiento con mejor relación
esfuerzo/resultado.
// TIPO 1 · Interfaz cerrada: Spring Data crea un proxy. Cero clases que mantener.
public interface ResumenPedidoProj {
String getReferencia();
EstadoPedido getEstado();
BigDecimal getTotal();
Instant getCreadoEn();
// Anidada: genera el JOIN necesario automáticamente
ClienteMini getCliente();
interface ClienteMini { String getNombre(); String getPais(); }
}
// Uso: solo hay que declarar el tipo de retorno
List<ResumenPedidoProj> findByEstado(EstadoPedido estado);
// SQL: select p.referencia, p.estado, p.total, p.creado_en, c.nombre, c.pais
// from pedido p join cliente c on c.id = p.cliente_id where p.estado = ?
// Solo 6 columnas de 14. Ni instantáneas ni dirty checking.
// TIPO 2 · Interfaz ABIERTA con @Value: cuidado, trae la entidad completa
public interface PedidoConEtiqueta {
String getReferencia();
@Value("#{target.estado.name() + ' · ' + target.total + ' ' + target.moneda}")
String getEtiqueta();
}
// ⚠ Al usar SpEL sobre `target`, Spring Data no puede saber qué campos hacen
// falta y carga la ENTIDAD COMPLETA. Pierdes casi toda la ventaja.
// TIPO 3 · DTO con record (mi favorita): explícita, inmutable y con métodos
public record ResumenPedido(String referencia, String cliente, EstadoPedido estado,
BigDecimal total, Instant creadoEn) {
public String etiqueta() {
return "%s · %s %s".formatted(referencia, total, estado);
}
// Fábrica desde la entidad, para reutilizar en los dos caminos
public static ResumenPedido desde(Pedido p) {
return new ResumenPedido(p.getReferencia(), p.getCliente().getNombre(),
p.getEstado(), p.getTotal(), p.getCreadoEn());
}
}
// Con JPQL y constructor. Requiere el nombre COMPLETO de la clase.
@Query("""
select new com.ejemplo.tienda.pedidos.ResumenPedido(
p.referencia, c.nombre, p.estado, p.total, p.creadoEn)
from Pedido p join p.cliente c
where p.estado = :estado
""")
Page<ResumenPedido> resumenes(EstadoPedido estado, Pageable pageable);
// Desde Hibernate 6.2 también se puede sin `new` si los nombres coinciden:
@Query("select p.referencia as referencia, c.nombre as cliente, p.estado as estado, "
+ " p.total as total, p.creadoEn as creadoEn "
+ " from Pedido p join p.cliente c where p.estado = :estado")
List<ResumenPedido> resumenesPorAlias(EstadoPedido estado);
// TIPO 4 · Proyección DINÁMICA: el mismo método sirve para varios tipos
<T> List<T> findByEstado(EstadoPedido estado, Class<T> tipo);
// En el servicio, según el caso de uso:
List<ResumenPedidoProj> ligeros = repo.findByEstado(CONFIRMADO, ResumenPedidoProj.class);
List<Pedido> completos = repo.findByEstado(CONFIRMADO, Pedido.class);
List<SoloReferencia> refs = repo.findByEstado(CONFIRMADO, SoloReferencia.class);
| Tipo | Solo las columnas necesarias | Se puede añadir lógica | Coste de mantenimiento | Cuándo |
| Interfaz cerrada | Sí | Métodos default | Mínimo | Listados simples, prototipos rápidos |
Interfaz abierta (@Value) | No: carga la entidad | Sí, con SpEL | Bajo | Evítala; el SpEL en la capa de datos envejece mal |
record con constructor JPQL | Sí | Sí, Java normal | Medio (el nombre completo en la consulta) | La opción por defecto en código de producción |
| Proyección dinámica | Sí | Según el tipo | Bajo | Cuando un mismo filtro sirve a varias vistas |
Tuplas / Object[] | Sí | No | Alto: acceso por índice, ilegible | Nunca en producción |
No devuelvas entidades JPA en la API REST. Es cómodo el primer día y una fuente de problemas después:
(1) al serializar, Jackson toca las relaciones perezosas y provoca LazyInitializationException o,
con open-in-view, un N+1 durante la serialización; (2) el contrato público de tu API queda atado a
tu esquema, y renombrar una columna rompe a los clientes; (3) expones campos que no deberías salir (auditoría
interna, version, campos de otros contextos); (4) en las peticiones, recibir entidades habilita el
problema de merge de la sección 5.8. Un record DTO por caso de uso es diez
líneas y te ahorra los cuatro problemas.
6.10 Paginación: Page, Slice, List y keyset
public interface PedidoRepository extends Repository<Pedido, Long> {
// Page: contenido + total de elementos + total de páginas.
// Ejecuta DOS consultas: la de datos y un count(*).
Page<Pedido> findByEstado(EstadoPedido estado, Pageable pageable);
// Slice: contenido + "¿hay más?". UNA consulta (pide N+1 elementos).
Slice<Pedido> findByMoneda(String moneda, Pageable pageable);
// List: solo el contenido. Una consulta, sin saber si hay más.
List<Pedido> findByClienteId(Long clienteId, Pageable pageable);
// Limit (Spring Data 3.2+): límite sin objeto Pageable
List<Pedido> findByEstadoOrderByCreadoEnDesc(EstadoPedido estado, Limit limite);
// countQuery propio: cuando el count por defecto es lento
@Query(value = """
select p from Pedido p join fetch p.cliente c
where p.estado = :estado
""",
countQuery = """
select count(p.id) from Pedido p where p.estado = :estado
""") // ← sin el join: el count no necesita el cliente
Page<Pedido> buscarConCliente(EstadoPedido estado, Pageable pageable);
}
| Tipo | Consultas | Sabe el total | Sabe si hay más | Coste con 10 M de filas | Cuándo |
Page<T> | 2 | Sí | Sí | Alto: el count(*) puede tardar segundos | Cuando la interfaz muestra «página 3 de 47» |
Slice<T> | 1 | No | Sí | Bajo | Scroll infinito, apps móviles, «cargar más» |
List<T> | 1 | No | No | Bajo | Cuando sabes que caben o hay un límite duro |
Keyset / Window | 1 | No | Sí | Constante: no depende de la página | Listados grandes, exportaciones, sincronizaciones |
POR QUÉ OFFSET ES CARO, con números reales de PostgreSQL sobre 10 M de pedidos:
select * from pedido order by creado_en desc limit 20 offset 0;
→ Index Scan, 20 filas leídas. 0,4 ms
select * from pedido order by creado_en desc limit 20 offset 100000;
→ Index Scan, 100.020 filas LEÍDAS y 100.000 DESCARTADAS. 85 ms
select * from pedido order by creado_en desc limit 20 offset 5000000;
→ 5.000.020 filas leídas. 4.100 ms
El motor tiene que PRODUCIR todas las filas anteriores para poder saltarlas.
El coste crece linealmente con el número de página. Y encima, si alguien
inserta un pedido entre la página 1 y la 2, un elemento se repite o se salta.
La paginación keyset resuelve las dos cosas: en lugar de "sáltate 100.000",
dice "dame los siguientes 20 DESPUÉS de este punto".
select * from pedido
where (creado_en, id) < (:ultimoCreadoEn, :ultimoId) -- comparación de tuplas
order by creado_en desc, id desc
limit 20;
→ Index Scan con arranque directo, 20 filas leídas. 0,4 ms ← SIEMPRE
Requisitos: un índice sobre (creado_en desc, id desc) y un criterio de orden
TOTAL (por eso se añade el id: sin él, dos pedidos con el mismo instante
pueden repetirse o perderse).
// Keyset con la API de Spring Data 3.1+: ScrollPosition y Window
public interface PedidoRepository extends Repository<Pedido, Long> {
Window<Pedido> findFirst20ByEstadoOrderByCreadoEnDescIdDesc(
EstadoPedido estado, ScrollPosition posicion);
}
@Service
@RequiredArgsConstructor
public class ExportadorPedidos {
private final PedidoRepository repo;
@Transactional(readOnly = true)
public void exportarTodo(EstadoPedido estado, Consumer<Pedido> escritor) {
ScrollPosition posicion = ScrollPosition.keyset(); // desde el principio
Window<Pedido> ventana;
do {
ventana = repo.findFirst20ByEstadoOrderByCreadoEnDescIdDesc(estado, posicion);
ventana.forEach(escritor);
if (ventana.hasNext()) posicion = ventana.positionAt(ventana.size() - 1);
} while (ventana.hasNext());
// Tiempo constante por página, con 10 filas o con 10 millones.
}
}
// Keyset a mano, cuando necesitas control total del SQL y del cursor público
@Query("""
select p from Pedido p
where p.estado = :estado
and (p.creadoEn < :cursorFecha
or (p.creadoEn = :cursorFecha and p.id < :cursorId))
order by p.creadoEn desc, p.id desc
""")
List<Pedido> siguientePagina(EstadoPedido estado, Instant cursorFecha, Long cursorId,
Limit limite);
// Sort: ordenación segura desde la petición HTTP.
// PELIGRO: Pageable acepta cualquier campo desde la URL. Si el cliente pide
// ?sort=cliente.pais, generas un JOIN inesperado; si pide un campo que no
// existe, obtienes un 500. Y ordenar por un campo sin índice es una bomba.
@GetMapping("/pedidos")
public Page<ResumenPedido> listar(
@RequestParam(defaultValue = "CONFIRMADO") EstadoPedido estado,
@PageableDefault(size = 20, sort = "creadoEn", direction = Sort.Direction.DESC)
Pageable pageable) {
return servicio.buscar(estado, sanear(pageable));
}
// Lista blanca de campos ordenables: solo lo que tiene índice
private static final Set<String> ORDENABLES = Set.of("creadoEn", "total", "referencia");
private Pageable sanear(Pageable p) {
Sort limpio = Sort.by(p.getSort().stream()
.filter(o -> ORDENABLES.contains(o.getProperty()))
.toList());
if (limpio.isUnsorted()) limpio = Sort.by(Sort.Direction.DESC, "creadoEn");
// Y un tope duro al tamaño de página: sin él, ?size=100000 es un DoS gratis
int tamano = Math.min(p.getPageSize(), 100);
return PageRequest.of(p.getPageNumber(), tamano, limpio);
}
Cómo evitar el count(*) caro sin perder la funcionalidad: (1) usa Slice si la
interfaz no necesita el total (la mayoría no lo necesita, se lo ha puesto porque venía gratis); (2)
PageableExecutionUtils.getPage() omite el count si el resultado cabe en una página;
(3) da un total aproximado con reltuples de PostgreSQL cuando el filtro es amplio; (4)
limita la cuenta (select count(*) from (select 1 from pedido where … limit 1000) t) y muestra
«más de 1.000 resultados», que es lo que hacen Google y GitHub.
6.11 Tipos de retorno: Optional, Stream, Streamable
public interface PedidoRepository extends Repository<Pedido, Long> {
// Optional: para «como máximo uno». Si hay dos, IncorrectResultSizeDataAccessException
Optional<Pedido> findByReferencia(String referencia);
// exists / count: no traen datos. Siempre preferibles a findBy(...).isPresent()
boolean existsByReferencia(String referencia); // select 1 ... limit 1
long countByEstado(EstadoPedido estado); // select count(*)
// Stream: cursor abierto. Requiere transacción y try-with-resources.
@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "500"))
Stream<Pedido> streamByEstado(EstadoPedido estado);
// Streamable: como una List pero componible sin materializar dos veces
Streamable<Pedido> findByMoneda(String moneda);
}
// Stream bien usado: procesar millones de filas sin llenar la memoria
@Transactional(readOnly = true)
public void exportar(EstadoPedido estado, Writer salida) {
try (Stream<Pedido> flujo = repo.streamByEstado(estado)) {
AtomicInteger n = new AtomicInteger();
flujo.forEach(p -> {
escribirLinea(salida, p);
// IMPRESCINDIBLE: sin esto, las entidades se acumulan en el contexto
// y el OutOfMemoryError llega igual que con findAll().
if (n.incrementAndGet() % 500 == 0) em.clear();
});
}
// Sin try-with-resources, el cursor y la conexión quedan abiertos.
}
Stream del repositorio: tres requisitos y un aviso. (1) Tiene que ejecutarse dentro de una
transacción (el cursor necesita la conexión abierta). (2) Hay que cerrarlo con try-with-resources. (3)
Necesita fetchSize configurado: sin él, el driver de PostgreSQL trae todas las filas a
memoria antes de darte la primera, y el streaming es una ilusión. Y el aviso: para procesos
masivos de solo lectura,
StatelessSession (sección 7.7) es más simple y más rápido que un Stream con
clear() a mano.
// Streamable: componer sin ejecutar dos veces la consulta
Streamable<Pedido> pedidos = repo.findByMoneda("EUR");
List<String> refs = pedidos.map(Pedido::getReferencia).toList();
BigDecimal suma = pedidos.stream().map(Pedido::getTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Ojo: Streamable es reutilizable, pero cada recorrido vuelve a iterar la
// colección ya materializada; no vuelve a consultar la base de datos.
// Envoltorio propio como tipo de retorno: útil para dar semántica de dominio
public class Pedidos implements Streamable<Pedido> {
private final Streamable<Pedido> origen;
public Pedidos(Streamable<Pedido> origen) { this.origen = origen; }
@Override public Iterator<Pedido> iterator() { return origen.iterator(); }
public BigDecimal facturacionTotal() {
return stream().map(Pedido::getTotal).reduce(BigDecimal.ZERO, BigDecimal::add);
}
public Pedidos confirmados() {
return new Pedidos(filter(p -> p.getEstado() == EstadoPedido.CONFIRMADO));
}
}
// Y el repositorio puede devolver Pedidos directamente:
// Pedidos findByClienteId(Long clienteId);
Árbol de decisión: ¿qué uso para esta consulta?
¿Es una búsqueda por id o por clave única?
└─ findById / findByReferencia. Fin.
¿Caben las condiciones en tres palabras clave y son fijas?
└─ Método derivado.
¿Son fijas pero complejas, o necesitas join fetch?
└─ @Query con JPQL y parámetros nombrados.
¿Los filtros son opcionales y combinables (2-3)?
└─ @Query con el truco de (:param is null or …).
¿Los filtros son opcionales y combinables (4 o más)?
└─ Specification, o Querydsl si hay muchas pantallas así.
¿Necesitas funciones de ventana, CTE, jsonb o upsert?
└─ SQL nativo con @Query(nativeQuery = true), o JdbcClient.
¿Es un informe o una agregación sin necesidad de entidades?
└─ JdbcClient con un record. No pases por JPA.
¿Vas a modificar muchas filas con lógica trivial?
└─ @Modifying con clearAutomatically = true.
¿Es un listado para una interfaz?
└─ Proyección a record + Slice (o Page si de verdad necesitas el total).
¿Vas a recorrer millones de filas?
└─ Keyset + StatelessSession, nunca findAll().
7 · El problema N+1 y el rendimiento
Este es el motivo número uno por el que una API con JPA va lenta, y la pregunta de entrevista más previsible
del módulo. Ya sabes de dónde viene (sección 1.2): de fingir que navegar por objetos es gratis. Ahora toca
detectarlo, medirlo y elegir el arreglo adecuado, porque hay cinco y ninguno sirve para todo.
7.1 Qué es exactamente
// El código parece perfectamente inocente
@Transactional(readOnly = true)
public List<ResumenPedido> listar(EstadoPedido estado) {
List<Pedido> pedidos = repo.findByEstado(estado); // 1 consulta
return pedidos.stream()
.map(p -> new ResumenPedido(
p.getReferencia(),
p.getCliente().getNombre(), // ← +1 consulta POR pedido
p.getEstado(), p.getTotal(), p.getCreadoEn()))
.toList();
}
Lo que ejecuta con 500 pedidos:
select p.* from pedido p where p.estado = ? ← 1
select c.* from cliente c where c.id = ? ← 2
select c.* from cliente c where c.id = ? ← 3
...
select c.* from cliente c where c.id = ? ← 501
501 consultas. Con 1,5 ms de ida y vuelta cada una: 750 ms de espera pura,
casi toda en la red, con la base de datos aburrida.
Y aquí está lo peor: en desarrollo, con 12 pedidos de prueba y la base de datos
en localhost, son 13 consultas de 0,1 ms = 1,3 ms. NADIE lo nota. El problema
aparece en producción, con datos reales y latencia real, y para entonces el
código lleva seis meses escrito y hay cuarenta sitios iguales.
Variantes del mismo problema que hay que saber reconocer:
· Colección perezosa en bucle: p.getLineas() por cada pedido.
· @OneToOne inverso: 1 consulta extra por entidad, aunque sea LAZY (4.3).
· Herencia JOINED con consulta polimórfica: JOIN por subclase.
· Serialización a JSON con open-in-view: el N+1 ocurre en el ObjectMapper.
· @Formula: subconsulta correlacionada por fila.
· El N+1 "de segundo grado": lineas → producto → categoria. N × M consultas.
7.2 Cómo detectarlo (cuatro formas, de peor a mejor)
| Método | Cuándo lo detecta | Esfuerzo | Fiabilidad |
| Leer los logs de SQL a ojo | Al desarrollar, si te fijas | Bajo | Baja: con 12 filas de prueba no se ve nada raro |
generate_statistics y el resumen de sesión | Al desarrollar y en tests manuales | Bajo | Media |
| APM / trazas (OpenTelemetry, Datadog) | En producción, cuando ya duele | Medio | Alta, pero tarde |
| Test que cuenta consultas | En CI, antes de mezclar | Medio (una vez) | Total: no se puede colar |
// LA HERRAMIENTA DEFINITIVA: un test que falla si aparece un N+1.
// Una vez montada la infraestructura, añadir la comprobación cuesta una línea.
@TestConfiguration
public class ContadorConsultasConfig {
@Bean
static BeanPostProcessor proxyDataSource() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String nombre) {
if (bean instanceof DataSource ds && !(bean instanceof ProxyDataSource)) {
return ProxyDataSourceBuilder.create(ds)
.name("test")
.countQuery() // ← habilita QueryCountHolder
.build();
}
return bean;
}
};
}
}
// Aserción reutilizable, expresiva y con mensaje útil cuando falla
public final class Consultas {
private Consultas() { }
public static void reiniciar() { QueryCountHolder.clear(); }
public static void seEjecutaron(int esperadas) {
QueryCount c = QueryCountHolder.getGrandTotal();
assertThat(c.getSelect())
.as("Consultas SELECT ejecutadas (esperadas %d). "
+ "Si el número crece con los datos, tienes un N+1.", esperadas)
.isEqualTo(esperadas);
}
public static void comoMaximo(int maximo) {
assertThat(QueryCountHolder.getGrandTotal().getSelect()).isLessThanOrEqualTo(maximo);
}
public static void resumen() {
QueryCount c = QueryCountHolder.getGrandTotal();
System.out.printf("select=%d insert=%d update=%d delete=%d total=%d%n",
c.getSelect(), c.getInsert(), c.getUpdate(), c.getDelete(), c.getTotal());
}
}
@SpringBootTest
@Import(ContadorConsultasConfig.class)
@Testcontainers
class PedidoConsultasTest {
@Autowired PedidoService servicio;
@Autowired PedidoRepository repo;
@Autowired TransactionTemplate tx;
@BeforeEach
void sembrarYReiniciar() {
// 50 pedidos: suficiente para que un N+1 sea evidente en el contador
tx.executeWithoutResult(t -> sembrar(50));
Consultas.reiniciar();
}
@Test
void el_listado_no_debe_tener_n_mas_1() {
List<ResumenPedido> resultado = servicio.listar(EstadoPedido.CONFIRMADO);
assertThat(resultado).hasSize(50);
Consultas.seEjecutaron(1); // ← con el N+1, aquí verías 51
}
@Test
void el_detalle_carga_el_agregado_en_una_consulta() {
servicio.detalle("PED-1");
Consultas.comoMaximo(2); // pedido+cliente+lineas y, si acaso, productos
}
// El test que de verdad blinda contra regresiones: comprobar que el número
// de consultas NO DEPENDE del número de filas.
@ParameterizedTest
@ValueSource(ints = {1, 10, 100})
void el_numero_de_consultas_es_constante(int cuantos) {
tx.executeWithoutResult(t -> sembrar(cuantos));
Consultas.reiniciar();
servicio.listar(EstadoPedido.CONFIRMADO);
Consultas.seEjecutaron(1); // 1 con 1 fila, 1 con 100 filas. Esa es la clave.
}
}
Por qué el test paramétrico es el bueno: afirmar «se ejecutan 3 consultas» es frágil (una optimización
legítima puede cambiar el número y romper el test sin motivo). Afirmar «el número de consultas es el mismo con
1 fila que con 100» captura exactamente la propiedad que importa y no se rompe por refactorizaciones
inofensivas. Es la diferencia entre un test que te protege y un test que te molesta.
// Alternativa sin dependencias extra, usando solo las estadísticas de Hibernate
@Test
void sin_n_mas_1_con_estadisticas() {
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.clear();
servicio.listar(EstadoPedido.CONFIRMADO);
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
// Y una comprobación complementaria muy útil:
assertThat(stats.getCollectionFetchCount())
.as("Colecciones cargadas por separado: cada una es un viaje extra")
.isZero();
}
// Requiere spring.jpa.properties.hibernate.generate_statistics=true en el
// application-test.yml. Es lo más barato de montar si no quieres otra librería.
7.3 Las cinco soluciones y su contrapartida
| Solución | Consultas | Ventaja | Contrapartida | Cuándo |
join fetch |
1 |
Una sola ida y vuelta; control total en la consulta. |
Duplica los datos del padre por cada hijo; rompe la paginación con colecciones; una consulta por caso de uso. |
@ManyToOne y una sola colección, sin paginar. |
@EntityGraph |
1 |
Declarativo, reutilizable, funciona con métodos derivados. |
Lo mismo que join fetch: es la misma técnica con otra sintaxis. |
Lo mismo, pero con métodos derivados o grafos compartidos. |
@BatchSize / default_batch_fetch_size |
1 + ⌈N/tamaño⌉ |
Funciona con paginación; global, sin tocar consultas; sin duplicados. |
No es una sola consulta; mitiga en vez de eliminar. |
Red de seguridad global. Ponlo siempre. |
@Fetch(SUBSELECT) |
2 |
Dos consultas fijas, sin duplicados, compatible con paginación. |
Repite la consulta principal como subconsulta: si es caro, se paga dos veces. |
Colección grande de un conjunto pequeño de padres ya filtrado. |
| Proyección DTO |
1 |
La más rápida: solo las columnas necesarias, sin entidades ni instantáneas. |
No sirve si vas a modificar; hay que escribir el DTO. |
Todos los listados y APIs de lectura. La primera opción a considerar. |
// SOLUCIÓN 1 · join fetch
@Query("""
select p from Pedido p
join fetch p.cliente
where p.estado = :estado
""")
List<Pedido> conCliente(EstadoPedido estado);
// SQL: 1 consulta con inner join. Perfecto para @ManyToOne.
// Con colección: usa LEFT para no perder los pedidos sin líneas,
// y DISTINCT... que en Hibernate 6 ya NO hace falta (deduplica solo).
@Query("""
select p from Pedido p
left join fetch p.lineas l
left join fetch l.producto
where p.referencia = :ref
""")
Optional<Pedido> detalleCompleto(String ref);
// SOLUCIÓN 2 · @EntityGraph (idéntico efecto, otra sintaxis)
@EntityGraph(attributePaths = {"cliente"})
List<Pedido> findByEstado(EstadoPedido estado);
// SOLUCIÓN 3 · @BatchSize: la red de seguridad
@Entity
public class Pedido {
@BatchSize(size = 25) // por relación
@OneToMany(mappedBy = "pedido")
private List<LineaPedido> lineas = new ArrayList<>();
}
@Entity
@BatchSize(size = 25) // por entidad: aplica a los proxies
public class Cliente { … }
# Y lo mismo de forma global, que es lo que deberías tener siempre puesto:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25
Qué hace @BatchSize / default_batch_fetch_size con 500 pedidos y tamaño 25:
select p.* from pedido p where p.estado = ? ← 1
select c.* from cliente c where c.id in (?,?,?,…,?) -- 25 ids ← 2
select c.* from cliente c where c.id in (?,?,?,…,?) -- 25 ids ← 3
...
20 consultas de lotes en lugar de 500 individuales.
Total: 21 consultas frente a 501. Una mejora de 24×, sin tocar el código.
Y lo más valioso: FUNCIONA CON PAGINACIÓN, porque no hay join fetch, así que
el limit/offset se aplica correctamente en SQL sobre la consulta principal.
Cómo elegir el tamaño: entre 16 y 50. Más grande genera cláusulas IN enormes
que producen planes distintos en la base de datos (mitígalo con
query.in_clause_parameter_padding: true, que redondea a potencias de 2 y
reduce el número de planes distintos que la BD tiene que cachear).
// SOLUCIÓN 4 · @Fetch(SUBSELECT): dos consultas, sin duplicados
@Entity
public class Pedido {
@OneToMany(mappedBy = "pedido")
@Fetch(FetchMode.SUBSELECT)
private List<LineaPedido> lineas = new ArrayList<>();
}
// SQL al cargar 500 pedidos y tocar las líneas:
// select p.* from pedido p where p.estado = ?
// select l.* from linea_pedido l
// where l.pedido_id in (select p.id from pedido p where p.estado = ?)
// Dos consultas fijas. Ojo: la subconsulta repite el filtro original.
// SOLUCIÓN 5 · Proyección DTO: la que deberías considerar primero
public record ResumenPedido(String referencia, String cliente, EstadoPedido estado,
BigDecimal total, Instant creadoEn) { }
@Query("""
select new com.ejemplo.tienda.pedidos.ResumenPedido(
p.referencia, c.nombre, p.estado, p.total, p.creadoEn)
from Pedido p join p.cliente c
where p.estado = :estado
""")
Page<ResumenPedido> resumenes(EstadoPedido estado, Pageable pageable);
// 1 consulta, 5 columnas, cero entidades gestionadas, cero instantáneas,
// paginación correcta en SQL. Es lo mejor de todos los mundos... si solo lees.
El orden en que deberías pensarlo: (1) ¿solo voy a leer? → proyección DTO y has
terminado. (2) ¿Necesito las entidades y no hay paginación? → join fetch o
@EntityGraph. (3) ¿Necesito las entidades y sí hay paginación? →
@BatchSize/default_batch_fetch_size, o dos consultas (ids primero). (4) Y
en todos los casos, ten default_batch_fetch_size puesto de forma global como red
de seguridad para los N+1 que se te escapen.
7.4 MultipleBagFetchException
// El error
@Entity
public class Pedido {
@OneToMany(mappedBy = "pedido") private List<LineaPedido> lineas;
@OneToMany(mappedBy = "pedido") private List<Envio> envios;
}
@Query("select p from Pedido p join fetch p.lineas join fetch p.envios where p.id = :id")
Optional<Pedido> malo(Long id);
// org.hibernate.loader.MultipleBagFetchException:
// cannot simultaneously fetch multiple bags: [Pedido.lineas, Pedido.envios]
POR QUÉ FALLA (y por qué es bueno que falle)
Un pedido con 3 líneas y 2 envíos, con los dos join fetch, produce:
linea_1 × envio_1 linea_1 × envio_2
linea_2 × envio_1 linea_2 × envio_2 ← producto cartesiano: 6 filas
linea_3 × envio_1 linea_3 × envio_2
Con un Set, Hibernate deduplica y reconstruye 3 líneas y 2 envíos: correcto.
Con dos List (bags), no puede: no sabe distinguir "la misma línea repetida por
el join" de "dos líneas idénticas legítimas", porque una bag permite duplicados.
Así que se niega a hacerlo en lugar de devolverte 6 líneas y 6 envíos.
Y ojo con la magnitud: con 20 líneas y 15 envíos, son 300 filas para leer 35
elementos. Con tres colecciones, miles. El error te está salvando de eso.
// SOLUCIÓN 1 (rápida): Set en lugar de List. Hibernate ya puede deduplicar.
@OneToMany(mappedBy = "pedido") private Set<LineaPedido> lineas = new HashSet<>();
@OneToMany(mappedBy = "pedido") private Set<Envio> envios = new HashSet<>();
// Funciona, pero sigues trayendo el producto cartesiano por la red.
// Necesita equals/hashCode correctos en las entidades hijas (sección 5.10).
// SOLUCIÓN 2 (la mejor): una colección por consulta, aprovechando la caché
// de primer nivel para que sea un solo objeto.
@EntityGraph(attributePaths = {"lineas"})
Optional<Pedido> findWithLineasById(Long id);
@EntityGraph(attributePaths = {"envios"})
Optional<Pedido> findWithEnviosById(Long id);
@Transactional(readOnly = true)
public Pedido cargarCompleto(Long id) {
Pedido p = repo.findWithLineasById(id).orElseThrow();
repo.findWithEnviosById(id); // MISMA instancia (caché L1): solo inicializa
return p; // los envíos en el objeto que ya tienes
}
// 2 consultas, cero producto cartesiano, cero riesgo. Es la opción correcta.
// SOLUCIÓN 3: dejar que @BatchSize lo resuelva y no hacer fetch de nada
@BatchSize(size = 25) @OneToMany(mappedBy = "pedido") private List<LineaPedido> lineas;
@BatchSize(size = 25) @OneToMany(mappedBy = "pedido") private List<Envio> envios;
// Cargar 20 pedidos y tocar las dos colecciones: 1 + 1 + 1 = 3 consultas.
7.5 Paginar con join fetch: por qué se hace en memoria
@Query("select p from Pedido p left join fetch p.lineas where p.estado = :estado")
Page<Pedido> malo(EstadoPedido estado, Pageable pageable);
HHH90003004: firstResult/maxResults specified with collection fetch;
applying in memory
Traducción: "voy a traerme TODAS las filas que cumplen la condición y a
paginar en Java". Con 200.000 pedidos, eso son 200.000 pedidos y todas sus
líneas en el heap para devolverte 20. Un OutOfMemoryError esperando su turno.
POR QUÉ no puede paginar en SQL: el join multiplica las filas. Un pedido con
5 líneas ocupa 5 filas del resultado. Si el motor aplica "limit 20", corta a
mitad de un pedido: te devolvería 4 pedidos completos y uno con 3 de sus 5
líneas. Los datos serían INCORRECTOS. Hibernate prefiere ser lento a ser
incorrecto, y avisa.
Con @ManyToOne (join fetch p.cliente) NO hay problema: la relación es a-uno,
no multiplica filas, y el limit funciona perfectamente en SQL.
# Convierte el aviso en un ERROR para que nadie lo ignore. Deberías tenerlo
# activado en todos los entornos: el aviso se pierde entre miles de líneas de log.
spring:
jpa:
properties:
hibernate:
query.fail_on_pagination_over_collection_fetch: true
// SOLUCIÓN 1 · Dos pasos: paginar los ids, cargar el detalle.
// La técnica clásica y la más fiable.
public interface PedidoRepository extends Repository<Pedido, Long> {
@Query(value = "select p.id from Pedido p where p.estado = :estado",
countQuery = "select count(p.id) from Pedido p where p.estado = :estado")
Page<Long> idsPorEstado(EstadoPedido estado, Pageable pageable);
@Query("""
select distinct p from Pedido p
left join fetch p.lineas l
left join fetch l.producto
where p.id in :ids
""")
List<Pedido> cargarConDetalle(Collection<Long> ids, Sort sort);
}
@Transactional(readOnly = true)
public Page<Pedido> paginarConDetalle(EstadoPedido estado, Pageable pageable) {
Page<Long> ids = repo.idsPorEstado(estado, pageable); // paginación en SQL: rápida
if (ids.isEmpty()) return Page.empty(pageable);
List<Pedido> pedidos = repo.cargarConDetalle(ids.getContent(), pageable.getSort());
return new PageImpl<>(pedidos, pageable, ids.getTotalElements());
}
// 2 consultas (3 con el count), paginación correcta, sin nada en memoria de más.
// SOLUCIÓN 2 · Paginar sin fetch y dejar que @BatchSize cargue las colecciones
@EntityGraph(attributePaths = {"cliente"}) // solo el @ManyToOne: no multiplica
Page<Pedido> findByEstado(EstadoPedido estado, Pageable pageable);
// Y con default_batch_fetch_size = 25, tocar p.getLineas() de los 20 pedidos
// de la página cuesta 1 consulta más. Total: 3. Simple y suficiente.
// SOLUCIÓN 3 · Proyección plana con la agregación que necesites
@Query("""
select new com.ejemplo.tienda.pedidos.PedidoConTotales(
p.referencia, c.nombre, p.estado, p.total,
count(l.id), coalesce(sum(l.cantidad), 0))
from Pedido p join p.cliente c left join p.lineas l
where p.estado = :estado
group by p.id, p.referencia, c.nombre, p.estado, p.total
""")
Page<PedidoConTotales> resumenConTotales(EstadoPedido estado, Pageable pageable);
// 1 consulta con group by: paginación correcta y los datos agregados que la
// interfaz quería mostrar. A menudo esto es lo que hacía falta desde el principio.
7.6 Escrituras en lote: el batching de verdad
spring:
jpa:
properties:
hibernate:
jdbc.batch_size: 50 # tamaño del lote
order_inserts: true # agrupa INSERT por tabla
order_updates: true # agrupa UPDATE por tabla
jdbc.batch_versioned_data: true # permite lotes con @Version
datasource:
hikari:
data-source-properties:
reWriteBatchedInserts: true # ← IMPRESCINDIBLE en PostgreSQL
LAS CUATRO CONDICIONES DEL BATCHING. Si falla una, no hay lotes y no te avisa.
1. jdbc.batch_size > 0 ← configuración
2. La estrategia de id NO es IDENTITY ← usa SEQUENCE (3.2)
3. Las sentencias consecutivas son IGUALES ← order_inserts/order_updates
4. En PostgreSQL, reWriteBatchedInserts = true ← propiedad del driver
Sin la 4, el driver de PostgreSQL envía las sentencias agrupadas en un solo
viaje, pero como sentencias INDIVIDUALES. Con ella, las REESCRIBE como un único
insert multi-valores, que es entre 2 y 3 veces más rápido:
sin reWriteBatchedInserts:
insert into linea_pedido (...) values (?,?,?); ×50 en un viaje
con reWriteBatchedInserts:
insert into linea_pedido (...) values (?,?,?),(?,?,?),… ×50 ← una sentencia
POR QUÉ order_inserts IMPORTA. Sin él, insertar pedidos con líneas produce:
insert pedido, insert linea, insert linea, insert pedido, insert linea…
Cada cambio de sentencia CIERRA el lote actual. Resultado: lotes de 1 y 2.
Con order_inserts=true, Hibernate reordena:
insert pedido ×N (un lote)
insert linea ×M (otro lote)
Y respeta las claves foráneas porque los padres van primero.
// MEDICIÓN REAL: insertar 50.000 líneas de pedido en PostgreSQL 16 (localhost)
// A) Bucle con save() por elemento, IDENTITY, sin batching
for (var linea : lineas) repo.save(linea);
// 50.000 INSERT individuales, 50.000 viajes. ~38 s
// B) saveAll() con IDENTITY
repo.saveAll(lineas);
// Igual: IDENTITY impide el batching. saveAll SOLO recorre la lista. ~36 s
// C) saveAll() con SEQUENCE(allocationSize=50) + batch_size=50
repo.saveAll(lineas);
// 1.000 SELECT nextval + 1.000 lotes de 50. ~2,4 s (15×)
// D) Lo mismo + reWriteBatchedInserts=true
// 1.000 SELECT nextval + 1.000 insert multi-valores. ~0,9 s (42×)
// E) StatelessSession (sin contexto de persistencia)
// Sin dirty checking, sin instantáneas, sin caché L1. ~0,6 s (63×)
// F) COPY nativo de PostgreSQL
// El límite físico de la máquina. ~0,15 s (250×)
// Los números concretos dependen de la máquina, pero los ÓRDENES DE MAGNITUD
// se mantienen: la diferencia entre (A) y (D) es solo configuración.
saveAll() no hace lotes por sí solo. Es un malentendido muy extendido. Su implementación es
literalmente for (S e : entities) result.add(save(e));. El batching lo hace Hibernate en
el flush, y solo si se cumplen las cuatro condiciones de arriba. saveAll() te ahorra el
bucle, nada más. La ventaja real de saveAll es que la lista completa está en el contexto antes del
flush, lo que permite a order_inserts agrupar bien.
// Procesar 1.000.000 de filas sin llenar la memoria: el patrón de troceado
@Service
@RequiredArgsConstructor
public class ImportadorPedidos {
private final EntityManager em;
private static final int LOTE = 500;
@Transactional
public void importar(Iterator<PedidoCsv> filas) {
int n = 0;
while (filas.hasNext()) {
em.persist(convertir(filas.next()));
if (++n % LOTE == 0) {
em.flush(); // envía el lote a la base de datos
em.clear(); // libera memoria: las entidades pasan a SEPARADAS
}
}
// El resto se vuelca en el commit
}
}
Una sola transacción para un millón de filas es una mala idea aunque uses flush y
clear: mantienes una conexión ocupada durante minutos, generas un volumen enorme de WAL, bloqueas
la limpieza (VACUUM) de esas tablas durante todo el proceso, y si falla en la fila 999.999 lo
pierdes todo. Trocea en transacciones de 1.000-10.000 filas, con seguimiento del progreso para poder reanudar.
En procesos verdaderamente masivos, la respuesta correcta no es JPA: es COPY a una tabla temporal
y un INSERT ... SELECT ... ON CONFLICT en la base de datos.
// Troceado con transacción por lote, reanudable
@Service
@RequiredArgsConstructor
public class ImportadorPorLotes {
private final ImportadorPedidos importador; // otro bean: el proxy debe aplicar
private final ProgresoRepository progreso;
public void importarTodo(Path fichero) {
try (var lineas = Files.lines(fichero)) {
Iterator<PedidoCsv> it = lineas.skip(progreso.ultimaFilaProcesada())
.map(PedidoCsv::parsear).iterator();
List<PedidoCsv> lote = new ArrayList<>(1000);
while (it.hasNext()) {
lote.add(it.next());
if (lote.size() == 1000) { importador.guardarLote(lote); lote.clear(); }
}
if (!lote.isEmpty()) importador.guardarLote(lote);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@Service
@RequiredArgsConstructor
public class ImportadorLote {
private final EntityManager em;
@Transactional // ← una transacción POR LOTE, no por fichero
public void guardarLote(List<PedidoCsv> lote) {
lote.forEach(csv -> em.persist(convertir(csv)));
// Al terminar el método: flush + commit + contexto cerrado. Memoria libre.
}
}
7.7 StatelessSession y las alternativas en SQL puro
// StatelessSession: JPA sin el contexto de persistencia. Ni caché L1, ni dirty
// checking, ni cascadas, ni callbacks, ni caché L2. Solo mapeo objeto-fila.
@Service
@RequiredArgsConstructor
public class CargaMasiva {
private final SessionFactory sessionFactory;
public void insertar(List<LineaPedido> lineas) {
try (StatelessSession sesion = sessionFactory.openStatelessSession()) {
Transaction tx = sesion.beginTransaction();
for (LineaPedido l : lineas) {
sesion.insert(l); // directo, sin contexto que crezca
}
tx.commit();
}
}
// Hibernate 6.6 añade operaciones por lotes explícitas en StatelessSession
public void insertarPorLotes(List<LineaPedido> lineas) {
try (StatelessSession sesion = sessionFactory.openStatelessSession()) {
Transaction tx = sesion.beginTransaction();
sesion.insertMultiple(lineas); // gestiona el lote internamente
tx.commit();
}
}
// Lectura masiva sin acumular nada en memoria
public void recorrerTodo(Consumer<Pedido> accion) {
try (StatelessSession sesion = sessionFactory.openStatelessSession()) {
try (var flujo = sesion.createQuery("from Pedido", Pedido.class)
.setFetchSize(1000)
.getResultStream()) {
flujo.forEach(accion); // sin em.clear(): no hay contexto que limpiar
}
}
}
}
| Session / EntityManager | StatelessSession |
| Caché de primer nivel | Sí | No |
| Dirty checking | Sí | No: update() explícito |
Cascadas y orphanRemoval | Sí | No |
Callbacks (@PrePersist…) y auditoría | Sí | No |
Bloqueo optimista con @Version | Sí | No automático |
| Carga perezosa | Sí | No: las relaciones llegan sin inicializar |
| Consumo de memoria | Crece con las entidades tocadas | Constante |
| Uso ideal | Lógica de negocio transaccional | Importación, exportación y migración de datos |
-- Y la alternativa que gana a todas cuando el volumen es de verdad grande:
-- hacerlo dentro de la base de datos y no mover los datos.
-- 1) Actualización masiva: 1 sentencia frente a 500.000 entidades cargadas
update producto
set precio = round(precio * 1.05, 2),
version = version + 1
where categoria_id = 3
and activo;
-- 40 ms frente a los ~4 minutos de cargarlas y modificarlas una a una.
-- 2) Carga desde fichero: COPY es el camino más rápido que existe
copy staging_pedido (referencia, cliente_email, total, creado_en)
from '/tmp/pedidos.csv' with (format csv, header true);
insert into pedido (referencia, cliente_id, estado, total, creado_en)
select s.referencia, c.id, 'CONFIRMADO', s.total, s.creado_en
from staging_pedido s
join cliente c on c.email = s.cliente_email
on conflict (referencia) do nothing; -- idempotente: se puede repetir
-- 3) Borrado masivo por lotes, sin bloquear la tabla ni inflar el WAL
with victimas as (
select id from pedido
where estado = 'CANCELADO' and creado_en < now() - interval '2 years'
limit 10000
for update skip locked -- no espera si otro proceso ya las tiene
)
delete from pedido p using victimas v where p.id = v.id;
-- Se ejecuta en bucle desde la aplicación hasta que devuelve 0 filas.
7.8 Lecturas de solo lectura: tres niveles de ahorro
// NIVEL 1 · @Transactional(readOnly = true). En TODOS los métodos de consulta.
@Transactional(readOnly = true)
public List<ResumenPedido> listar(EstadoPedido estado) { … }
// Qué hace exactamente:
// · Connection.setReadOnly(true): la BD puede optimizar y, en una réplica,
// es obligatorio.
// · FlushMode.MANUAL en la sesión: NO hay dirty checking ni flush automático.
// Es lo que de verdad ahorra CPU.
// · Protege de escrituras accidentales: un cambio no se persiste.
// · Permite el enrutamiento a réplicas de lectura (sección 2.6).
// NIVEL 2 · Entidades de solo lectura: sin instantánea para el dirty checking.
// Ahorra la mitad de la memoria por entidad.
@Query("select p from Pedido p where p.estado = :estado")
@QueryHints(@QueryHint(name = AvailableHints.HINT_READ_ONLY, value = "true"))
List<Pedido> soloLectura(EstadoPedido estado);
// O para toda la sesión, con la API de Hibernate:
em.unwrap(Session.class).setDefaultReadOnly(true);
// NIVEL 3 · No cargues entidades: proyección DTO.
// Ni instantánea, ni entidad, ni columnas de más. El máximo ahorro.
@Query("""
select new com.ejemplo.tienda.pedidos.ResumenPedido(
p.referencia, c.nombre, p.estado, p.total, p.creadoEn)
from Pedido p join p.cliente c where p.estado = :estado
""")
List<ResumenPedido> proyeccion(EstadoPedido estado);
Coste comparado de leer 10.000 pedidos (medición aproximada, heap tras la
consulta y tiempo total):
Entidades normales, transacción de escritura ~38 MB 420 ms
Entidades con @Transactional(readOnly = true) ~38 MB 310 ms (-26 %)
Entidades con hint READ_ONLY ~21 MB 280 ms (-33 %)
Proyección a record de 5 campos ~4 MB 95 ms (-77 %)
La proyección gana por tres motivos que se suman:
1. Menos columnas por la red (14 → 5).
2. Ni entidad gestionada ni instantánea (nada que recorrer en el flush).
3. Objetos más pequeños: menos presión sobre el recolector de basura.
Una regla que rinde muchísimo: pon @Transactional(readOnly = true) a nivel de
clase en los servicios de consulta, y @Transactional (sin readOnly)
solo en los métodos concretos que escriben. Es una línea por clase, no puede olvidarse por método, y protege
contra escrituras accidentales en el 80 % del código que solo lee.
7.9 Evitar entidades enormes
El síntoma: una entidad con 60 campos, 12 relaciones y tres @Lob. Cada
findById trae 60 columnas y varios megabytes; cada flush compara 60 propiedades por
entidad; cada UPDATE escribe 60 columnas (recordemos que Hibernate actualiza todas por defecto) y
reescribe todos los índices que las contienen; y cualquier cambio en cualquier campo bloquea la fila entera
para todos los demás casos de uso.
// Las cuatro técnicas para partir una entidad demasiado grande
// 1) Sacar los binarios y los textos enormes a otra entidad (o fuera de la BD)
@Entity class Producto { @OneToOne(mappedBy="producto", fetch=LAZY) FichaTecnica ficha; }
@Entity class FichaTecnica { @Lob String contenido; @MapsId @OneToOne Producto producto; }
// 2) Agrupar campos relacionados en @Embeddable: no cambia el SQL, pero hace
// el código manejable y permite razonar por bloques.
@Embedded private DatosFiscales datosFiscales;
@Embedded private DatosLogisticos datosLogisticos;
// 3) Separar por CONTEXTO: dos entidades sobre la misma tabla, cada una con
// los campos que su caso de uso necesita. Perfectamente válido en JPA.
@Entity @Table(name = "producto")
public class ProductoCatalogo { // lo que necesita el catálogo público
@Id private Long id;
private String sku; private String nombre; private BigDecimal precio;
}
@Entity @Table(name = "producto")
public class ProductoAlmacen { // lo que necesita el almacén
@Id private Long id;
private String sku; private int stock; private String ubicacion;
@Version private long version;
}
// Ojo: dos entidades sobre la misma tabla en la MISMA transacción pueden
// pisarse. Úsalo cuando los contextos no se solapan (que es lo habitual).
// 4) @DynamicUpdate: solo las columnas modificadas en el UPDATE
@Entity
@DynamicUpdate
public class Producto { … }
// Ventaja: sentencias más cortas, menos índices tocados, menos conflictos.
// Coste: una sentencia SQL distinta por combinación de campos cambiados,
// así que la base de datos cachea más planes y se pierde el batching.
// Regla: úsalo en entidades con MUCHAS columnas o con columnas LOB, no en
// entidades pequeñas donde no aporta nada.
7.10 Lista de comprobación de rendimiento
8 · Transacciones
Una anotación de once caracteres que controla la integridad de todos tus datos. @Transactional es
fácil de poner y difícil de poner bien, porque su comportamiento depende de dónde la pones, de quién llama al
método y de qué excepción se lanza. Esta sección cubre todo eso, incluido lo que no funciona y por qué.
8.1 Qué garantiza una transacción
ACID, con el matiz que importa en cada letra:
ATOMICIDAD Todo o nada. Si algo falla, ninguna de las escrituras queda.
→ Es lo que te permite escribir lógica de negocio sin
compensaciones manuales por cada paso.
CONSISTENCIA Las restricciones se cumplen al terminar (NOT NULL, UNIQUE,
CHECK, claves foráneas).
→ NO significa que tus reglas de negocio se cumplan: eso lo
garantizas tú (o lo trasladas a un CHECK).
AISLAMIENTO Las transacciones concurrentes no se ven a medias.
→ Es la letra con más matices: hay NIVELES, y el que se usa
por defecto (READ COMMITTED) permite anomalías reales.
Sección 9.4.
DURABILIDAD Confirmado es confirmado, aunque se caiga la máquina.
→ Con replicación asíncrona, "confirmado en la primaria" no
es "confirmado en todas las réplicas". Matiz importante en
bases de datos gestionadas en la nube.
Lo que una transacción NO te da:
· No hace atómica una llamada HTTP a otro servicio (sección 8.9).
· No protege de un "lost update" en READ COMMITTED (sección 9.1).
· No serializa el acceso: para eso hacen falta bloqueos (sección 9.3).
· No es gratis: mantiene abierta una conexión y retiene versiones de filas.
8.2 @Transactional en detalle
// Todos los atributos, con lo que hace cada uno
@Transactional(
propagation = Propagation.REQUIRED, // defecto. Sección 8.3
isolation = Isolation.DEFAULT, // usa el del motor. Sección 9.4
timeout = 10, // segundos. 0 = sin límite
readOnly = false, // true en consultas. Sección 7.8
rollbackFor = { Exception.class }, // también con checked. Sección 8.4
noRollbackFor = { StockInsuficienteException.class },
label = { "critico" }, // etiquetas para el gestor (raro)
value = "transactionManager" // qué gestor usar (multi-BD)
)
public void metodo() { … }
Dónde poner la anotación
| Capa | ¿@Transactional? | Por qué |
Controlador (@RestController) |
No |
Alargaría la transacción a la validación, la serialización y el mapeo. Es la vía rápida a las transacciones largas y al N+1 durante la serialización. |
| Servicio de aplicación |
Sí: aquí es el sitio |
Un método de servicio = un caso de uso = una unidad de trabajo. La transacción coincide exactamente con la operación de negocio. |
| Repositorio |
Ya la tiene |
SimpleJpaRepository está anotado. Sus métodos son REQUIRED, así que se unen a la transacción del servicio si existe. |
| Entidad o clase de dominio |
No |
No son beans de Spring: la anotación no hace nada en absoluto. |
Método private |
No funciona |
El proxy no puede interceptarlo. Sección 8.5. |
// El patrón recomendado: readOnly en la clase, escritura en los métodos concretos
@Service
@Transactional(readOnly = true) // ← por defecto, todo es lectura
@RequiredArgsConstructor
public class PedidoService {
private final PedidoRepository repo;
private final ProductoRepository productos;
private final PublicadorEventos eventos;
// Hereda readOnly = true: no hay dirty checking y no se puede escribir
public DetallePedido detalle(String referencia) {
return repo.findWithDetailByReferencia(referencia)
.map(DetallePedido::desde)
.orElseThrow(() -> new PedidoNoEncontradoException(referencia));
}
@Transactional // ← sobrescribe: transacción de escritura
public String crear(CrearPedidoCmd cmd) {
Pedido pedido = Pedido.nuevo(clienteRepo.getReferenceById(cmd.clienteId()));
cmd.lineas().forEach(l ->
pedido.anadirLinea(productos.getReferenceById(l.productoId()), l.cantidad()));
repo.save(pedido);
eventos.publicar(new PedidoCreado(pedido.getReferencia())); // sección 8.7
return pedido.getReferencia();
}
@Transactional(timeout = 5)
public void confirmar(String referencia) {
repo.findByReferencia(referencia)
.orElseThrow(() -> new PedidoNoEncontradoException(referencia))
.confirmar();
}
}
8.3 Los siete valores de propagation
| Valor | Sin transacción activa | Con transacción activa | Uso real |
REQUIRED (defecto) |
Crea una |
Se une a ella |
El 95 % de los casos. Un fallo en cualquier punto deshace todo el caso de uso. |
REQUIRES_NEW |
Crea una |
Suspende la actual y crea otra independiente |
Auditoría o registro que debe sobrevivir al rollback del negocio. Consume una segunda conexión del pool. |
SUPPORTS |
No crea ninguna |
Se une |
Métodos de lectura que funcionan igual dentro o fuera. Poco útil en la práctica. |
NOT_SUPPORTED |
No crea ninguna |
Suspende la actual y ejecuta sin transacción |
Una operación larga que no debe retener la transacción (una consulta de informe enorme). |
MANDATORY |
Falla (IllegalTransactionStateException) |
Se une |
Documentar en el código que un método interno exige contexto transaccional. Muy útil como contrato. |
NEVER |
Ejecuta |
Falla |
Proteger explícitamente una llamada externa lenta de estar dentro de una transacción. |
NESTED |
Crea una |
Crea un savepoint: se puede deshacer solo esta parte |
Rollback parcial. Funciona con JDBC/JPA sobre PostgreSQL, pero es raro y sorprende: úsalo con mucha justificación. |
// REQUIRES_NEW: el caso de uso legítimo más frecuente.
// La auditoría del intento debe quedar registrada AUNQUE el pago falle.
@Service
@RequiredArgsConstructor
public class PagoService {
private final AuditoriaService auditoria; // ← otro bean: el proxy debe aplicar
private final PasarelaPago pasarela;
@Transactional
public void cobrar(String referencia, BigDecimal importe) {
auditoria.registrarIntento(referencia, importe); // REQUIRES_NEW: se confirma ya
Pedido pedido = repo.findByReferencia(referencia).orElseThrow();
pedido.marcarPagado(pasarela.cobrar(pedido, importe));
// Si esto lanza, el pedido no se marca... pero el intento queda auditado.
}
}
@Service
@RequiredArgsConstructor
public class AuditoriaService {
private final IntentoRepository repo;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registrarIntento(String referencia, BigDecimal importe) {
repo.save(new IntentoPago(referencia, importe, Instant.now()));
}
}
El peligro oculto de REQUIRES_NEW: agotar el pool desde dentro. La transacción exterior
mantiene su conexión mientras la interior pide otra. Con un pool de 10 conexiones y 10
peticiones simultáneas ejecutando ese patrón, las 10 conexiones están ocupadas por las transacciones exteriores
y las 10 interiores esperan una conexión que nunca llegará: deadlock a nivel de pool. El
síntoma es Connection is not available, request timed out con la base de datos completamente
ociosa. Y hay un segundo peligro: si la transacción interior bloquea una fila que la exterior también quiere,
tienes un deadlock real en la base de datos. Usa REQUIRES_NEW con moderación y nunca dentro de un
bucle.
// MANDATORY como contrato: documenta y verifica en tiempo de ejecución que
// este método solo se llama dentro de una unidad de trabajo mayor.
@Service
public class ReservaStockService {
@Transactional(propagation = Propagation.MANDATORY)
public void reservar(Long productoId, int cantidad) {
// Si alguien llama a esto sin transacción, falla inmediatamente en vez
// de reservar stock en una transacción propia que nadie va a deshacer.
}
}
// NEVER / NOT_SUPPORTED: proteger una llamada lenta
@Service
public class InformesService {
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public byte[] generarInformeAnual(int anio) {
// Consulta de 40 segundos que NO debe retener una transacción abierta
// ni bloquear el VACUUM de las tablas implicadas.
return jdbcClient.sql(SQL_INFORME).param("anio", anio)
.query(FilaInforme.class).stream()
.collect(aExcel());
}
}
8.4 Rollback: la regla que sorprende a todo el mundo
Por defecto, Spring solo deshace la transacción con RuntimeException y
Error. Si tu método lanza una excepción comprobada (que extiende
Exception pero no RuntimeException), la transacción se confirma con
los datos a medias. Nadie espera esto la primera vez.
// ✗ EL BUG
public class SaldoInsuficienteException extends Exception { } // COMPROBADA
@Transactional
public void transferir(Long origen, Long destino, BigDecimal importe)
throws SaldoInsuficienteException {
cuentaRepo.findById(origen).orElseThrow().retirar(importe); // se aplica
if (algo) throw new SaldoInsuficienteException(); // ✗ ¡COMMIT!
cuentaRepo.findById(destino).orElseThrow().ingresar(importe); // no se ejecuta
}
// Resultado: el dinero desaparece. Se retiró del origen y nunca llegó al destino,
// y la transacción se confirmó porque la excepción es comprobada.
// ✓ SOLUCIÓN 1 (la mejor): usa excepciones no comprobadas para los errores de negocio
public class SaldoInsuficienteException extends RuntimeException { }
// ✓ SOLUCIÓN 2: declara rollbackFor si no puedes cambiar la jerarquía
@Transactional(rollbackFor = Exception.class)
public void transferir(...) throws SaldoInsuficienteException { … }
// El OTRO error de rollback, más sutil: capturar y no relanzar
@Transactional
public void procesarLote(List<Long> ids) {
for (Long id : ids) {
try {
procesarUno(id); // @Transactional REQUIRED: MISMA transacción
} catch (Exception e) {
log.warn("Falló el pedido {}, sigo con el resto", id, e);
// ✗ La transacción ya está MARCADA para rollback por dentro.
}
}
}
// Al llegar al commit:
// org.springframework.transaction.UnexpectedRollbackException:
// Transaction silently rolled back because it has been marked as rollback-only
//
// Nada se ha guardado, y el error final no dice cuál fue la causa original.
// ✓ SOLUCIÓN: cada elemento en su propia transacción
@Service
@RequiredArgsConstructor
public class ProcesadorLote {
private final ProcesadorUno procesador; // otro bean
public ResultadoLote procesar(List<Long> ids) { // ← SIN @Transactional
List<Long> fallidos = new ArrayList<>();
for (Long id : ids) {
try {
procesador.procesar(id); // @Transactional propio: commit o rollback
} catch (Exception e) {
log.warn("Falló el pedido {}", id, e);
fallidos.add(id);
}
}
return new ResultadoLote(ids.size() - fallidos.size(), fallidos);
}
}
@Service
@RequiredArgsConstructor
public class ProcesadorUno {
@Transactional // una transacción por elemento
public void procesar(Long id) { … }
}
| Situación | ¿Rollback? | Cómo cambiarlo |
RuntimeException propagada | Sí | noRollbackFor |
Error propagado | Sí | — |
Exception comprobada propagada | No | rollbackFor = Exception.class |
| Excepción capturada y no relanzada, en el mismo método | No | — |
Excepción capturada, lanzada por un método REQUIRED anidado | Sí, y al confirmar salta UnexpectedRollbackException | Que el anidado sea REQUIRES_NEW, o extraerlo a su propia transacción |
Excepción capturada, lanzada por un método REQUIRES_NEW | Solo la interior | — |
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() | Sí | Marcado explícito sin excepción |
8.5 El proxy y la autoinvocación que no funciona
Cómo funciona @Transactional (repaso del módulo 04):
Spring crea un PROXY alrededor de tu bean. El proxy abre la transacción,
llama a tu método y confirma o deshace.
Quien llama ──► [ PROXY ] ──► instancia real
│
└─ begin / commit / rollback
CONSECUENCIA INEVITABLE: si el método se llama desde DENTRO de la misma
instancia (this.otroMetodo()), la llamada NO pasa por el proxy.
La anotación se ignora por completo, en silencio.
// ✗ EL BUG DE LA AUTOINVOCACIÓN
@Service
public class PedidoService {
public void procesarTodos(List<Long> ids) { // sin transacción
ids.forEach(this::procesarUno); // ← this: NO pasa por el proxy
}
@Transactional // ← esta anotación NO se aplica
public void procesarUno(Long id) {
Pedido p = repo.findById(id).orElseThrow();
p.confirmar(); // ¿se guarda? Depende de si el repositorio abre su propia
} // transacción. El resultado es impredecible y silencioso.
}
// Cómo detectarlo en tiempo de ejecución:
// TransactionSynchronizationManager.isActualTransactionActive() → false
// El log de org.springframework.transaction: DEBUG no muestra "Creating new
// transaction" para procesarUno.
// ✓ SOLUCIÓN 1 (la mejor): extraer a otro bean. Diseño más claro, sin trucos.
@Service
@RequiredArgsConstructor
public class ProcesadorPedidos {
private final ProcesadorPedido uno; // inyectado = proxy
public void procesarTodos(List<Long> ids) { ids.forEach(uno::procesar); }
}
@Service
@RequiredArgsConstructor
public class ProcesadorPedido {
@Transactional
public void procesar(Long id) { … }
}
// ✓ SOLUCIÓN 2: autoinyección. Funciona y es explícita, aunque incomoda a algunos.
@Service
public class PedidoService {
@Autowired @Lazy private PedidoService self; // @Lazy evita el ciclo
public void procesarTodos(List<Long> ids) { ids.forEach(self::procesarUno); }
@Transactional public void procesarUno(Long id) { … }
}
// ✓ SOLUCIÓN 3: TransactionTemplate. Explícita, sin proxies y muy testeable.
@Service
@RequiredArgsConstructor
public class PedidoService {
private final TransactionTemplate tx;
public void procesarTodos(List<Long> ids) {
for (Long id : ids) {
tx.executeWithoutResult(estado -> {
repo.findById(id).orElseThrow().confirmar();
});
}
}
}
// ✗ SOLUCIÓN 4 que NO deberías usar: modo AspectJ
// spring.aop.proxy-target-class + @EnableTransactionManagement(mode = ASPECTJ)
// Hace que la autoinvocación funcione tejiendo el bytecode, pero añade una
// complejidad de compilación enorme para arreglar algo que se arregla con
// un refactor de dos minutos.
| Qué NO intercepta el proxy | Efecto |
Métodos private | La anotación se ignora (con proxies JDK, ni siquiera se puede) |
Métodos final | Con proxies CGLIB no se pueden sobrescribir: se ignora |
Métodos static | Se ignora |
Llamadas con this.metodo() | Se ignora |
Clases final | CGLIB no puede crear la subclase: falla al arrancar |
Objetos creados con new | No son beans: nada se aplica |
| Hilos nuevos lanzados dentro del método | La transacción está atada al hilo original: el hilo nuevo no la tiene |
Cómo hacer que estos errores no vuelvan a pasar: activa
logging.level.org.springframework.transaction.interceptor=TRACE en desarrollo. Verás una línea por
cada transacción creada, unida o suspendida, con el nombre del método. Si un método que crees transaccional no
aparece, ya tienes la respuesta. Y en CI, un test de ArchUnit puede prohibir @Transactional en
métodos privados y en controladores.
8.6 TransactionTemplate: control programático
@Service
@RequiredArgsConstructor
public class OperacionesAvanzadas {
private final PlatformTransactionManager gestor;
private final PedidoRepository repo;
// Plantilla configurada como bean, reutilizable
@Bean
TransactionTemplate txCorta(PlatformTransactionManager gestor) {
var t = new TransactionTemplate(gestor);
t.setTimeout(3);
t.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
t.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return t;
}
// CASO 1 · La transacción es una PARTE del método, no todo el método.
// Este es el patrón correcto para "llamar a un servicio externo y guardar".
public void confirmarConPasarela(String referencia) {
// 1. Leer en una transacción corta
DatosCobro datos = new TransactionTemplate(gestor)
.execute(estado -> DatosCobro.desde(
repo.findByReferencia(referencia).orElseThrow()));
// 2. Llamada HTTP FUERA de cualquier transacción (puede tardar 3 s)
ResultadoCobro resultado = pasarela.cobrar(datos);
// 3. Escribir en otra transacción corta
new TransactionTemplate(gestor).executeWithoutResult(estado ->
repo.findByReferencia(referencia).orElseThrow()
.registrarCobro(resultado));
}
// CASO 2 · Rollback explícito sin lanzar una excepción
public boolean intentarReservar(Long productoId, int cantidad) {
return Boolean.TRUE.equals(new TransactionTemplate(gestor).execute(estado -> {
var producto = productoRepo.findById(productoId).orElseThrow();
if (!producto.puedeReservar(cantidad)) {
estado.setRollbackOnly(); // deshace sin excepción
return false;
}
producto.reservar(cantidad);
return true;
}));
}
// CASO 3 · Bucle con transacción por elemento, sin necesitar otro bean
public void procesarTodos(List<Long> ids) {
var tx = new TransactionTemplate(gestor);
for (Long id : ids) {
try { tx.executeWithoutResult(e -> procesarUno(id)); }
catch (Exception ex) { log.warn("Falló {}", id, ex); }
}
}
}
Cuándo preferir TransactionTemplate a @Transactional: cuando el límite de la
transacción no coincide con el límite del método; cuando necesitas rollback
condicional sin excepción; cuando estás en un bucle y no quieres crear otro bean; y cuando el código
está en un sitio que el proxy no alcanza (un @Scheduled que llama a métodos propios, un
listener de mensajes, un CommandLineRunner). No es un mecanismo de segunda: es la
herramienta correcta para esos casos.
8.7 Eventos transaccionales
// EL PROBLEMA: publicar un evento dentro de una transacción que puede fallar.
@Transactional
public void crear(CrearPedidoCmd cmd) {
Pedido pedido = repo.save(Pedido.nuevo(...));
eventos.publishEvent(new PedidoCreado(pedido.getReferencia()));
// ✗ Si el listener es síncrono, se ejecuta AHORA, antes del commit.
// Si envía un correo y luego la transacción falla, el cliente recibe
// la confirmación de un pedido que no existe.
}
// LA SOLUCIÓN: @TransactionalEventListener, que escucha FASES de la transacción
@Component
@RequiredArgsConstructor
public class NotificadorPedidos {
private final ServicioCorreo correo;
private final ProductorKafka kafka;
// AFTER_COMMIT (el defecto): solo si la transacción se confirmó.
// Es la fase que quieres el 90 % de las veces.
@TransactionalEventListener
public void alConfirmar(PedidoCreado evento) {
correo.enviarConfirmacion(evento.referencia());
}
// BEFORE_COMMIT: dentro de la transacción, justo antes del commit.
// Si lanza, la transacción se deshace. Útil para validaciones finales.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void validarAntesDeConfirmar(PedidoCreado evento) { … }
// AFTER_ROLLBACK: para métricas o alertas de fallos
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void alFallar(PedidoCreado evento) {
metricas.contador("pedido.creacion.fallida").increment();
}
// AFTER_COMPLETION: siempre, confirmada o no
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void alTerminar(PedidoCreado evento) { … }
// AFTER_COMMIT + @Async: el listener no bloquea la respuesta HTTP.
// OJO: en un hilo nuevo NO hay transacción ni EntityManager, así que el
// evento debe llevar DATOS (ids, valores), nunca entidades.
@Async
@TransactionalEventListener
public void publicarEnKafka(PedidoCreado evento) {
kafka.enviar("pedidos", evento.referencia(), evento);
}
}
Tres trampas de AFTER_COMMIT. (1) La transacción ya está cerrada: si
intentas escribir en la base de datos desde el listener sin abrir una nueva, no se guardará nada (o
fallará). Usa @Transactional(propagation = REQUIRES_NEW) en el listener. (2) El
evento no debe llevar entidades: estarán separadas y cualquier relación perezosa lanzará
LazyInitializationException. Lleva ids y valores primitivos. (3) Si el
listener falla, la transacción ya se confirmó: no hay vuelta atrás. Para operaciones que
deben ocurrir (publicar en Kafka, llamar a otro servicio), AFTER_COMMIT no es suficiente:
necesitas el patrón outbox (sección 8.10).
// TransactionSynchronization: el mecanismo de bajo nivel. Útil para limpiar
// recursos o ejecutar algo justo tras el commit sin crear un evento.
@Service
@RequiredArgsConstructor
public class ServicioFicheros {
@Transactional
public void guardarConAdjunto(Long pedidoId, Path temporal) {
var doc = repo.save(new Documento(pedidoId, temporal.getFileName().toString()));
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() {
// Solo subimos el fichero si la fila se guardó de verdad
almacen.subir(doc.getClaveAlmacen(), temporal);
}
@Override public void afterCompletion(int estado) {
// Se ejecute o no el commit, borramos el temporal
try { Files.deleteIfExists(temporal); }
catch (IOException e) { log.warn("No pude borrar {}", temporal, e); }
}
});
}
}
8.8 Llamadas remotas dentro de una transacción
Nunca hagas una llamada de red dentro de una transacción de base de datos. Motivos concretos:
- Retienes una conexión del pool durante toda la llamada. Con un pool de 10 y una pasarela
que tarda 2 s, tu techo es 5 peticiones por segundo. La base de datos está ociosa y tu servicio parece
colapsado.
- Retienes los bloqueos que hayas tomado. Otras transacciones esperan a que un servicio
externo responda.
- Bloqueas el
VACUUM de PostgreSQL: la transacción abierta impide limpiar
versiones muertas en toda la base de datos, lo que causa bloat y degradación general.
- No es atómico de todas formas. Si el
commit falla tras cobrar, has cobrado y
no lo has registrado. La transacción de base de datos no cubre la llamada externa: te da una falsa sensación
de seguridad.
- El timeout se descontrola. Si el servicio externo se cuelga 30 s y no tienes
timeout, tienes conexiones ocupadas 30 s cada una.
// ✗ MAL
@Transactional
public void confirmar(String referencia) {
Pedido p = repo.findByReferencia(referencia).orElseThrow();
var resultado = pasarela.cobrar(p.getTotal()); // ← 2 s con la conexión ocupada
p.marcarPagado(resultado.id());
almacen.notificarEnvio(p); // ← otro segundo más
}
// ✓ BIEN: leer, llamar fuera, escribir. Tres pasos, dos transacciones cortas.
@Service
@RequiredArgsConstructor
public class ConfirmacionService {
private final PedidoLectura lectura; // @Transactional(readOnly = true)
private final PedidoEscritura escritura; // @Transactional
private final PasarelaPago pasarela;
public void confirmar(String referencia) { // ← SIN @Transactional
DatosCobro datos = lectura.datosParaCobro(referencia); // tx 1: ~2 ms
ResultadoCobro resultado;
try {
resultado = pasarela.cobrar(datos); // sin transacción: 2 s
} catch (PasarelaException e) {
escritura.marcarFalloDePago(referencia, e.getMessage()); // tx 2a
throw new CobroRechazadoException(referencia, e);
}
escritura.registrarCobro(referencia, resultado); // tx 2b: ~3 ms
// El envío se dispara por evento tras el commit, o mejor, por outbox
}
}
Y para la parte difícil (¿qué pasa si el cobro sale bien y el registrarCobro falla?): haz la
operación externa idempotente con una clave de idempotencia y guarda una fila de intención
antes de llamar. Así, al reintentar, la pasarela reconoce la clave y no cobra dos veces, y tu proceso
de reconciliación puede completar el registro. Esto es el patrón outbox y el mismo razonamiento de las
sagas del módulo 08: cuando cruzas el límite de tu base de datos, la atomicidad se sustituye por
idempotencia más reintentos.
8.9 Transacciones largas y el daño que hacen
Qué se rompe con una transacción abierta durante minutos en PostgreSQL:
1. CONEXIÓN OCUPADA. El pool es pequeño (10-20). Cinco transacciones largas
y el resto del servicio no puede trabajar.
2. VACUUM BLOQUEADO. PostgreSQL no puede limpiar ninguna versión de fila más
nueva que la transacción más antigua ABIERTA, EN TODA LA BASE DE DATOS.
Tu informe de 40 minutos hace crecer tablas que no tienen nada que ver.
Esto se ve en pg_stat_activity con now() - xact_start.
3. BLOQUEOS RETENIDOS. Todo lo que hayas actualizado sigue bloqueado hasta
el commit. Otras peticiones esperan y acumulan timeouts.
4. WAL ENORME. Una transacción que escribe 10 millones de filas genera
gigabytes de WAL que hay que archivar y replicar de golpe.
5. RIESGO TOTAL. Un fallo en el minuto 39 lo pierde todo.
Cómo detectarlas (sección 14):
select pid, now() - xact_start as duracion, state, left(query, 60)
from pg_stat_activity
where xact_start is not null
and now() - xact_start > interval '30 seconds'
order by xact_start;
# Defensas en capas contra las transacciones largas
spring:
transaction:
default-timeout: 10 # segundos, para TODA la aplicación
datasource:
hikari:
leak-detection-threshold: 20000 # avisa con traza si no se devuelve
# Y en la base de datos, que es la defensa que no depende de tu código:
# ALTER ROLE app_tienda SET statement_timeout = '10s';
# ALTER ROLE app_tienda SET idle_in_transaction_session_timeout = '30s';
# ALTER ROLE app_tienda SET lock_timeout = '3s';
8.10 LazyInitializationException: causa real y soluciones
org.hibernate.LazyInitializationException:
could not initialize proxy [com.ejemplo.tienda.Cliente#42] - no Session
Traducción literal: "me pides datos de una relación perezosa, pero la sesión
que la tenía que cargar ya está cerrada".
La secuencia exacta:
1. @Transactional termina → commit → el EntityManager se CIERRA.
2. Todas las entidades pasan a estado SEPARADO.
3. Sus relaciones perezosas son proxies sin datos y sin sesión.
4. Alguien (tu código, o Jackson al serializar) toca uno de esos proxies.
5. El proxy intenta consultar, no tiene sesión, y lanza.
Dónde ocurre en la práctica:
· En el controlador, tras llamar al servicio.
· Al serializar a JSON (el caso más frecuente, y el más confuso porque la
traza apunta a Jackson y no a tu código).
· En una plantilla Thymeleaf.
· En un @Async o en un hilo nuevo.
· En un @TransactionalEventListener(AFTER_COMMIT).
· En un test sin @Transactional.
La solución incorrecta: open-in-view
spring:
jpa:
open-in-view: true # ✗ el valor por DEFECTO en Spring Boot. Cámbialo.
Por qué spring.jpa.open-in-view debe ser false. Ese ajuste mantiene el
EntityManager abierto durante
toda la petición HTTP, incluida la serialización.
Elimina la excepción, sí, pero a cambio de cinco problemas peores:
- Convierte la serialización en un generador de consultas. Jackson recorre el grafo y cada
relación perezosa que toca dispara un
SELECT. Es un N+1 que ocurre después de tu código,
donde no lo ves ni lo puedes controlar.
- Retiene la conexión de base de datos durante toda la petición, incluido el tiempo de
escribir la respuesta en un socket lento. Tu pool se agota por clientes con mala red.
- Oculta el problema de diseño. El error te estaba diciendo «no has cargado lo que
necesitas»; silenciarlo no lo arregla, lo esconde hasta producción.
- Las consultas se ejecutan fuera de transacción, en modo autocommit: cada una es
su propia transacción, así que la respuesta puede mezclar datos de instantes distintos.
- Hace imposible razonar sobre el rendimiento. El mismo endpoint hace 3 o 40 consultas
según qué campos serialice el DTO ese día.
Spring Boot lo deja activado por defecto
y avisa en el log de arranque
(
spring.jpa.open-in-view is enabled by default) precisamente porque los mantenedores saben que es
problemático pero no pueden cambiar el defecto sin romper aplicaciones existentes.
// SOLUCIÓN 1 · Cargar lo que necesitas en la consulta. La más directa.
@EntityGraph(attributePaths = {"cliente", "lineas", "lineas.producto"})
Optional<Pedido> findWithDetailByReferencia(String referencia);
// SOLUCIÓN 2 · Mapear a DTO DENTRO de la transacción. La mejor de todas.
@Transactional(readOnly = true)
public DetallePedido detalle(String referencia) {
Pedido p = repo.findWithDetailByReferencia(referencia).orElseThrow();
return DetallePedido.desde(p); // el mapeo ocurre con la sesión abierta
}
// Ventaja añadida: la API deja de exponer entidades (sección 6.9).
// SOLUCIÓN 3 · Proyección directa: nunca hay entidades, así que no hay problema.
@Query("select new com.ejemplo.tienda.pedidos.DetallePedido(...) from Pedido p ...")
Optional<DetallePedido> detalle(String referencia);
// SOLUCIÓN 4 · Inicialización explícita, cuando el grafo depende de una condición
@Transactional(readOnly = true)
public DetallePedido detalleCondicional(String referencia, boolean conLineas) {
Pedido p = repo.findByReferencia(referencia).orElseThrow();
if (conLineas) Hibernate.initialize(p.getLineas());
return DetallePedido.desde(p);
}
// ✗ SOLUCIÓN QUE NO ES SOLUCIÓN: poner EAGER.
// Arregla este endpoint y empeora los otros veinte (sección 4.10).
8.11 Transacciones distribuidas y el patrón outbox
Tarde o temprano alguien pregunta: «¿y si necesito guardar en la base de datos y publicar en Kafka de forma
atómica?». La respuesta corta es que no puedes, y que XA/2PC no es la solución que buscas.
| Opción | Cómo funciona | Por qué no (o sí) |
XA / 2PC (JtaTransactionManager) |
Un coordinador prepara y confirma en todos los recursos. |
Evítalo. Necesitas un coordinador con estado y recuperación, el rendimiento cae, los bloqueos se alargan a la fase de preparación, y si el coordinador se cae en medio quedan transacciones «colgadas» que hay que resolver a mano. Kafka además no es un recurso XA. |
Publicar tras el commit (AFTER_COMMIT) |
Se confirma en la base de datos y luego se publica. |
Simple y suficiente si puedes permitirte perder eventos: si el proceso muere entre el commit y la publicación, el evento se pierde para siempre y nadie se enterará. |
| Patrón outbox |
El evento se guarda como una fila en la misma transacción; un proceso aparte lo publica y lo marca como enviado. |
La respuesta correcta. Atomicidad real (una sola transacción de base de datos) y entrega garantizada «al menos una vez». |
| CDC (Debezium leyendo el WAL) |
Un conector lee el registro de escritura de PostgreSQL y publica los cambios. |
Excelente a gran escala y sin tocar la aplicación, pero añade una pieza de infraestructura importante. |
-- La tabla outbox: nada exótico, solo una cola en la base de datos
create table outbox (
id bigint generated by default as identity primary key,
tipo_agregado varchar(60) not null, -- 'pedido'
id_agregado varchar(64) not null, -- 'PED-2026-0001'
tipo_evento varchar(80) not null, -- 'PedidoConfirmado'
payload jsonb not null,
creado_en timestamptz not null default now(),
publicado_en timestamptz, -- null = pendiente
intentos integer not null default 0,
ultimo_error text
);
-- Índice parcial: solo indexa lo pendiente. Diminuto y siempre en caché.
create index ix_outbox_pendiente on outbox (creado_en)
where publicado_en is null;
// 1) La escritura del evento va en la MISMA transacción que el cambio de negocio
@Service
@RequiredArgsConstructor
public class PedidoService {
private final PedidoRepository repo;
private final OutboxRepository outbox;
private final ObjectMapper json;
@Transactional
public void confirmar(String referencia) {
Pedido pedido = repo.findByReferencia(referencia).orElseThrow();
pedido.confirmar();
// Misma transacción: o se confirma el pedido Y se registra el evento,
// o no ocurre ninguna de las dos cosas. Atomicidad real, sin XA.
outbox.save(EventoOutbox.de("pedido", referencia, "PedidoConfirmado",
json.valueToTree(new PedidoConfirmado(referencia, pedido.getTotal()))));
}
}
// 2) Un publicador aparte drena la tabla. SKIP LOCKED permite varias instancias.
@Component
@RequiredArgsConstructor
public class PublicadorOutbox {
private final OutboxRepository outbox;
private final ProductorKafka kafka;
@Scheduled(fixedDelay = 500)
@Transactional
public void publicarPendientes() {
for (EventoOutbox e : outbox.tomarPendientes(100)) {
try {
// Clave de mensaje = id del agregado: garantiza el orden por pedido
kafka.enviar("pedidos", e.getIdAgregado(), e.getPayload());
e.marcarPublicado();
} catch (Exception ex) {
e.registrarFallo(ex.getMessage()); // reintento con contador
log.warn("Fallo publicando outbox {}", e.getId(), ex);
}
}
}
}
public interface OutboxRepository extends Repository<EventoOutbox, Long> {
// for update skip locked: varias instancias trabajan en paralelo sin
// pisarse y sin esperarse. Es la clave de que el patrón escale.
@Query(value = """
select * from outbox
where publicado_en is null
and intentos < 10
order by creado_en
limit :limite
for update skip locked
""", nativeQuery = true)
List<EventoOutbox> tomarPendientes(int limite);
}
Consecuencia que hay que aceptar: el outbox garantiza entrega al menos una
vez, no exactamente una vez. Si el publicador se cae después de enviar a Kafka y antes de marcar la
fila, el evento se publicará dos veces. Por eso los consumidores tienen que ser
idempotentes: incluye un identificador de evento y que el consumidor lleve registro de los ya
procesados. Perseguir «exactamente una vez» en sistemas distribuidos es perseguir un fantasma; la solución
práctica es «al menos una vez» más idempotencia. Más detalle en el módulo 08.
Las doce reglas de las transacciones
@Transactional va en el servicio de aplicación: un método, un caso de uso, una unidad de trabajo.
@Transactional(readOnly = true) a nivel de clase y escritura solo en los métodos que escriben.
- Por defecto solo hay rollback con
RuntimeException. Usa excepciones no comprobadas para los errores de negocio.
- Capturar la excepción de un método
REQUIRED anidado provoca UnexpectedRollbackException en el commit.
- La autoinvocación no funciona: extrae a otro bean o usa
TransactionTemplate.
- Nada de
@Transactional en métodos private, final, static ni en controladores.
REQUIRES_NEW consume una segunda conexión: nunca dentro de un bucle.
- Ninguna llamada de red dentro de una transacción. Lee, llama fuera, escribe.
spring.jpa.open-in-view = false, y las relaciones se cargan explícitamente o se mapea a DTO dentro de la transacción.
- Timeouts en cadena y coherentes: base de datos < petición < cliente.
- Transacciones cortas: retienen conexiones, bloqueos y el
VACUUM de toda la base de datos.
- Para atomicidad con sistemas externos, outbox más consumidores idempotentes, no XA.
9 · Concurrencia y bloqueos
Todo lo anterior asume un usuario. Con dos usuarios editando lo mismo, aparece una clase de errores que no
reproduces en local, que no salen en los tests y que corrompen datos en silencio. Esta sección es la caja de
herramientas para evitarlos, con el criterio de cuándo usar cada una.
9.1 La actualización perdida (lost update)
Dos usuarios editan el mismo pedido. Nivel de aislamiento READ COMMITTED,
el de PostgreSQL por defecto. Nada exótico: esto pasa todos los días.
t Usuario A Usuario B
── ───────────────────────────────── ─────────────────────────────────
1 BEGIN
2 select * from pedido where id=42
→ total=100, direccion='Calle 1'
3 BEGIN
4 select * from pedido where id=42
→ total=100, direccion='Calle 1'
5 (el usuario edita la dirección)
6 (el usuario edita el total)
7 update pedido set total=100,
direccion='Calle 2' where id=42
8 COMMIT
9 update pedido set total=150,
direccion='Calle 1' where id=42
10 COMMIT
RESULTADO: direccion='Calle 1'. El cambio de A ha DESAPARECIDO.
Y nadie se ha dado cuenta: no hubo error, no hubo bloqueo, no hubo log.
El usuario A verá su cambio revertido y pensará que la aplicación "pierde
datos a veces". Fíjate en que el problema lo agrava JPA: al actualizar TODAS
las columnas (no solo las modificadas), B sobrescribe la dirección que ni
siquiera tocó.
Ningún nivel de aislamiento por debajo de REPEATABLE READ evita esto, porque
las dos transacciones son perfectamente legales por separado. Hace falta un
mecanismo explícito.
9.2 Bloqueo optimista con @Version
@Entity
@Table(name = "pedido")
public class Pedido {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pedido_gen")
private Long id;
@Version // eso es todo. Hibernate hace el resto.
private long version; // long primitivo o Long; también sirve
// Instant/Timestamp, pero es menos fiable
// (dos cambios en el mismo milisegundo)
// …
}
Qué hace exactamente Hibernate con @Version:
1. Al cargar la entidad, guarda la versión leída (por ejemplo, 7).
2. Al actualizar, AÑADE la versión a la cláusula WHERE e incrementa:
update pedido
set total = ?, direccion = ?, version = 8
where id = 42
and version = 7 ← la comprobación está aquí
3. Comprueba las FILAS AFECTADAS que devuelve el driver:
· 1 fila → nadie más lo tocó. Correcto.
· 0 filas → otro lo modificó (o lo borró) desde que lo leí.
→ lanza OptimisticLockException
Repitiendo el escenario anterior con @Version:
t Usuario A Usuario B
── ───────────────────────────────── ─────────────────────────────────
2 lee pedido 42, version=7
4 lee pedido 42, version=7
7 update ... version=8 where
id=42 and version=7 → 1 fila
8 COMMIT
9 update ... version=8 where
id=42 and version=7 → 0 FILAS
10 → OptimisticLockException, ROLLBACK
Ahora el conflicto es VISIBLE. Ningún cambio se pierde en silencio.
Coste: cero bloqueos, cero espera, una columna de 8 bytes. Por eso es la
opción por defecto: solo penaliza cuando de verdad hay conflicto.
// Manejo del conflicto en la capa web: 409 Conflict con un mensaje útil
@RestControllerAdvice
public class ManejadorConflictos {
// Spring traduce OptimisticLockException a esta excepción de su jerarquía
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
ProblemDetail conflicto(ObjectOptimisticLockingFailureException e) {
var pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Otro usuario ha modificado este recurso mientras lo editabas. "
+ "Recarga la página y vuelve a intentarlo.");
pd.setTitle("Conflicto de concurrencia");
pd.setType(URI.create("https://api.ejemplo.com/errores/conflicto"));
pd.setProperty("recurso", e.getPersistentClassName());
pd.setProperty("identificador", String.valueOf(e.getIdentifier()));
return pd;
}
}
Exponer la versión en la API: el ciclo completo
// El cliente necesita saber qué versión está editando. Dos formas: en el cuerpo
// o —mejor, porque es lo que dice HTTP— con ETag e If-Match.
// FORMA 1 · La versión en el DTO
public record PedidoDto(String referencia, EstadoPedido estado,
BigDecimal total, DireccionDto envio,
long version) { } // ← el cliente la devuelve
@PutMapping("/pedidos/{referencia}")
public PedidoDto actualizar(@PathVariable String referencia,
@Valid @RequestBody ActualizarPedidoDto dto) {
return servicio.actualizar(referencia, dto);
}
@Transactional
public PedidoDto actualizar(String referencia, ActualizarPedidoDto dto) {
Pedido pedido = repo.findByReferencia(referencia).orElseThrow();
// Comprobación EXPLÍCITA: detecta el conflicto antes de hacer nada.
// Sin esto, el conflicto solo se detectaría si el otro usuario escribe
// entre nuestra lectura y nuestro commit (ventana muy estrecha); con esto
// se detecta también el caso "leíste hace 10 minutos y alguien ya guardó".
if (pedido.getVersion() != dto.version()) {
throw new ObjectOptimisticLockingFailureException(Pedido.class, referencia);
}
pedido.cambiarDireccionEnvio(dto.envio().aDominio());
return PedidoDto.desde(pedido);
}
// FORMA 2 (la correcta según HTTP) · ETag + If-Match
@GetMapping("/pedidos/{referencia}")
public ResponseEntity<PedidoDto> obtener(@PathVariable String referencia) {
PedidoDto dto = servicio.detalle(referencia);
return ResponseEntity.ok()
.eTag("\"" + dto.version() + "\"") // ETag = versión
.body(dto);
}
@PutMapping("/pedidos/{referencia}")
public ResponseEntity<PedidoDto> actualizar(
@PathVariable String referencia,
@RequestHeader(HttpHeaders.IF_MATCH) String ifMatch,
@Valid @RequestBody ActualizarPedidoDto dto) {
long version = Long.parseLong(ifMatch.replace("\"", ""));
// Si no coincide: 412 Precondition Failed, que es lo semánticamente correcto
return ResponseEntity.ok(servicio.actualizar(referencia, version, dto));
}
Con If-Match obligatorio en los PUT y PATCH, la actualización perdida
deja de ser posible por diseño. El cliente no puede escribir sin decir qué versión leyó, y el servidor
rechaza cualquier escritura basada en datos obsoletos. Es la misma idea que @Version, elevada al
protocolo, y además es cacheable y estándar.
Reintentos automáticos
// Para operaciones IDEMPOTENTES y con conflicto poco frecuente, reintentar es
// mejor que devolver un error al usuario.
@Service
@RequiredArgsConstructor
public class ContadorService {
private final EstadisticaRepository repo;
// CLAVE: el @Retryable va FUERA del @Transactional. Reintentar dentro de
// la misma transacción es inútil: ya está marcada para rollback.
@Retryable(retryFor = { ObjectOptimisticLockingFailureException.class,
CannotAcquireLockException.class },
maxAttempts = 4,
backoff = @Backoff(delay = 50, multiplier = 2, random = true))
public void incrementarVisitas(Long productoId) {
transaccional.incrementar(productoId); // otro bean con @Transactional
}
@Recover
public void agotadosLosIntentos(ObjectOptimisticLockingFailureException e, Long id) {
log.error("No se pudo incrementar visitas de {} tras 4 intentos", id, e);
metricas.contador("visitas.conflicto.irrecuperable").increment();
}
}
// Reintento a mano, sin Spring Retry, cuando quieres control total
public <T> T conReintentos(int maximo, Supplier<T> operacion) {
RuntimeException ultima = null;
for (int intento = 1; intento <= maximo; intento++) {
try {
return operacion.get(); // cada llamada abre su propia transacción
} catch (ObjectOptimisticLockingFailureException | CannotAcquireLockException e) {
ultima = e;
// Espera con retardo exponencial y algo de aleatoriedad para que
// dos hilos en conflicto no vuelvan a chocar en el mismo instante
long espera = (long) (Math.pow(2, intento) * 10 * (0.5 + Math.random()));
try { Thread.sleep(espera); }
catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; }
}
}
throw ultima;
}
No reintentes operaciones no idempotentes. Reintentar «incrementar el contador de visitas» es seguro.
Reintentar «cobrar 50 €» no lo es, porque si el fallo ocurrió después de cobrar, cobrarás dos
veces. Antes de poner un @Retryable, pregúntate: «si esto se ejecuta dos veces, ¿el resultado es el
mismo?». Si la respuesta es no, necesitas una clave de idempotencia (sección 9.6), no un reintento.
Casos especiales de @Version
// 1) @Version NO se incrementa al cambiar una colección con mappedBy, porque
// el cambio está en la tabla hija. Si quieres que añadir una línea cuente
// como una modificación del pedido (normalmente sí), fuérzalo:
@Transactional
public void anadirLinea(String referencia, Long productoId, int cantidad) {
Pedido pedido = repo.findByReferencia(referencia).orElseThrow();
pedido.anadirLinea(productoRepo.getReferenceById(productoId), cantidad);
em.lock(pedido, LockModeType.OPTIMISTIC_FORCE_INCREMENT); // ← sube la versión
}
// 2) Comprobar la versión de una entidad que solo LEES (integridad del agregado):
// "he decidido según el stock del producto; si alguien lo cambia antes de mi
// commit, mi decisión ya no vale".
em.lock(producto, LockModeType.OPTIMISTIC); // comprueba la versión en el commit
// 3) @Modifying NO incrementa la versión: el bloqueo optimista se salta.
// Si usas actualizaciones masivas sobre entidades versionadas, hazlo explícito:
@Modifying
@Query("update Pedido p set p.estado = :nuevo, p.version = p.version + 1 "
+ " where p.estado = :viejo")
int caducar(EstadoPedido viejo, EstadoPedido nuevo);
LockModeType | Qué hace | SQL |
OPTIMISTIC | Comprueba en el commit que la versión de una entidad leída no ha cambiado. | select version from … where id = ? al confirmar |
OPTIMISTIC_FORCE_INCREMENT | Incrementa la versión aunque la entidad no cambie. | update … set version = version + 1 |
PESSIMISTIC_READ | Bloqueo compartido: otros pueden leer, nadie puede escribir. | for share |
PESSIMISTIC_WRITE | Bloqueo exclusivo: nadie más puede leer con bloqueo ni escribir. | for update |
PESSIMISTIC_FORCE_INCREMENT | Bloqueo exclusivo e incremento de versión. | for update + update version |
NONE | Sin bloqueo (el defecto). | — |
9.3 Bloqueo pesimista
public interface ProductoRepository extends Repository<Producto, Long> {
// select ... for update: bloquea la fila hasta el fin de la transacción
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Producto p where p.id = :id")
Optional<Producto> findByIdBloqueado(@Param("id") Long id);
// Con timeout: mejor fallar en 3 s que esperar indefinidamente
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select p from Producto p where p.sku = :sku")
Optional<Producto> findBySkuBloqueado(@Param("sku") String sku);
// Bloqueo compartido: "nadie cambie esto mientras lo uso para decidir"
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select p from Producto p where p.categoria.id = :categoriaId")
List<Producto> findPorCategoriaBloqueados(@Param("categoriaId") Long categoriaId);
}
// Uso: la sección crítica debe ser lo MÁS CORTA POSIBLE
@Transactional(timeout = 5)
public void reservarStock(Long productoId, int unidades) {
Producto p = repo.findByIdBloqueado(productoId) // select … for update
.orElseThrow(() -> new ProductoNoEncontradoException(productoId));
p.reservar(unidades); // valida y resta
// El bloqueo se libera en el commit. Nada de llamadas externas aquí dentro.
}
| Criterio | Optimista (@Version) | Pesimista (FOR UPDATE) |
| Cuándo detecta el conflicto | Al escribir (tarde) | Al leer (pronto) |
| Espera | Ninguna | Sí: los demás se encolan |
| Coste sin conflictos | Prácticamente nulo | Real: bloqueos y serialización |
| Coste con conflictos | Se pierde el trabajo y hay que reintentar | Solo se espera |
| Riesgo de deadlock | No | Sí |
| Funciona entre peticiones HTTP | Sí (la versión viaja al cliente) | No: el bloqueo muere con la transacción |
| Escala | Muy bien | Mal si la contención es alta |
| Uso típico | Edición por usuarios, formularios, APIs REST | Stock, asientos, saldos, colas de trabajo |
Y la tercera opción, que a menudo es la mejor: ninguna de las dos. Una sola sentencia SQL atómica con la
condición dentro no necesita bloqueos ni reintentos, porque la base de datos garantiza la atomicidad de un
UPDATE por sí misma.
// La solución más simple y más rápida para el problema del stock
public interface ProductoRepository extends Repository<Producto, Long> {
@Modifying
@Query("""
update Producto p
set p.stock = p.stock - :unidades,
p.version = p.version + 1
where p.id = :id
and p.stock >= :unidades
""")
int intentarReservar(@Param("id") Long id, @Param("unidades") int unidades);
}
@Transactional
public void reservar(Long productoId, int unidades) {
int filas = repo.intentarReservar(productoId, unidades);
if (filas == 0) {
// 0 filas = no había stock suficiente (o no existe el producto).
// Sin bloqueos, sin reintentos, sin condiciones de carrera: una sentencia.
throw new StockInsuficienteException(productoId, unidades);
}
}
// Con 500 peticiones simultáneas sobre el mismo producto: 500 UPDATE, cada uno
// atómico. Las que no caben devuelven 0. Es lo más rápido y lo más simple.
SKIP LOCKED: una cola de trabajos sin dependencias
// El patrón para procesar tareas con varias instancias sin duplicados ni esperas
public interface TareaRepository extends Repository<Tarea, Long> {
@Query(value = """
select * from tarea
where estado = 'PENDIENTE'
and ejecutar_en <= now()
order by prioridad desc, ejecutar_en
limit :limite
for update skip locked
""", nativeQuery = true)
List<Tarea> tomar(int limite);
}
@Component
@RequiredArgsConstructor
public class ProcesadorTareas {
private final TareaRepository repo;
private final Ejecutor ejecutor;
@Scheduled(fixedDelay = 1000)
@Transactional
public void procesarTanda() {
for (Tarea t : repo.tomar(20)) {
t.marcarEnCurso(); // dentro de la transacción y del bloqueo
try {
ejecutor.ejecutar(t);
t.marcarCompletada();
} catch (Exception e) {
t.reintentarMasTarde(e.getMessage()); // retardo exponencial
}
}
}
}
POR QUÉ SKIP LOCKED es la pieza clave:
Sin él, con 4 instancias:
instancia 1: select … for update limit 20 → toma las tareas 1-20
instancia 2: select … for update limit 20 → ESPERA a la instancia 1,
y al desbloquearse ve que ya
no están PENDIENTE → 0 tareas
Resultado: las 4 instancias se serializan. Escalado horizontal inútil.
Con SKIP LOCKED:
instancia 1 → tareas 1-20 (bloqueadas)
instancia 2 → tareas 21-40 (se salta las bloqueadas, no espera)
instancia 3 → tareas 41-60
instancia 4 → tareas 61-80
Cuatro instancias trabajando en paralelo de verdad, sin coordinación,
sin Redis, sin Kafka, sin ZooKeeper. Solo PostgreSQL.
Detalle imprescindible: rescatar las tareas colgadas. Si una instancia muere
con tareas EN_CURSO, nadie las volverá a tomar. Añade una consulta periódica:
update tarea set estado = 'PENDIENTE', intentos = intentos + 1
where estado = 'EN_CURSO'
and actualizado_en < now() - interval '10 minutes';
9.4 Niveles de aislamiento y sus anomalías
| Nivel | Lectura sucia | Lectura no repetible | Lectura fantasma | Anomalía de serialización | En PostgreSQL |
READ_UNCOMMITTED | Posible por el estándar | Posible | Posible | Posible | Se comporta como READ COMMITTED: PostgreSQL no permite lecturas sucias nunca |
READ_COMMITTED | No | Posible | Posible | Posible | El defecto. Cada sentencia ve una instantánea nueva |
REPEATABLE_READ | No | No | No en PostgreSQL | Posible | Instantánea fija para toda la transacción. Puede fallar con could not serialize access (40001) |
SERIALIZABLE | No | No | No | No | SSI real. Puede fallar con 40001 y hay que reintentar |
// Subir el aislamiento en un método concreto
@Transactional(isolation = Isolation.REPEATABLE_READ, timeout = 10)
public InformeCierre cerrarDia(LocalDate dia) {
// Todas las consultas de este método ven EXACTAMENTE la misma instantánea:
// los totales cuadran aunque entren pedidos nuevos mientras se calcula.
var pedidos = repo.contarPorEstado(dia);
var facturacion = repo.facturacion(dia);
var devoluciones = repo.devoluciones(dia);
return new InformeCierre(pedidos, facturacion, devoluciones);
}
// Con READ_COMMITTED, cada consulta ve un instante distinto y el informe
// puede ser internamente incoherente: 100 pedidos y la facturación de 103.
Si subes el aislamiento, tienes que manejar el fallo. Con REPEATABLE_READ o
SERIALIZABLE, PostgreSQL puede abortar tu transacción con
could not serialize access due to concurrent update (SQLSTATE 40001). No es un bug: es el
contrato. El nivel te garantiza que no habrá anomalías, y el precio es que algunas transacciones se
abortan y hay que reintentarlas completas. En Spring eso llega como
CannotAcquireLockException o ConcurrencyFailureException, y el reintento va
fuera del @Transactional. Si no reintentas, has cambiado «datos incorrectos a veces» por
«errores 500 a veces», que no es mejor.
Sobre isolation y el pool: Spring aplica el nivel con setTransactionIsolation()
en la conexión al abrir la transacción, y HikariCP la restaura al devolverla al pool. Si usas
un pooler externo como PgBouncer en modo transaction, comprueba que esto funcione: es una
fuente clásica de sorpresas. Y recuerda que subir el aislamiento globalmente en el
DataSource afecta a todo el servicio; casi siempre es mejor hacerlo método a método.
9.5 Interbloqueos: cómo se producen y cómo evitarlos
Un deadlock necesita dos transacciones que bloqueen los mismos recursos en
ORDEN DISTINTO:
t Transacción A Transacción B
── ───────────────────────────────── ─────────────────────────────────
1 update producto set … where id=7
2 update producto set … where id=9
3 update producto set … where id=9 → espera a B
4 update producto set … where id=7 → espera a A
↓
ERROR: deadlock detected (SQLSTATE 40P01)
PostgreSQL mata a una de las dos (la "víctima").
En JPA los deadlocks aparecen sobre todo por tres vías, y ninguna es obvia:
1. Un bucle que actualiza entidades en el orden que trae una consulta,
y otra transacción las trae en otro orden (otro ORDER BY, otro filtro).
2. El ORDEN DEL FLUSH: Hibernate reordena las sentencias (sección 5.7).
Dos transacciones con las mismas operaciones en distinto orden de código
pueden acabar generando distinto orden de bloqueos.
3. Claves foráneas: un INSERT en linea_pedido toma un bloqueo compartido
sobre la fila de producto para comprobar la FK. Con actualizaciones
concurrentes de producto, aparecen deadlocks "imposibles".
// ✓ REGLA 1 · Orden canónico de bloqueo. Siempre el mismo, en todo el código.
@Transactional
public void transferirStock(Long origenId, Long destinoId, int unidades) {
// Ordenar por id ANTES de bloquear: así todas las transacciones del sistema
// toman los bloqueos en el mismo orden y el deadlock es imposible.
List<Long> ids = Stream.of(origenId, destinoId).sorted().toList();
Map<Long, Producto> bloqueados = repo.findAllByIdBloqueados(ids).stream()
.collect(toMap(Producto::getId, identity()));
bloqueados.get(origenId).retirar(unidades);
bloqueados.get(destinoId).ingresar(unidades);
}
public interface ProductoRepository extends Repository<Producto, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Producto p where p.id in :ids order by p.id")
List<Producto> findAllByIdBloqueados(@Param("ids") List<Long> ids);
}
// ✓ REGLA 2 · Ordena también los lotes
@Transactional
public void actualizarPrecios(Map<Long, BigDecimal> nuevosPrecios) {
nuevosPrecios.entrySet().stream()
.sorted(Map.Entry.comparingByKey()) // ← orden determinista
.forEach(e -> repo.findById(e.getKey()).orElseThrow()
.cambiarPrecio(e.getValue()));
}
// ✓ REGLA 3 · Transacciones cortas y lock_timeout: un deadlock que no llega a
// formarse es mejor que uno detectado.
@Transactional(timeout = 3)
public void operacionCritica() { … }
// ✓ REGLA 4 · Reintentar el deadlock: es un error TRANSITORIO y esperable
@Retryable(retryFor = { CannotAcquireLockException.class,
DeadlockLoserDataAccessException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 3, random = true))
public void conReintento() { transaccional.operacion(); }
-- Cómo diagnosticarlos en PostgreSQL
-- 1) Activar el registro de esperas: el log dirá QUÉ dos sentencias chocaron
alter system set log_lock_waits = on;
alter system set deadlock_timeout = '1s';
select pg_reload_conf();
-- 2) Ver bloqueos en vivo, con quién bloquea a quién
select bloqueada.pid as pid_bloqueado,
bloqueada.query as consulta_bloqueada,
bloqueante.pid as pid_bloqueante,
bloqueante.query as consulta_bloqueante,
now() - bloqueada.query_start as esperando_desde
from pg_stat_activity bloqueada
join pg_stat_activity bloqueante
on bloqueante.pid = any(pg_blocking_pids(bloqueada.pid))
where cardinality(pg_blocking_pids(bloqueada.pid)) > 0;
-- 3) En el log, el DETAIL del deadlock te da las dos sentencias exactas:
-- Process 4711 waits for ShareLock on transaction 9182; blocked by process 4712.
-- Process 4712 waits for ShareLock on transaction 9181; blocked by process 4711.
9.6 Idempotencia en escrituras
El cliente pulsa dos veces «Confirmar pedido». O la red se corta después de que el servidor haya procesado la
petición y el cliente reintenta. En los dos casos llegan dos peticiones idénticas y solo debe ocurrir una vez.
La solución no es JavaScript deshabilitando el botón: es una clave de idempotencia y una restricción única.
create table idempotencia (
clave varchar(128) primary key,
endpoint varchar(200) not null,
huella char(64) not null, -- sha256 del cuerpo: detecta reuso
estado_http integer,
respuesta jsonb,
creado_en timestamptz not null default now(),
completado_en timestamptz
);
-- Limpieza periódica: 24-48 h de retención suele ser suficiente
create index ix_idempotencia_creado on idempotencia (creado_en);
@Service
@RequiredArgsConstructor
public class ServicioIdempotente {
private final IdempotenciaRepository repo;
private final ObjectMapper json;
@Transactional
public <T> T ejecutar(String clave, String endpoint, Object cuerpo,
Class<T> tipo, Supplier<T> operacion) {
String huella = sha256(json.writeValueAsBytes(cuerpo));
// 1. ¿Ya la hemos visto? La restricción PRIMARY KEY es la garantía real:
// con dos peticiones simultáneas, una inserta y la otra falla.
Optional<Idempotencia> previa = repo.findById(clave);
if (previa.isPresent()) {
Idempotencia i = previa.get();
if (!i.getHuella().equals(huella)) {
// Misma clave, cuerpo distinto: el cliente ha reutilizado la clave
throw new ClaveIdempotenciaReutilizadaException(clave);
}
if (i.getCompletadoEn() == null) {
// En curso en otra petición: pide al cliente que reintente luego
throw new OperacionEnCursoException(clave);
}
return json.treeToValue(i.getRespuesta(), tipo); // respuesta cacheada
}
// 2. Reservar la clave. Si otra petición la reserva a la vez, salta
// DataIntegrityViolationException y la tratamos como duplicado.
try {
repo.saveAndFlush(Idempotencia.reservar(clave, endpoint, huella));
} catch (DataIntegrityViolationException e) {
throw new OperacionEnCursoException(clave);
}
// 3. Ejecutar y guardar la respuesta en la MISMA transacción
T resultado = operacion.get();
repo.findById(clave).orElseThrow()
.completar(200, json.valueToTree(resultado));
return resultado;
}
}
// En el controlador
@PostMapping("/pedidos")
public ResponseEntity<PedidoDto> crear(
@RequestHeader("Idempotency-Key") @NotBlank String clave,
@Valid @RequestBody CrearPedidoDto dto) {
PedidoDto creado = idempotente.ejecutar(clave, "POST /pedidos", dto,
PedidoDto.class, () -> servicio.crear(dto.aComando()));
return ResponseEntity.status(HttpStatus.CREATED).body(creado);
}
La versión de un hombre pobre, que a veces es suficiente: una restricción UNIQUE sobre una
clave natural de negocio. Si un pedido no puede repetirse para el mismo cliente, el mismo carrito y el mismo
minuto, pon un índice único sobre esas columnas y captura la violación traduciéndola a «ya existe». Sin tabla
extra, sin cabecera nueva, y con la garantía de la base de datos en lugar de la de tu código. La comprobación
previa con existsBy… no es suficiente: entre el SELECT y el
INSERT cabe otra petición.
10 · Cachés
Hay tres cachés distintas en juego y se confunden constantemente. Saber cuál estás usando y cuál es su alcance
es la diferencia entre una optimización y un incidente de datos obsoletos.
10.1 Las tres cachés y su alcance
| Caché | Alcance | Qué guarda | Se configura | Coherencia con varias instancias |
| Primer nivel (L1) |
Una transacción |
Entidades por id |
No: siempre activa |
Trivial: muere con la transacción |
| Segundo nivel (L2) |
Toda la SessionFactory (la JVM) |
Estado desmontado de entidades, colecciones y resultados de consulta |
Sí, y hay que activarla explícitamente |
Problema real: cada instancia tiene la suya |
De aplicación (@Cacheable) |
Lo que decida el proveedor (local o distribuida) |
El valor de retorno de un método (normalmente un DTO) |
Sí, con Spring Cache |
Resoluble con Redis o Hazelcast |
10.2 Caché de segundo nivel: cuándo tiene sentido
Antes de configurar nada, comprueba que tu caso cumple las cuatro condiciones. La caché L2 es una de las
optimizaciones peor aplicadas del ecosistema Java: se activa «porque suena bien», añade complejidad e
invalidación, y en muchos casos no mejora nada porque los datos son demasiado volátiles o el acceso demasiado
disperso.
- Muchas más lecturas que escrituras, al menos 10 a 1 y preferiblemente 100 a 1.
- Acceso por identificador, no por consulta. La L2 cachea
find(Producto, 7);
no cachea findByEstado (para eso está la caché de consultas, que es otra cosa y más peligrosa).
- Conjunto de datos acotado que quepa en memoria: catálogos, tipos de IVA, países,
configuración, tarifas.
- Tolerancia a datos ligeramente obsoletos, o todas las escrituras pasando por la misma
aplicación.
Si no cumples las cuatro, casi seguro que lo que quieres es una caché de aplicación con
@Cacheable sobre un DTO, con TTL corto y en Redis.
<!-- Hibernate 6 con JCache (JSR-107) y Ehcache 3 como proveedor -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<classifier>jakarta</classifier>
</dependency>
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
use_query_cache: false # ← por defecto NO. Ver sección 10.4
region.factory_class: jcache
# ENABLE_SELECTIVE: solo se cachea lo que anotes. Es lo correcto:
# nunca actives la caché "para todo".
javax.cache.provider: org.ehcache.jsr107.EhcacheCachingProvider
javax.cache.uri: classpath:ehcache.xml
generate_statistics: true # imprescindible para saber si sirve
jpa.properties.jakarta.persistence.sharedCache.mode: ENABLE_SELECTIVE
<!-- src/main/resources/ehcache.xml -->
<config xmlns="http://www.ehcache.org/v3">
<cache alias="com.ejemplo.tienda.catalogo.Categoria">
<expiry><ttl unit="hours">12</ttl></expiry>
<heap unit="entries">500</heap>
</cache>
<cache alias="com.ejemplo.tienda.catalogo.Producto">
<expiry><ttl unit="minutes">10</ttl></expiry>
<heap unit="entries">10000</heap>
</cache>
<!-- La caché de colecciones usa el nombre de la entidad + el del campo -->
<cache alias="com.ejemplo.tienda.catalogo.Categoria.hijas">
<expiry><ttl unit="hours">12</ttl></expiry>
<heap unit="entries">500</heap>
</cache>
</config>
// Marcar las entidades cacheables, una por una y con la estrategia adecuada
@Entity
@Cacheable // JPA estándar
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate
public class Producto {
@Id private Long id;
private String sku;
private BigDecimal precio;
@ManyToOne(fetch = FetchType.LAZY)
private Categoria categoria;
// Las colecciones se cachean APARTE y hay que anotarlas también
@OneToMany(mappedBy = "producto")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private Set<ProductoEtiqueta> etiquetas = new HashSet<>();
}
// Datos que no cambian nunca: READ_ONLY es la más rápida
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
@Immutable // Hibernate ignora cualquier cambio
public class Pais {
@Id @Column(length = 2) private String codigo;
private String nombre;
private BigDecimal iva;
}
10.3 Las cuatro estrategias de concurrencia
| Estrategia | Qué hace al escribir | Puede devolver datos obsoletos | Coste | Cuándo |
READ_ONLY |
Nada: prohíbe las modificaciones (lanza excepción). |
No |
El más bajo |
Datos de referencia inmutables: países, divisas, tipos de IVA históricos. |
NONSTRICT_READ_WRITE |
Invalida la entrada después del commit. |
Sí, brevemente |
Bajo |
Datos que cambian raramente y donde unos segundos de desfase no importan. |
READ_WRITE |
Soft lock: marca la entrada durante la escritura y la actualiza tras el commit. |
No dentro de una JVM |
Medio |
El defecto razonable para datos que se modifican. |
TRANSACTIONAL |
La caché participa en la transacción XA. |
No |
Alto |
Requiere JTA y un proveedor transaccional. En la práctica, casi nunca. |
10.4 La caché de consultas y sus peligros
// Se activa por consulta, no globalmente
@QueryHints(@QueryHint(name = HibernateHints.HINT_CACHEABLE, value = "true"))
@Query("select p from Producto p where p.categoria.id = :categoriaId and p.activo")
List<Producto> activosPorCategoria(@Param("categoriaId") Long categoriaId);
Por qué la caché de consultas decepciona casi siempre.
- Solo guarda los identificadores, no las entidades. Al acertar, tiene que buscar cada
entidad en la caché L2; si esas entidades no están cacheadas, ejecuta un
SELECT por cada
identificador: has creado un N+1 con la caché activada.
- La invalidación es por tabla, no por consulta. Cualquier escritura en
producto invalida todas las consultas cacheadas que la mencionan. En una tabla con
escrituras frecuentes, la tasa de acierto es cercana a cero y solo has añadido trabajo.
- La clave incluye todos los parámetros. Una consulta con un rango de fechas o un texto
libre genera una entrada distinta por combinación: la caché se llena de entradas de un solo uso.
- No es coherente entre instancias sin una caché distribuida, y con ella pagas la red en
cada acierto.
Regla práctica: cachea consultas solo cuando el resultado sea
pequeño, muy consultado y casi
inmutable (la lista de categorías activas, los tipos de IVA vigentes), y siempre con las entidades
implicadas también en la L2. En cualquier otro caso, cachea el
DTO de la respuesta con
@Cacheable: es más simple, más predecible y más fácil de invalidar.
10.5 Caché de aplicación con @Cacheable
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
RedisCacheManagerBuilderCustomizer personalizar() {
return builder -> builder
.withCacheConfiguration("catalogo",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer())))
.withCacheConfiguration("tarifas",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(12)));
}
}
@Service
@RequiredArgsConstructor
public class CatalogoService {
private final ProductoRepository repo;
// sync = true: si 200 peticiones piden la misma clave caducada, solo una
// ejecuta el método. Es la mitigación básica de la estampida de caché.
@Cacheable(cacheNames = "catalogo", key = "#sku", sync = true)
@Transactional(readOnly = true)
public ProductoDto porSku(String sku) {
return repo.findBySku(sku).map(ProductoDto::desde)
.orElseThrow(() -> new ProductoNoEncontradoException(sku));
}
// Invalidación explícita al escribir. Esta es la parte que se olvida y
// la que causa el 90 % de los problemas de "datos obsoletos".
@CacheEvict(cacheNames = "catalogo", key = "#result.sku()")
@Transactional
public ProductoDto cambiarPrecio(String sku, BigDecimal nuevo) {
Producto p = repo.findBySku(sku).orElseThrow();
p.cambiarPrecio(nuevo);
return ProductoDto.desde(p);
}
@CacheEvict(cacheNames = "catalogo", allEntries = true)
@Transactional
public void recargarCatalogo() { … }
}
| Criterio | Caché L2 de Hibernate | @Cacheable de Spring |
| Qué cachea | Entidades por id, colecciones, resultados de consulta | El valor de retorno de un método (normalmente un DTO) |
| Invalidación | Automática cuando Hibernate escribe | Manual: tú pones los @CacheEvict |
| Coherencia entre instancias | Necesita una caché distribuida y configuración fina | Trivial con Redis |
| Se salta con SQL nativo o cambios externos | Sí: queda obsoleta sin avisar | Igual, pero al menos con TTL controlado |
| Complejidad de configuración | Alta (regiones, estrategias, proveedor) | Baja |
| Devuelve entidades gestionadas | Sí | No: devuelve el objeto cacheado |
| Recomendación | Solo para datos de referencia con las cuatro condiciones de 10.2 | La opción por defecto para cachear en una aplicación web |
El problema que ninguna caché de la aplicación resuelve: la coherencia entre instancias. Con tres
réplicas y caché local (Ehcache, Caffeine), un cambio hecho en la réplica 1 no invalida las cachés de las
réplicas 2 y 3. Los usuarios verán datos distintos según a qué instancia les toque ir, y no habrá forma de
reproducirlo. Tienes tres salidas: (1) caché distribuida (Redis, Hazelcast), que es la
respuesta habitual; (2) TTL corto y aceptar la ventana de inconsistencia, que suele ser
suficiente para datos de catálogo; (3) invalidación por eventos, publicando la invalidación en
un canal que todas las instancias escuchan. Lo que no vale es caché local con TTL largo y confianza en
que «casi nunca cambia».
Orden de decisión para cachear
- Mide primero. Sin un perfil que diga qué consulta duele, cachear es adivinar. Muchas veces el problema real es un índice ausente y la caché solo lo tapa.
- Arregla las consultas. Un índice adecuado convierte 400 ms en 2 ms sin ninguna invalidación que mantener. Siempre es preferible a cachear.
- Elimina los N+1. Cachear un N+1 es cachear 500 consultas en lugar de arreglar una.
- Caché de aplicación con TTL corto sobre DTO, en Redis. Es el 90 % de los casos reales.
- Caché L2 de Hibernate solo para datos de referencia que cumplan las cuatro condiciones.
- Caché de consultas, casi nunca, y solo con las entidades también en L2.
- Mide otra vez. Si la tasa de acierto es baja, la caché es coste puro: quítala.
11 · Migraciones y control del esquema
El esquema de la base de datos es código: se versiona, se revisa y se despliega. Si no está en el repositorio,
nadie sabe cómo es realmente y cada entorno es una sorpresa. Esta sección es cómo hacerlo bien con Flyway y
cómo cambiar el esquema sin cortar el servicio.
11.1 Flyway: estructura, versiones y nombres
src/main/resources/db/migration/
├── V1__esquema_inicial.sql ← versionada
├── V2__indice_pedido_estado.sql
├── V3.1__anade_columna_canal.sql ← versión con punto: válida
├── V3.2__backfill_canal.sql
├── V4__canal_not_null.sql
├── V5__tabla_outbox.sql
├── R__vista_facturacion_mensual.sql ← repetible: se reaplica al cambiar
├── R__funcion_normalizar_texto.sql
└── U3__deshacer_columna_canal.sql ← "undo" (solo Flyway Teams)
ANATOMÍA DEL NOMBRE:
V 3.2 __ backfill_canal .sql
│ │ │ │ │
prefijo versión separador descripción extensión
(dos _) (los _ pasan a
espacios al mostrar)
PREFIJOS:
V versionada: se aplica UNA VEZ, en orden. El 95 % de tus migraciones.
R repetible: se aplica siempre que cambie su checksum, DESPUÉS de las
versionadas y en orden alfabético. Ideal para vistas, funciones,
procedimientos y datos de referencia con MERGE.
U undo: deshace una versión. Solo en la edición comercial y, sinceramente,
no la necesitas: ver 11.6.
LA TABLA DE CONTROL (flyway_schema_history):
installed_rank | version | description | checksum | success
---------------+---------+-------------------+------------+--------
1 | 1 | esquema inicial | -18374652 | t
2 | 2 | indice pedido est.| 1928374 | t
3 | 3.1 | anade columna can.| -882736 | t
El checksum es la razón por la que NO PUEDES editar una migración aplicada.
| Convención | Recomendación | Por qué |
| Numeración | Entera y secuencial (V1, V2…), o con fecha (V20260314103000) | Con varios equipos, la fecha y hora evita colisiones de número en las ramas. Con un equipo, el entero es más legible. |
| Descripción | Verbo + objeto, en minúsculas y con guiones bajos | V7__anade_indice_pedido_canal.sql se entiende sin abrirlo. |
| Una migración, un cambio | Sí | Si falla, sabes exactamente qué falló. Y en el historial se lee la evolución. |
| Sin acentos ni eñes en los nombres de fichero | Sí | Problemas de codificación entre sistemas operativos y en imágenes Docker. |
| Migraciones de datos separadas del DDL | Sí | El DDL es rápido; el backfill puede tardar horas y necesita trocearse. |
| Idempotentes cuando sea posible | Sí (if not exists) | Facilita reintentar una migración que falló a medias. |
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false # true SOLO al adoptar Flyway en una BD existente
baseline-version: 1
validate-on-migrate: true # comprueba checksums al arrancar: déjalo así
clean-disabled: true # en Flyway 10 ya es el defecto. No lo cambies
out-of-order: false # true permite aplicar una V5 después de una V6
placeholders:
esquema: tienda # se usa como ${esquema} en el SQL
# Para migraciones largas, súbelo o ejecútalas como Job aparte (11.5)
lock-retry-count: 50
# Datos de prueba en otro directorio, solo en el perfil de desarrollo
---
spring:
config.activate.on-profile: dev
flyway:
locations: classpath:db/migration,classpath:db/dev-data
11.2 Las reglas de oro (y qué pasa si las rompes)
Regla 1: nunca edites una migración ya aplicada. Flyway calcula un checksum de cada fichero y lo
guarda. Si cambias una coma, el checksum deja de coincidir y el arranque falla con
Migration checksum mismatch for migration version 7. Esto es una protección, no
una molestia: significa que producción y tu rama ya no coinciden. La solución nunca es
flyway repair a ciegas, es una migración nueva que corrija lo anterior.
Regla 2: flyway clean está prohibido en producción. Borra todos los objetos del
esquema. Ha causado incidentes reales de pérdida total de datos porque alguien lo tenía en un perfil y el perfil
equivocado se activó en el despliegue. Desde Flyway 10 está desactivado por defecto; asegúrate de que sigue así
con spring.flyway.clean-disabled: true y de que nadie puede activarlo por variable de entorno.
Regla 3: toda migración debe ser compatible con la versión anterior de la aplicación. Durante un
despliegue progresivo, hay instancias con el código viejo y con el nuevo funcionando a la vez
contra el mismo esquema. Si la migración borra una columna que el código viejo todavía lee,
tienes errores durante todo el despliegue. De ahí el patrón expand-contract (sección 11.4).
-- V2__indice_pedido_canal.sql
-- Cabecera recomendada: contexto para quien lo lea en dos años
-- ============================================================================
-- Motivo : el listado del panel filtra por canal y hacía Seq Scan (400 ms)
-- Impacto : CREATE INDEX CONCURRENTLY, sin bloqueo de escrituras
-- Duración : ~40 s con 8 M de filas en preproducción
-- Ticket : TIENDA-1841
-- ============================================================================
-- Flyway envuelve cada migración en una transacción por defecto. CREATE INDEX
-- CONCURRENTLY NO PUEDE ejecutarse dentro de una transacción, así que hay que
-- desactivarla para esta migración concreta.
-- En Flyway: usa el sufijo de configuración por script.
create index concurrently if not exists ix_pedido_canal
on pedido ((metadatos ->> 'canal'));
# src/main/resources/db/migration/V2__indice_pedido_canal.sql.conf
# Configuración POR SCRIPT: desactiva la transacción para poder usar CONCURRENTLY
executeInTransaction=false
Cuidado con CREATE INDEX CONCURRENTLY y los fallos: si falla a mitad (por ejemplo, por un
timeout), deja un índice inválido que no se usa pero sí penaliza las escrituras.
Comprueba select indexrelid::regclass from pg_index where not indisvalid; tras cada despliegue con
índices, y si aparece uno, DROP INDEX y vuelve a crearlo. El if not exists no te salva
de esto: el índice inválido existe.
11.3 Migraciones de datos y backfill por lotes
-- ✗ MAL: actualizar 40 millones de filas en una sola sentencia.
-- Bloquea la tabla durante minutos, genera gigabytes de WAL de golpe,
-- infla la tabla con versiones muertas y, si falla, hay que repetirlo entero.
update pedido set canal = 'WEB' where canal is null;
-- ✓ BIEN: por lotes, con progreso y reanudable. Se ejecuta desde un script o
-- desde un Job de Kubernetes, NO como migración de Flyway bloqueante.
do $$
declare
filas integer;
total integer := 0;
begin
loop
with lote as (
select id from pedido
where canal is null
order by id
limit 5000
for update skip locked -- no espera a otras transacciones
)
update pedido p
set canal = coalesce(p.metadatos ->> 'canal', 'WEB')
from lote
where p.id = lote.id;
get diagnostics filas = row_count;
total := total + filas;
exit when filas = 0;
raise notice 'Actualizadas % filas (total %)', filas, total;
commit; -- ← libera bloqueos y WAL cada lote
perform pg_sleep(0.05); -- respira: deja hueco al tráfico real
end loop;
end $$;
// Cuando la transformación necesita lógica Java, hazla desde la aplicación
// con un job idempotente y reanudable, no con una migración de Flyway.
@Component
@RequiredArgsConstructor
public class BackfillCanal {
private final JdbcClient jdbc;
private final TransactionTemplate tx;
/** Se puede lanzar tantas veces como quieras: solo toca lo que falta. */
public ResultadoBackfill ejecutar() {
long total = 0;
int lote;
do {
lote = tx.execute(estado -> jdbc.sql("""
with candidatos as (
select id, metadatos from pedido
where canal is null
order by id limit 1000
for update skip locked
)
update pedido p set canal = ?
from candidatos c where p.id = c.id
""")
.param(1, "WEB")
.update());
total += lote;
log.info("Backfill de canal: {} filas acumuladas", total);
} while (lote > 0);
return new ResultadoBackfill(total);
}
}
11.4 Expand-contract: cambiar el esquema sin cortar el servicio
Este es el patrón que hay que saber explicar en una entrevista para cualquier puesto que toque producción.
Ejemplo concreto y completo: renombrar la columna direccion a direccion_envio en una
tabla con 40 millones de filas, sin ni un segundo de caída.
EL PROBLEMA con el enfoque ingenuo:
alter table pedido rename column direccion to direccion_envio;
· Durante el despliegue progresivo, las instancias viejas siguen haciendo
"select direccion from pedido" → ERROR: column "direccion" does not exist.
· El rename es instantáneo, así que TODAS las instancias viejas fallan a la vez.
· Y para volver atrás hay que renombrar de nuevo, con las nuevas fallando.
LA SOLUCIÓN: cuatro despliegues, cada uno compatible con el anterior.
┌──────────────┬───────────────────────┬──────────────────────────────────────┐
│ Despliegue │ Esquema │ Código │
├──────────────┼───────────────────────┼──────────────────────────────────────┤
│ 1 · EXPAND │ + direccion_envio │ escribe en LAS DOS, │
│ │ (nullable) │ lee de direccion │
├──────────────┼───────────────────────┼──────────────────────────────────────┤
│ 2 · BACKFILL │ (sin cambios) │ job por lotes rellena la nueva │
├──────────────┼───────────────────────┼──────────────────────────────────────┤
│ 3 · MIGRATE │ direccion_envio │ escribe en LAS DOS, │
│ │ NOT NULL │ lee de direccion_envio │
├──────────────┼───────────────────────┼──────────────────────────────────────┤
│ 4 · CONTRACT │ - direccion │ solo usa direccion_envio │
└──────────────┴───────────────────────┴──────────────────────────────────────┘
En CADA paso se puede volver atrás sin pérdida de datos. Eso es lo que hace
que el patrón funcione: no hay ningún momento en que las dos versiones del
código no puedan convivir.
-- ============ PASO 1 · EXPAND ============
-- V10__anade_direccion_envio.sql
-- Nullable y sin valor por defecto: instantáneo, sin reescribir la tabla.
alter table pedido add column direccion_envio varchar(200);
-- ⚠ En PostgreSQL 11+ añadir una columna CON default también es instantáneo
-- (se guarda el default en el catálogo). En versiones anteriores reescribía
-- toda la tabla con un ACCESS EXCLUSIVE LOCK: minutos de caída.
-- ============ PASO 2 · BACKFILL ============
-- No como migración de Flyway: como Job aparte (ver 11.3 y 11.5).
-- Por lotes, con commit intermedio y reanudable.
-- ============ PASO 3 · MIGRATE ============
-- V11__direccion_envio_not_null.sql
-- El truco para no bloquear: NOT VALID primero, VALIDATE después.
alter table pedido
add constraint ck_direccion_envio_presente
check (direccion_envio is not null) not valid; -- instantáneo: no revisa nada
-- VALIDATE toma solo un SHARE UPDATE EXCLUSIVE: permite lecturas y escrituras
alter table pedido validate constraint ck_direccion_envio_presente;
-- ⚠ "alter column set not null" SÍ bloquea porque revisa toda la tabla.
-- Con el CHECK ya validado, PostgreSQL 12+ puede hacerlo sin recorrerla:
alter table pedido alter column direccion_envio set not null;
alter table pedido drop constraint ck_direccion_envio_presente;
-- ============ PASO 4 · CONTRACT ============
-- V12__elimina_direccion.sql (¡en un despliegue POSTERIOR!)
alter table pedido drop column direccion;
// El código del paso 1 y 3: escribe en las dos, lee de una.
// Poco elegante y temporal, pero es el precio de no tener caída.
@Entity
public class Pedido {
@Deprecated(forRemoval = true) // marca que desaparece en el paso 4
@Column(name = "direccion", length = 200)
private String direccionAntigua;
@Column(name = "direccion_envio", length = 200)
private String direccionEnvio;
public void cambiarDireccion(String nueva) {
this.direccionEnvio = nueva;
this.direccionAntigua = nueva; // escritura doble durante la transición
}
public String getDireccion() {
// PASO 1: return direccionAntigua;
// PASO 3: leemos de la nueva, con la vieja como red de seguridad
return direccionEnvio != null ? direccionEnvio : direccionAntigua;
}
}
| Operación en PostgreSQL 16 | Bloqueo | ¿Segura en caliente? | Alternativa |
ADD COLUMN nullable | ACCESS EXCLUSIVE instantáneo | Sí | — |
ADD COLUMN … DEFAULT | Instantáneo (PG 11+) | Sí | — |
ADD COLUMN … NOT NULL sin default | Falla si hay filas | No | Nullable + backfill + SET NOT NULL |
DROP COLUMN | ACCESS EXCLUSIVE instantáneo | Sí en la BD, no para el código viejo | Contract en un despliegue posterior |
ALTER COLUMN TYPE (p. ej. varchar(50)→varchar(200)) | Instantáneo si solo amplía | Sí | — |
ALTER COLUMN TYPE incompatible (int→bigint) | Reescribe la tabla | No | Columna nueva + backfill + intercambio |
CREATE INDEX | Bloquea escrituras | No | CREATE INDEX CONCURRENTLY |
ADD FOREIGN KEY | Bloquea las dos tablas mientras valida | No | NOT VALID + VALIDATE |
ADD CHECK | Recorre la tabla | No | NOT VALID + VALIDATE |
RENAME COLUMN / RENAME TABLE | Instantáneo | No para el código | Expand-contract completo |
SET NOT NULL | Recorre la tabla, salvo con CHECK validado (PG 12+) | Con el truco, sí | CHECK NOT VALID + VALIDATE + SET NOT NULL |
La línea que debe estar en todas tus migraciones de DDL:
SET lock_timeout = '3s'; al principio. Sin ella, un ALTER TABLE que no consigue el
bloqueo se pone a esperar y encola detrás de sí todas las consultas de la tabla, incluidas las
lecturas. Es el mecanismo exacto por el que una migración «instantánea» tumba producción durante quince minutos.
Con lock_timeout, la migración falla rápido, no bloquea a nadie y se reintenta en un momento más
tranquilo.
-- Plantilla segura para cualquier migración de DDL
set lock_timeout = '3s';
set statement_timeout = '30s';
-- Y si el ALTER puede tardar, hazlo con reintento desde el orquestador
-- en lugar de esperar dentro de la transacción.
alter table pedido add column canal varchar(20);
11.5 Ejecutar migraciones en Kubernetes
| Estrategia | Cómo | Ventajas | Inconvenientes |
| En el arranque de la aplicación (el defecto de Boot) |
spring.flyway.enabled=true |
Cero configuración; siempre coherente con el código. |
Con N réplicas, N procesos compiten (Flyway usa un bloqueo, así que es correcto pero lento); una migración larga retrasa el arranque y puede hacer fallar la sonda de startup; la aplicación necesita permisos de DDL. |
initContainer |
Un contenedor con la CLI de Flyway antes del principal |
Separa migración de arranque; la aplicación no necesita DDL. |
Se ejecuta por pod: con 6 réplicas, 6 ejecuciones (idempotentes, pero ruidosas). |
Job de Kubernetes |
Un Job que se ejecuta una vez antes del rollout |
La opción recomendada: una sola ejecución, con sus propios recursos, timeout y registro; se puede reintentar y auditar por separado. |
Requiere orquestación (Helm hook, Argo, un pipeline). |
| Paso del pipeline de CI/CD |
Una tarea antes del despliegue |
Control total, aprobación manual posible en producción. |
El pipeline necesita acceso a la red de la base de datos. |
# Opción recomendada: Job con hook de Helm, que se ejecuta antes del rollout
apiVersion: batch/v1
kind: Job
metadata:
name: tienda-migracion-{{ .Release.Revision }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: 2
activeDeadlineSeconds: 900 # 15 min: si tarda más, algo va mal
template:
spec:
restartPolicy: Never
containers:
- name: flyway
image: flyway/flyway:10-alpine
args:
- -url=jdbc:postgresql://$(DB_HOST):5432/tienda
- -user=$(DB_MIGRACION_USER) # usuario CON permisos de DDL
- -password=$(DB_MIGRACION_PASS)
- -connectRetries=10
- -lockRetryCount=50
- -baselineOnMigrate=false
- -cleanDisabled=true
- migrate
volumeMounts:
- name: migraciones
mountPath: /flyway/sql
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
volumes:
- name: migraciones
configMap: { name: tienda-migraciones }
# Y la aplicación, con Flyway DESACTIVADO y validate activo.
# Separación de privilegios: la app no puede hacer DDL nunca.
spring:
flyway:
enabled: false
jpa:
hibernate:
ddl-auto: validate # si el Job no se ejecutó, el arranque FALLA
datasource:
username: app_tienda # solo SELECT/INSERT/UPDATE/DELETE
-- La separación de privilegios que hace esto posible
create role tienda_migracion login password '…';
grant all on schema tienda to tienda_migracion; -- puede hacer DDL
create role app_tienda login password '…';
grant usage on schema tienda to app_tienda;
grant select, insert, update, delete on all tables in schema tienda to app_tienda;
grant usage, select on all sequences in schema tienda to app_tienda;
-- Y para las tablas futuras:
alter default privileges in schema tienda
grant select, insert, update, delete on tables to app_tienda;
alter default privileges in schema tienda
grant usage, select on sequences to app_tienda;
-- Resultado: una inyección SQL en la aplicación NO puede hacer DROP TABLE.
-- Es defensa en profundidad casi gratis (módulo 10).
11.6 Flyway frente a Liquibase, y la reversión
| Criterio | Flyway | Liquibase |
| Formato | SQL nativo (y Java para casos complejos) | XML, YAML, JSON o SQL |
| Curva de aprendizaje | Nula si sabes SQL | Media: hay que aprender su DSL |
| Portabilidad entre motores | Manual (un directorio por motor) | Automática con el DSL abstracto |
| Rollback automático | Solo en la edición comercial | Sí, con rollback en cada changeset |
| Precondiciones | No | Sí (preConditions) |
| Contextos y etiquetas | Placeholders y directorios | context, labels, muy potente |
| Generar el diff del esquema | No (edición comercial) | Sí, diffChangeLog |
| Legibilidad de una migración | Máxima: es el SQL que se va a ejecutar | Menor: hay que traducir mentalmente del DSL al SQL |
| Integración con Spring Boot | De primera clase | De primera clase |
| Cuándo elegirlo | Por defecto, y sobre todo con un solo motor y un equipo que sabe SQL | Cuando de verdad hay que soportar varios motores, o cuando necesitas precondiciones y contextos complejos |
Sobre el rollback automático: la razón por la que no lo echarás de menos. Un
rollback de esquema es una ficción reconfortante. Deshacer un ALTER TABLE ADD COLUMN es
fácil; deshacer un DROP COLUMN es imposible, porque los datos ya no están. Y deshacer una migración
de datos que ha transformado 40 millones de filas requiere haber guardado el estado anterior, que es una
decisión de diseño, no una característica de la herramienta. Lo que sí funciona en producción es
avanzar siempre: si la V11 fue un error, la V12 lo corrige. Combinado con
expand-contract, el código puede volver a la versión anterior en cualquier momento, que es lo que de
verdad necesitas cuando algo va mal a las tres de la mañana.
11.7 Generación de esquema y esquema en los tests
# En TESTS de integración: exactamente lo mismo que en producción.
# Con Testcontainers no hay excusa para usar otra cosa.
spring:
flyway:
enabled: true
jpa:
hibernate:
ddl-auto: validate
# Beneficio doble: cada ejecución de la batería de tests VERIFICA que tus
# migraciones producen un esquema compatible con tus entidades. Es el mejor
# test de migraciones que existe y sale gratis.
---
# Y solo para tests de MAPEO puros (comprobar que una anotación funciona),
# donde Flyway sería un lastre:
spring:
config.activate.on-profile: test-mapeo
flyway:
enabled: false
jpa:
hibernate:
ddl-auto: create-drop
El bug que validate en los tests te ahorra: añades un campo a una entidad, se te olvida la
migración, los tests pasan porque en algún sitio hay un ddl-auto: update heredado, y el despliegue
a producción falla… o peor: no falla, porque producción también tiene update, y ahora
tienes un esquema en producción que no está en ninguna migración y que nadie puede reproducir. Con
validate + Flyway en los tests, ese olvido rompe la compilación de CI en el primer intento, que es
exactamente donde quieres enterarte.
12 · Auditoría y patrones habituales
12.1 Auditoría con Spring Data
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorActual")
public class AuditoriaConfig {
@Bean
AuditorAware<String> auditorActual() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getName)
// Los procesos automáticos también dejan rastro, con nombre propio
.or(() -> Optional.of("sistema"));
}
// Reloj inyectable: hace los tests deterministas
@Bean
DateTimeProvider proveedorFecha(Clock reloj) {
return () -> Optional.of(reloj.instant().atZone(ZoneOffset.UTC));
}
}
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class EntidadAuditable {
@CreatedDate
@Column(name = "creado_en", nullable = false, updatable = false)
private Instant creadoEn;
@CreatedBy
@Column(name = "creado_por", length = 100, updatable = false)
private String creadoPor;
@LastModifiedDate
@Column(name = "actualizado_en")
private Instant actualizadoEn;
@LastModifiedBy
@Column(name = "actualizado_por", length = 100)
private String actualizadoPor;
@Version
private long version;
// Getters solamente: nadie debe poder falsear la auditoría
public Instant getCreadoEn() { return creadoEn; }
public String getCreadoPor() { return creadoPor; }
public Instant getActualizadoEn() { return actualizadoEn; }
public String getActualizadoPor() { return actualizadoPor; }
public long getVersion() { return version; }
}
Tres limitaciones de la auditoría de Spring Data que hay que conocer: (1) no se dispara con
@Modifying ni con SQL nativo, porque no pasa por el ciclo de vida de la entidad; (2) no se dispara
con StatelessSession; (3) solo guarda quién y cuándo del último cambio, no el histórico.
Si necesitas «qué cambió exactamente y cuándo», necesitas Envers o una tabla de histórico
(sección 12.2). Y si la auditoría es un requisito legal, la única forma infalible es ponerla en
disparadores de la base de datos: así ninguna ruta de escritura puede saltársela.
12.2 Hibernate Envers: histórico completo
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-envers</artifactId>
</dependency>
@Entity
@Audited // ← eso es todo
@AuditTable("pedido_aud") // nombre de la tabla de histórico
public class Pedido {
@Id private Long id;
@Column private BigDecimal total;
@NotAudited // excluye campos ruidosos o pesados
@Column private String cacheBusqueda;
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
@ManyToOne(fetch = FetchType.LAZY) // guarda el id, no audita el cliente
private Cliente cliente;
}
// Consultar el histórico
@Service
@RequiredArgsConstructor
public class HistorialService {
private final EntityManager em;
@Transactional(readOnly = true)
public List<RevisionPedido> historial(Long pedidoId) {
AuditReader lector = AuditReaderFactory.get(em);
return lector.createQuery()
.forRevisionsOfEntity(Pedido.class, false, true)
.add(AuditEntity.id().eq(pedidoId))
.addOrder(AuditEntity.revisionNumber().asc())
.getResultList()
.stream()
.map(fila -> {
Object[] f = (Object[]) fila;
return new RevisionPedido((Pedido) f[0],
(DefaultRevisionEntity) f[1],
(RevisionType) f[2]);
})
.toList();
}
/** El pedido tal como era en un instante concreto. */
@Transactional(readOnly = true)
public Optional<Pedido> comoEraEn(Long pedidoId, Instant momento) {
AuditReader lector = AuditReaderFactory.get(em);
Number revision = lector.getRevisionNumberForDate(Date.from(momento));
return Optional.ofNullable(lector.find(Pedido.class, pedidoId, revision));
}
}
-- Lo que Envers crea por ti
create table pedido_aud (
id bigint not null,
rev integer not null, -- número de revisión
revtype smallint, -- 0=ADD, 1=MOD, 2=DEL
total numeric(12,2),
estado varchar(20),
cliente_id bigint,
primary key (id, rev)
);
create table revinfo (
rev integer generated by default as identity primary key,
revtstmp bigint -- marca de tiempo de la revisión
);
-- Y una entidad de revisión propia para guardar el usuario y la petición:
-- @RevisionEntity con @ModifiedEntityNames y campos personalizados.
| Opción de histórico | Coste de implementación | Coste en escritura | Granularidad | Cuándo |
@CreatedDate/@LastModifiedBy | Mínimo | Nulo | Solo el último cambio | El 80 % de los casos: basta saber quién tocó algo por última vez |
| Envers | Bajo (una anotación) | Doble: cada escritura inserta en la tabla de auditoría | Cada campo, cada revisión | Requisitos de trazabilidad, «¿quién cambió el precio el martes?» |
| Tabla de histórico propia | Medio | Controlado | Lo que decidas | Cuando necesitas un modelo de histórico distinto del modelo actual |
| Disparadores en la base de datos | Medio | Bajo | Cada fila | Cuando la auditoría es legal: nadie puede saltársela, ni un DBA con psql |
| Eventos de dominio en outbox | Alto | Medio | Semántica de negocio | Cuando el histórico es parte del dominio (event sourcing ligero) |
Envers duplica el coste de escritura y hace crecer la base de datos sin parar. Cada
UPDATE se convierte en un UPDATE más un INSERT, y las tablas
_aud crecen indefinidamente: es habitual que superen a las tablas reales en un año. Antes de
activarlo, decide (1) qué entidades se auditan —no todas—, (2) qué campos se excluyen con
@NotAudited, y (3) la política de retención, con un proceso de archivado o
particionado por fecha. Activar Envers «por si acaso» en todo el modelo es una decisión que se paga cada mes en
la factura de almacenamiento.
12.3 Borrado lógico bien hecho
// Hibernate 6.4+: @SoftDelete, la forma nativa y la más limpia
@Entity
@Table(name = "producto")
@SoftDelete(columnName = "eliminado", strategy = SoftDeleteType.DELETED)
public class Producto {
@Id private Long id;
// Hibernate añade "and eliminado = false" a TODAS las consultas de esta
// entidad, y convierte delete() en "update producto set eliminado = true".
}
// Alternativa (Hibernate 6.3+, sustituye a @Where): @SQLRestriction
@Entity
@Table(name = "producto")
@SQLRestriction("eliminado = false")
@SQLDelete(sql = "update producto set eliminado = true, eliminado_en = now() where id = ?")
public class Producto {
@Id private Long id;
@Column(nullable = false) private boolean eliminado = false;
@Column(name = "eliminado_en") private Instant eliminadoEn;
}
Las seis trampas del borrado lógico. Todas te van a pasar.
- Las restricciones únicas dejan de funcionar como esperas. Si borras lógicamente el
producto con SKU
A-1 y creas otro con el mismo SKU, el índice único falla: la fila vieja sigue
ahí. Solución: índice único parcial
(create unique index … on producto (sku) where not eliminado).
- Las consultas nativas y
JdbcClient NO aplican el filtro. Cualquier informe
con SQL directo verá los borrados. Hay que acordarse en cada consulta, y alguien no se acordará.
- Las claves foráneas siguen apuntando a filas «borradas», así que
join con la
entidad borrada devuelve datos que el usuario cree eliminados.
- Las agregaciones y los
count requieren cuidado: un count(*) en
SQL nativo cuenta los borrados; el de JPQL no. Los números no cuadran y nadie sabe por qué.
- Los índices crecen con datos muertos. Con años de borrados lógicos, el 60 % de la
tabla puede ser basura que se recorre en cada escaneo.
- El RGPD choca de frente. El derecho de supresión exige borrar de verdad los datos
personales; una fila marcada como eliminada no cumple. Hay que anonimizar o borrar
físicamente.
-- Cómo mitigar cada trampa
-- 1) Índice único parcial: la unicidad solo entre los vivos
create unique index uk_producto_sku_vivo on producto (sku) where not eliminado;
-- 2) Vista para el SQL nativo y los informes: nadie tiene que acordarse
create view producto_vivo as select * from producto where not eliminado;
-- 3) Índices parciales para las consultas normales: más pequeños y rápidos
create index ix_producto_categoria_vivo on producto (categoria_id) where not eliminado;
-- 4) Archivado periódico: los borrados de más de un año se mueven fuera
insert into producto_archivo select * from producto
where eliminado and eliminado_en < now() - interval '1 year';
delete from producto where eliminado and eliminado_en < now() - interval '1 year';
-- 5) Anonimización para el RGPD: los datos personales se van, la fila queda
update cliente
set email = 'anonimo-' || id || '@invalido.local',
nombre = 'Cliente anonimizado',
telefono = null,
anonimizado_en = now()
where id = :id;
-- Así se conservan la integridad referencial y los datos contables (obligatorios
-- durante años) sin conservar datos personales identificables.
Y la pregunta que casi nadie hace: ¿de verdad necesitas borrado lógico? Muchas veces lo que el negocio
quiere no es «poder recuperar registros borrados», es un estado: un producto
DESCATALOGADO, un cliente INACTIVO, un pedido CANCELADO. Eso es un campo
de dominio con significado, se consulta explícitamente, aparece en los filtros de la interfaz y no tiene ninguna
de las seis trampas. Reserva el borrado lógico para cuando de verdad necesites deshacer un borrado, y considera
en su lugar una tabla de papelera con retención definida.
12.4 Multitenencia
| Modelo | Aislamiento | Coste operativo | Límite práctico | Cómo se implementa en JPA |
Discriminador (columna tenant_id) |
Bajo: un bug en un where filtra datos entre clientes |
El más bajo: una base de datos, un esquema, un despliegue |
Miles de tenants |
@TenantId (Hibernate 6) o filtros; mejor aún, RLS en PostgreSQL |
| Esquema por tenant |
Medio: el aislamiento lo garantiza el motor |
Medio: N esquemas que migrar en cada despliegue |
Cientos |
MultiTenantConnectionProvider con set search_path |
| Base de datos por tenant |
Alto |
Alto: N bases de datos, N copias de seguridad, N pools |
Decenas |
MultiTenantConnectionProvider con un DataSource por tenant |
// MODELO 1 · Discriminador con @TenantId (Hibernate 6). Automático y limpio.
@Entity
public class Pedido {
@Id private Long id;
@TenantId // Hibernate lo filtra y lo rellena
@Column(name = "tenant_id", nullable = false, updatable = false)
private String tenantId;
}
@Component
public class ResolutorTenant implements CurrentTenantIdentifierResolver<String> {
@Override
public String resolveCurrentTenantIdentifier() {
return Optional.ofNullable(ContextoTenant.actual())
.orElseThrow(() -> new IllegalStateException("Sin tenant en el contexto"));
}
@Override public boolean validateExistingCurrentSessions() { return true; }
}
// Hibernate añade "and tenant_id = ?" a TODAS las consultas y rellena la columna
// en los INSERT. No hay que acordarse en cada consulta, que es lo que falla.
-- Y la defensa que no depende de tu código: Row Level Security.
-- Aunque una consulta olvide el filtro, PostgreSQL lo aplica.
alter table pedido enable row level security;
alter table pedido force row level security; -- también para el dueño de la tabla
create policy pedido_por_tenant on pedido
using (tenant_id = current_setting('app.tenant_id', true));
-- La aplicación fija la variable de sesión al obtener la conexión:
-- set_config('app.tenant_id', 'acme', true) ← true = solo esta transacción
-- Un bug en un WHERE ya no puede filtrar datos de otro cliente: es el motor
-- el que lo impide. Defensa en profundidad real (módulo 06 y 10).
// MODELO 2 · Esquema por tenant: cambiar search_path al obtener la conexión
@Component
@RequiredArgsConstructor
public class ProveedorConexionPorEsquema
implements MultiTenantConnectionProvider<String> {
private final DataSource dataSource;
@Override
public Connection getConnection(String tenant) throws SQLException {
Connection cn = dataSource.getConnection();
// Sanear SIEMPRE: el nombre del esquema no puede venir del usuario sin filtrar
if (!tenant.matches("[a-z0-9_]{1,40}"))
throw new IllegalArgumentException("Tenant inválido: " + tenant);
try (Statement st = cn.createStatement()) {
st.execute("set search_path to \"" + tenant + "\", public");
}
return cn;
}
@Override
public void releaseConnection(String tenant, Connection cn) throws SQLException {
try (Statement st = cn.createStatement()) {
st.execute("set search_path to public"); // limpia antes de devolverla
}
cn.close();
}
}
Con esquema o base de datos por tenant, las migraciones se multiplican. Cada despliegue tiene que
aplicar Flyway a N esquemas, en paralelo y con reintento, y contemplar que uno falle mientras los demás avanzan.
Es un trabajo real que hay que diseñar desde el principio: un Job que itera sobre la lista de
tenants, con registro por tenant y capacidad de reanudar. Con 200 tenants, un
despliegue puede pasar de 30 segundos a 20 minutos si esto no está bien hecho.
12.5 Campos calculados y @Formula
| Técnica | Cuándo se calcula | Se puede indexar | Coste | Cuándo usarla |
Método Java (@Transient) | Al llamarlo, en la JVM | No | Nulo en la BD | Lo primero que hay que probar. Si los datos ya están cargados, calcula en Java. |
@Formula | En cada SELECT de la entidad | No | Alto: subconsulta por fila, siempre | Casi nunca. Solo con datos pequeños y consultas puntuales. |
Columna generada (generated always as … stored) | Al insertar o actualizar, en la BD | Sí | Bajo | Cuando el cálculo depende solo de columnas de la misma fila. |
| Columna desnormalizada mantenida por la aplicación | Al escribir, en el dominio | Sí | Bajo, pero hay que mantener la coherencia | Agregados de hijos (total del pedido, número de líneas). |
| Vista materializada | Al refrescar | Sí | Alto al refrescar | Informes y agregaciones caras que toleran minutos de desfase. |
// La opción recomendada para agregados de hijos: mantener el total en el dominio
@Entity
public class Pedido {
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal total = BigDecimal.ZERO; // desnormalizado a propósito
@Column(name = "num_lineas", nullable = false)
private int numeroLineas = 0;
// Un solo punto de mantenimiento: si las líneas cambian, esto se recalcula.
private void recalcular() {
this.total = lineas.stream().map(LineaPedido::importe)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
this.numeroLineas = lineas.size();
}
}
// Ventajas: el listado muestra total y número de líneas SIN cargar las líneas
// (una consulta, cero N+1), se puede ordenar e indexar por total, y los
// informes en SQL lo leen directamente. El coste es la disciplina de llamar a
// recalcular() en un único sitio, que es exactamente lo que hace el agregado.
12.6 @DynamicUpdate y @DynamicInsert
POR DEFECTO Hibernate genera UNA sentencia UPDATE fija por entidad, con TODAS
las columnas:
update pedido set estado=?, total=?, moneda=?, direccion=?, metadatos=?,
version=?, actualizado_en=?, actualizado_por=?
where id=? and version=?
Ventajas de ese comportamiento (que suele sorprender que sean ventajas):
· Una sola sentencia preparada, reutilizada siempre → un solo plan en la BD.
· Permite el BATCHING: 50 UPDATE idénticos en un lote.
Inconvenientes:
· Escribe columnas que no han cambiado → reescribe índices innecesariamente.
· Con columnas LOB o jsonb grandes, reescribe megabytes por cambiar un enum.
· Aumenta la ventana de conflicto si dos transacciones tocan campos distintos.
Con @DynamicUpdate, solo las columnas modificadas:
update pedido set estado=?, version=? where id=? and version=?
· Sentencias más cortas y menos índices tocados.
· PERO: una sentencia SQL distinta por combinación de campos cambiados.
La base de datos cachea muchos planes distintos y SE PIERDE EL BATCHING.
CUÁNDO USARLO:
✓ Entidades con muchas columnas (más de 20) donde se cambian pocas.
✓ Entidades con LOB, jsonb grande o arrays.
✓ Cuando distintos casos de uso actualizan campos distintos de la misma fila
y quieres reducir conflictos.
✗ Entidades pequeñas: no aporta nada y pierdes el batching.
✗ Procesos masivos de actualización: el batching vale más.
12.7 Identificadores de negocio
// Requisito real y frecuente: un número de factura consecutivo SIN HUECOS,
// por año, con formato legal. La clave primaria NO sirve para esto (sección 3.2).
@Service
@RequiredArgsConstructor
public class GeneradorNumeroFactura {
private final JdbcClient jdbc;
/**
* Serie consecutiva y sin huecos por año.
* SERIALIZA las emisiones de factura del mismo año: es el precio inevitable
* de "sin huecos". Con volúmenes altos, emite en un proceso aparte.
*/
@Transactional(propagation = Propagation.MANDATORY) // exige la transacción del caso de uso
public String siguiente(int anio) {
// Un UPDATE ... RETURNING atómico: el bloqueo de fila serializa a los
// concurrentes y el número no se pierde si la transacción se deshace...
// porque el rollback devuelve también el contador. Eso es lo que hace
// que NO haya huecos, al contrario que con una secuencia.
int numero = jdbc.sql("""
update serie_factura
set ultimo = ultimo + 1
where anio = :anio
returning ultimo
""")
.param("anio", anio)
.query(Integer.class)
.single();
return "FAC-%d-%06d".formatted(anio, numero);
}
}
| Necesidad | Mecanismo | Huecos | Concurrencia |
| Clave primaria técnica | Secuencia con allocationSize | Sí, y no importa | Excelente |
| Referencia pública opaca | UUIDv7 o cadena aleatoria | No aplica | Excelente |
| Número consecutivo legal (facturas) | Contador en tabla con UPDATE … RETURNING | No | Serializada: es el precio |
| Código legible no consecutivo | Prefijo + fecha + sufijo aleatorio | No aplica | Excelente |
| Número corto para el usuario | Secuencia + codificación (base32 sin caracteres ambiguos) | Sí | Excelente |
12.8 Referencias entre microservicios: guarda el id, no la entidad
// ✗ MAL: la entidad Cliente vive en otro servicio y aquí hay una copia mapeada
// con @ManyToOne. Ahora tu esquema depende del suyo, cualquier cambio en su
// modelo rompe el tuyo, y necesitas su tabla en tu base de datos.
@Entity
public class Pedido {
@ManyToOne private Cliente cliente; // Cliente es de otro bounded context
}
// ✓ BIEN: una referencia blanda. Guardas el identificador y una copia de los
// datos que necesitas EN EL MOMENTO de la operación.
@Entity
public class Pedido {
@Column(name = "cliente_id", nullable = false, updatable = false, length = 64)
private String clienteId; // el id del OTRO servicio
// Copia inmutable de los datos que necesitas para este pedido.
// No es desnormalización perezosa: es un requisito de negocio. La factura
// debe llevar el nombre y la dirección de CUANDO se emitió, no los actuales.
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "nombre", column = @Column(name = "cliente_nombre")),
@AttributeOverride(name = "email", column = @Column(name = "cliente_email"))
})
private DatosClienteEnPedido datosCliente;
}
@Embeddable
public record DatosClienteEnPedido(String nombre, String email) { }
Este patrón resuelve dos problemas de golpe. El técnico: tu servicio no necesita la base de datos ajena
ni se acopla a su esquema, y puede funcionar aunque el otro servicio esté caído. Y el de negocio, que es el
importante: los datos de una operación pasada no deben cambiar cuando el cliente se cambie el
nombre. Una factura de 2024 con la dirección de 2026 es un error contable, no una mejora. La copia no es
duplicación indebida: es la fotografía del momento, que es exactamente lo que el dominio necesita. Más sobre
contextos delimitados y anticorrupción en el módulo 08.
13 · Testing de la capa de datos
Un test de la capa de datos que pasa sin comprobar nada útil es peor que no tener test: da confianza falsa.
Esta sección es cómo escribir tests que de verdad detectan los problemas de esta capa: SQL que no compila,
migraciones que no cuadran con las entidades, N+1 que aparece con un cambio inocente y conflictos de
concurrencia.
13.1 @DataJpaTest: qué hace exactamente
@DataJpaTest
class PedidoRepositoryTest {
@Autowired PedidoRepository repositorio;
@Autowired TestEntityManager em;
@Test
void encuentra_pedidos_por_cliente_y_estado() {
Cliente cliente = em.persistAndFlush(unCliente().build());
em.persistAndFlush(unPedido().cliente(cliente).estado(PAGADO).build());
em.persistAndFlush(unPedido().cliente(cliente).estado(BORRADOR).build());
em.clear(); // ← IMPRESCINDIBLE: ver 13.4
List<Pedido> encontrados =
repositorio.findByClienteIdAndEstado(cliente.getId(), PAGADO);
assertThat(encontrados).hasSize(1);
}
}
@DataJpaTest configura | Detalle | Consecuencia práctica |
| Escaneo restringido | Solo @Entity, repositorios y @JdbcTest-slice | Tus @Service y @Component no están: si los necesitas, @Import |
@Transactional en cada test | Con rollback al terminar | Los tests no se contaminan entre sí, pero causa el falso verde de 13.4 |
TestEntityManager | Wrapper con persistAndFlush, find, clear | Permite preparar datos y limpiar el contexto sin ceremonia |
| Base de datos embebida si la encuentra | H2, HSQLDB o Derby en el classpath | Sustituye tu DataSource real sin avisar. Ver 13.2 |
| Flyway y Liquibase | Se ejecutan si están activos | Con Testcontainers, el esquema real |
| Registro de SQL | spring.jpa.show-sql=true | Ves el SQL de cada test en la consola |
| No configura | MockMvc, seguridad, @Cacheable, RestClient | Es un slice: para el flujo completo, @SpringBootTest |
13.2 H2 frente a Testcontainers: no es una cuestión de gustos
H2 en modo compatibilidad PostgreSQL no es PostgreSQL. Cada uno de estos ha causado un despliegue roto en
proyectos reales, con la batería de tests en verde:
jsonb, tsvector, arrays nativos, interval, tipos
range: no existen o se comportan distinto.
ON CONFLICT … DO UPDATE, FOR UPDATE SKIP LOCKED,
CREATE INDEX CONCURRENTLY: no soportados o silenciosamente ignorados.
- Window functions avanzadas,
DISTINCT ON, LATERAL: cambian de
semántica.
- El ordenamiento por defecto y la ordenación de cadenas (collation) difieren: tests que dependen
del orden pasan en H2 y fallan en producción.
- La sensibilidad a mayúsculas de los identificadores es distinta.
- Los niveles de aislamiento y el comportamiento MVCC son distintos: no puedes probar
concurrencia.
- Los mensajes y códigos de error son otros, así que tu manejo de
DataIntegrityViolationException no se prueba de verdad.
- Los planes de ejecución no tienen nada que ver: un test que va rápido en H2 puede ser un
Seq Scan de 4 segundos en producción.
Con Testcontainers, tus tests corren contra
el mismo PostgreSQL 16 que producción. No hay
ninguna razón técnica para no usarlo en 2026.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
/**
* Clase base con @ServiceConnection (Boot 3.1+): Spring configura la url, el
* usuario y la contraseña del contenedor automáticamente. Cero propiedades.
*
* El contenedor es STATIC, así que se comparte entre TODAS las clases de test
* que hereden de esta. Arrancar PostgreSQL cuesta ~2 s; hacerlo una vez por
* clase de test convierte una batería de 5 minutos en una de 40.
*/
@Testcontainers
public abstract class BaseDatosIT {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
// Acelera los tests: sin durabilidad, no hace fsync.
// Perder datos al matar el contenedor es justo lo que queremos.
.withCommand("postgres", "-c", "fsync=off",
"-c", "full_page_writes=off",
"-c", "synchronous_commit=off",
"-c", "max_connections=200")
.withReuse(true); // requiere testcontainers.reuse.enable=true
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // ← clave
class PedidoRepositoryIT extends BaseDatosIT {
// Sin Replace.NONE, @DataJpaTest sustituiría el contenedor por H2 si H2
// está en el classpath. Es la causa número uno de "pero yo configuré
// Testcontainers y sigue usando H2".
}
# ~/.testcontainers.properties (en tu máquina, no en el repositorio)
# Reutiliza los contenedores entre ejecuciones: la segunda vez arranca en 0 s
testcontainers.reuse.enable=true
Alternativa más rápida aún para desarrollo local: el modo singleton con esquema por clase.
Un solo contenedor para toda la ejecución de Maven y, en lugar de limpiar tablas entre clases, cada clase de
test usa su propio esquema (create schema test_<n>). Aísla de verdad y evita el coste de
truncar. Para la mayoría de proyectos, sin embargo, el contenedor estático compartido más
@Transactional con rollback ya es suficientemente rápido: mide antes de complicarte.
13.3 Datos de prueba: builders antes que SQL
// ✗ Frágil: 30 líneas de preparación en cada test, y cualquier campo nuevo
// obligatorio rompe los 80 tests a la vez.
Cliente c = new Cliente();
c.setEmail("a@b.com"); c.setNombre("Ana"); c.setPais("ES"); /* … */
// ✓ Un "object mother" con valores válidos por defecto y sobrescritura selectiva.
// El test declara SOLO lo que le importa, y así se lee la intención.
public final class Datos {
private static final AtomicInteger SEQ = new AtomicInteger();
public static Cliente.Builder unCliente() {
int n = SEQ.incrementAndGet();
return Cliente.builder()
.email("cliente" + n + "@ejemplo.es") // único: evita choques
.nombre("Cliente " + n)
.pais("ES")
.activo(true);
}
public static Pedido.Builder unPedido() {
return Pedido.builder()
.referencia("REF-" + SEQ.incrementAndGet())
.estado(EstadoPedido.BORRADOR)
.moneda("EUR");
}
public static Pedido unPedidoPagadoCon(int lineas) {
Pedido p = unPedido().estado(EstadoPedido.PAGADO).build();
for (int i = 0; i < lineas; i++)
p.anadirLinea(unProducto().build(), i + 1);
return p;
}
}
// En el test se lee lo que se prueba, no cómo se construye:
@Test
void un_pedido_pagado_no_admite_lineas_nuevas() {
Pedido pedido = Datos.unPedidoPagadoCon(2);
assertThatThrownBy(() -> pedido.anadirLinea(Datos.unProducto().build(), 1))
.isInstanceOf(EstadoPedidoInvalidoException.class);
}
// @Sql sigue siendo útil para escenarios grandes y para probar consultas
// contra un conjunto de datos concreto que sería tedioso construir en Java.
@Sql(scripts = "/datos/catalogo-completo.sql",
executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(statements = "delete from pedido; delete from cliente;",
executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
@Test
void el_informe_de_ventas_agrupa_por_categoria() { /* … */ }
| Técnica | Ventaja | Inconveniente | Cuándo |
| Builders / object mother | Legible, refactorizable, tipado | Hay que mantenerlo | Por defecto |
@Sql con script | Rápido, permite estados imposibles de crear por la API | Se desincroniza del esquema en silencio | Escenarios grandes, datos de referencia |
TestEntityManager | Control fino del contexto | Solo en @DataJpaTest | Preparar y limpiar dentro del test |
| Flyway con location de test | Datos compartidos por toda la batería | Acoplamiento global entre tests | Catálogos, países, tipos de IVA |
| Llamar a la API de la aplicación | Prueba el camino real | Lento, y un fallo de preparación parece un fallo del test | Tests de extremo a extremo |
13.4 El falso verde de @Transactional en los tests
Este es el error de testing más frecuente de toda la capa de datos, y el que más se pregunta en
entrevistas. Un test transaccional comparte el mismo contexto de persistencia entre la
preparación, la ejecución y las comprobaciones. Consecuencia: la caché de primer nivel devuelve los objetos que
tú acabas de crear en memoria, sin ir a la base de datos, y el test pasa aunque el mapeo, las restricciones y
el SQL estén mal.
// ✗ TEST QUE PASA SIEMPRE, incluso con el mapeo roto
@DataJpaTest
class PedidoTestFalsoVerde {
@Autowired PedidoRepository repositorio;
@Test
void guarda_y_recupera() {
Pedido p = new Pedido("REF-1");
p.setDescripcion("x".repeat(5000)); // la columna es varchar(200) (!)
repositorio.save(p); // no hace INSERT todavía
Pedido leido = repositorio.findById(p.getId()).orElseThrow();
assertThat(leido.getDescripcion()).hasSize(5000); // ✓ PASA
// ¿Por qué? Porque findById encuentra p en la caché de primer nivel y
// devuelve LA MISMA INSTANCIA. No ha habido ni INSERT ni SELECT.
// En producción: ERROR: value too long for type character varying(200)
}
}
// ✓ CORRECTO: forzar el viaje a la base de datos
@Test
void guarda_y_recupera_de_verdad() {
Pedido p = new Pedido("REF-1");
p.setDescripcion("x".repeat(5000));
repositorio.saveAndFlush(p); // fuerza el INSERT → aquí ya explota
em.clear(); // vacía la caché de primer nivel
Pedido leido = repositorio.findById(p.getId()).orElseThrow();
// Ahora sí hay un SELECT real y "leido" es una instancia nueva
assertThat(leido).isNotSameAs(p);
assertThat(leido.getDescripcion()).hasSize(5000);
}
| Lo que oculta un test transaccional | Cómo destaparlo |
Columna demasiado corta, NOT NULL incumplido, check violado | flush() antes de comprobar |
LazyInitializationException (dentro del test la sesión está abierta) | Un test @SpringBootTest sin @Transactional que llame al servicio real |
| El N+1 (las entidades ya están en la caché) | em.clear() + contador de consultas (13.5) |
| Que la entidad no se persiste realmente (cascada olvidada) | flush() + clear() + recarga |
| Que el trigger o la columna generada no funcionan | refresh() tras el flush |
Conflictos de concurrencia y @Version | Dos transacciones reales en hilos distintos (13.6) |
Que el commit falla (restricciones diferidas, triggers AFTER) | @Commit o TransactionTemplate explícito |
// Utilidad que resuelve el 90 % de los casos: ejecutar en transacciones
// SEPARADAS y de verdad confirmadas, como en producción.
@SpringBootTest
class PedidoServiceIT extends BaseDatosIT {
@Autowired TransactionTemplate tx;
@Autowired PedidoService servicio;
@Autowired PedidoRepository repositorio;
@AfterEach
void limpiar() {
// Sin @Transactional en la clase, hay que limpiar a mano.
// Es el precio de tener tests realistas, y merece la pena.
tx.executeWithoutResult(e -> repositorio.deleteAllInBatch());
}
@Test
void pagar_un_pedido_confirma_los_cambios() {
Long id = tx.execute(e -> repositorio.save(unPedido().build()).getId());
servicio.pagar(id); // transacción propia, con COMMIT real
// Transacción NUEVA: lee de la base de datos, no de ninguna caché
EstadoPedido estado = tx.execute(e ->
repositorio.findById(id).orElseThrow().getEstado());
assertThat(estado).isEqualTo(EstadoPedido.PAGADO);
}
}
13.5 Tests que cuentan consultas: el antídoto contra el N+1
El N+1 no se detecta leyendo código: aparece cuando alguien añade un getX() en una plantilla o un
mapper tres meses después. La única defensa duradera es un test que falle si el número de consultas
cambia.
<dependency>
<groupId>net.ttddyy</groupId>
<artifactId>datasource-proxy</artifactId>
<version>1.10</version>
<scope>test</scope>
</dependency>
/** Envuelve el DataSource de test para contar y registrar cada consulta. */
@TestConfiguration
public class ContadorConsultasConfig {
@Bean
static BeanPostProcessor envolverDataSource(ContadorConsultas contador) {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String nombre) {
if (bean instanceof DataSource ds && !(bean instanceof ProxyDataSource)) {
return ProxyDataSourceBuilder.create(ds)
.name("contado")
.listener(contador)
.logQueryBySlf4j(SLF4JLogLevel.DEBUG)
.multiline()
.build();
}
return bean;
}
};
}
@Bean ContadorConsultas contadorConsultas() { return new ContadorConsultas(); }
}
public class ContadorConsultas implements QueryExecutionListener {
private final List<String> consultas = Collections.synchronizedList(new ArrayList<>());
@Override public void beforeQuery(ExecutionInfo i, List<QueryInfo> qs) { }
@Override
public void afterQuery(ExecutionInfo info, List<QueryInfo> qs) {
qs.forEach(q -> consultas.add(q.getQuery()));
}
public void reiniciar() { consultas.clear(); }
public int total() { return consultas.size(); }
public long contarSelect() {
return consultas.stream()
.filter(q -> q.trim().regionMatches(true, 0, "select", 0, 6))
.count();
}
/** Mensaje de fallo útil: no solo "esperaba 2, había 11". */
public String informe() {
return IntStream.range(0, consultas.size())
.mapToObj(i -> " %2d. %s".formatted(i + 1, resumir(consultas.get(i))))
.collect(Collectors.joining("\n", "Consultas ejecutadas:\n", ""));
}
private String resumir(String sql) {
String s = sql.replaceAll("\\s+", " ").trim();
return s.length() > 140 ? s.substring(0, 140) + "…" : s;
}
}
@SpringBootTest
@Import(ContadorConsultasConfig.class)
class ListadoPedidosConsultasIT extends BaseDatosIT {
@Autowired ContadorConsultas contador;
@Autowired PedidoConsultaService servicio;
@Autowired TransactionTemplate tx;
@BeforeEach
void preparar() {
tx.executeWithoutResult(e -> {
for (int i = 0; i < 20; i++) repositorio.save(unPedidoPagadoCon(3));
});
contador.reiniciar(); // no cuentes la preparación
}
@Test
void el_listado_usa_un_numero_constante_de_consultas() {
List<PedidoResumen> resultado = servicio.listar(PageRequest.of(0, 20));
assertThat(resultado).hasSize(20);
// Con 20 pedidos y 3 líneas cada uno, un N+1 daría 21 o 81 consultas.
// Este assert es el que avisará a quien añada un getLineas() de más.
assertThat(contador.contarSelect())
.as(contador.informe()) // ← el informe sale en el fallo
.isEqualTo(1);
}
@Test
void el_numero_de_consultas_no_depende_del_numero_de_datos() {
// El test definitivo contra el N+1: la misma operación con 5 y con 50
// elementos debe hacer EXACTAMENTE las mismas consultas.
contador.reiniciar();
servicio.listar(PageRequest.of(0, 5));
long con5 = contador.contarSelect();
contador.reiniciar();
servicio.listar(PageRequest.of(0, 50));
long con50 = contador.contarSelect();
assertThat(con50).as(contador.informe()).isEqualTo(con5);
}
}
Y la versión sin dependencias, con las estadísticas de Hibernate. Menos precisa (agrupa por sesión) pero
suficiente para muchos casos y sin añadir nada al proyecto:
@Autowired EntityManagerFactory emf;
private Statistics estadisticas() {
Statistics s = emf.unwrap(SessionFactory.class).getStatistics();
s.clear();
return s;
}
@Test
void sin_n_mas_uno() {
Statistics stats = estadisticas();
servicio.listar(PageRequest.of(0, 20));
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
}
Requiere
spring.jpa.properties.hibernate.generate_statistics=true en el perfil de test.
13.6 Tests de migraciones y de concurrencia
/**
* El test de migraciones más valioso y el más barato: si Flyway aplica todas
* las migraciones y ddl-auto=validate no protesta, el esquema y las entidades
* están de acuerdo. Este único test detecta el 90 % de los olvidos.
*/
@SpringBootTest
class EsquemaCoherenteIT extends BaseDatosIT {
@Test
void las_migraciones_producen_un_esquema_compatible_con_las_entidades() {
// Si llegamos aquí, el contexto arrancó: Flyway migró y validate pasó.
}
}
/** Y uno que comprueba que ninguna migración se ha editado a posteriori. */
@SpringBootTest
class MigracionesIT extends BaseDatosIT {
@Autowired Flyway flyway;
@Test
void todas_las_migraciones_estan_aplicadas_y_validadas() {
flyway.validate(); // lanza si hay problemas
MigrationInfoService info = flyway.info();
assertThat(info.pending()).as("migraciones pendientes").isEmpty();
assertThat(info.applied()).isNotEmpty();
assertThat(Arrays.stream(info.applied())
.allMatch(m -> m.getState().isApplied())).isTrue();
}
@Test
void ninguna_migracion_contiene_sentencias_prohibidas() throws Exception {
// Test de estilo: barato y evita incidentes. Ajusta la lista a tu criterio.
List<String> prohibidas = List.of("drop database", "flyway clean",
"create index " /* sin concurrently */);
try (Stream<Path> ficheros =
Files.walk(Path.of("src/main/resources/db/migration"))) {
ficheros.filter(p -> p.toString().endsWith(".sql")).forEach(p -> {
String sql = leer(p).toLowerCase(Locale.ROOT);
prohibidas.forEach(mala -> assertThat(sql)
.as("%s contiene '%s'", p.getFileName(), mala)
.doesNotContain(mala));
});
}
}
}
/** Test de concurrencia real: dos hilos, dos transacciones, un conflicto. */
@SpringBootTest
class BloqueoOptimistaIT extends BaseDatosIT {
@Autowired PedidoService servicio;
@Autowired TransactionTemplate tx;
@Test
void dos_actualizaciones_simultaneas_una_falla_con_conflicto() throws Exception {
Long id = tx.execute(e -> repositorio.save(unPedido().build()).getId());
// Dos barreras para forzar el entrelazado: sin ellas, el test es
// no determinista y "pasa" casi siempre por casualidad.
CyclicBarrier ambosHanLeido = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<Optional<Throwable>> tarea = () -> {
try {
tx.executeWithoutResult(e -> {
Pedido p = repositorio.findById(id).orElseThrow();
esperar(ambosHanLeido); // los dos han leído version=0
p.cambiarDireccion("Calle " + Thread.currentThread().getId());
repositorio.saveAndFlush(p); // el segundo choca aquí
});
return Optional.empty();
} catch (Throwable t) {
return Optional.of(t);
}
};
List<Future<Optional<Throwable>>> res = pool.invokeAll(List.of(tarea, tarea));
pool.shutdown();
List<Throwable> fallos = res.stream().map(this::get)
.flatMap(Optional::stream).toList();
assertThat(fallos).hasSize(1);
assertThat(fallos.getFirst())
.isInstanceOf(ObjectOptimisticLockingFailureException.class);
// Y lo más importante: la versión avanzó exactamente una vez
long version = tx.execute(e ->
repositorio.findById(id).orElseThrow().getVersion());
assertThat(version).isEqualTo(1L);
}
}
Los tests de concurrencia sin barreras son ruido. Lanzar dos hilos y esperar que choquen produce un test
que pasa el 95 % de las veces y falla el otro 5 % sin motivo aparente: el peor tipo de test, porque
acaba desactivado. Usa CyclicBarrier o CountDownLatch para forzar el punto exacto de
entrelazado que quieres probar, y ejecuta las dos ramas en transacciones distintas de verdad. Si el test no es
determinista, no es un test.
13.7 Lista de comprobación de testing de datos
14 · Diagnóstico en producción
Son las 10:40, la API responde en 8 segundos y el jefe de producto está preguntando en el canal. Esta sección
es el orden en que hay que mirar las cosas para encontrar la causa en minutos en lugar de horas, con los
comandos concretos.
14.1 El orden correcto de las preguntas
API LENTA · árbol de decisión
1 · ¿Está el POOL AGOTADO?
hikaricp.connections.pending > 0 durante segundos
├─ SÍ → alguien retiene conexiones. Ve al paso 2.
└─ NO → las conexiones están libres: el problema es en la BD o en el código.
Ve al paso 4.
2 · ¿Hay TRANSACCIONES ABIERTAS SIN ACTIVIDAD?
select * from pg_stat_activity where state = 'idle in transaction'
├─ SÍ → una transacción abierta esperando algo que no es la BD:
│ casi siempre una LLAMADA HTTP dentro de @Transactional (8.9),
│ o un @Transactional en un método que hace trabajo en la JVM.
└─ NO → ve al paso 3.
3 · ¿Hay CONSULTAS LARGAS o BLOQUEOS?
select pid, now()-query_start as duracion, wait_event_type, query
from pg_stat_activity where state='active' order by duracion desc;
├─ wait_event_type='Lock' → contención de bloqueos. Ve a 14.4.
└─ duración alta y activa → consulta lenta: falta índice o el plan cambió.
EXPLAIN (ANALYZE, BUFFERS). Ver módulo 06.
4 · ¿CUÁNTAS CONSULTAS hace cada petición?
hibernate.query.executions o el contador de datasource-proxy en el log
├─ Decenas o cientos por petición → N+1 (sección 7). LA CAUSA MÁS FRECUENTE
│ y la que más se disfraza de "la base de datos va lenta".
└─ Pocas y lentas → problema de consulta o de datos, no de JPA.
5 · ¿Ha cambiado algo?
Un despliegue, un crecimiento de datos que cruzó un umbral (el planificador
cambió de Index Scan a Seq Scan), un ANALYZE que no se ejecutó, una tabla
que se infló por falta de VACUUM.
NOTA CLAVE: en la gran mayoría de incidentes de "la base de datos va lenta" en
aplicaciones con JPA, la base de datos está aburrida. El problema es el NÚMERO
de consultas, no su velocidad. Empieza siempre por el paso 4 si puedes.
14.2 Métricas que hay que tener antes del incidente
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # /readiness y /liveness para Kubernetes
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: true
hikaricp.connections.acquire: true
slo:
http.server.requests: 50ms,100ms,300ms,1s,3s
spring:
jpa:
properties:
hibernate:
generate_statistics: true # necesario para las métricas de Hibernate
datasource:
hikari:
register-mbeans: true
# El nombre del pool aparece en las métricas: útil con varios datasources
pool-name: principal
| Métrica | Qué significa | Umbral de alarma |
hikaricp.connections.pending | Hilos esperando una conexión | > 0 sostenido: el síntoma más claro de saturación |
hikaricp.connections.acquire (p99) | Tiempo en obtener una conexión | > 50 ms |
hikaricp.connections.usage (p99) | Cuánto se retiene una conexión | > 1 s: transacciones demasiado largas |
hikaricp.connections.active / .max | Ocupación del pool | > 80 % sostenido |
hikaricp.connections.timeout | SQLTransientConnectionException | > 0: ya hay peticiones fallando |
hibernate.query.executions (tasa) | Consultas por segundo | Salto sin aumento de tráfico = N+1 nuevo |
hibernate.sessions.open | Sesiones abiertas | Crecimiento sostenido = fuga |
hibernate.transactions (result=failure) | Rollbacks | Subida = conflictos o errores nuevos |
hibernate.second.level.cache.* | Aciertos y fallos de L2 | Ratio de acierto < 80 % = caché inútil |
| Consultas por petición (derivada) | hibernate.query.executions ÷ http.server.requests | La métrica más útil de todas. Cualquier salto es un N+1 |
// La métrica derivada más valiosa, calculada en la aplicación
@Configuration
@RequiredArgsConstructor
public class MetricasJpaConfig {
@Bean
MeterBinder consultasPorPeticion(EntityManagerFactory emf) {
return registro -> {
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
Gauge.builder("jpa.consultas.por.peticion", () -> {
double peticiones = registro.find("http.server.requests")
.timers().stream()
.mapToDouble(Timer::count).sum();
return peticiones == 0 ? 0
: stats.getPrepareStatementCount() / peticiones;
})
.description("Media de sentencias JDBC por petición HTTP")
.register(registro);
};
}
}
// Y un filtro que avisa POR PETICIÓN cuando se pasa de la raya.
// Detecta el N+1 en el log, con la URL exacta, sin necesidad de un APM.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
@Profile("!prod-critico") // en producción, muestrea en lugar de todas
public class AvisoConsultasExcesivasFilter extends OncePerRequestFilter {
private static final int UMBRAL = 30;
private final SessionFactory sf;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain cadena) throws IOException, ServletException {
Statistics s = sf.getStatistics();
long antes = s.getPrepareStatementCount();
try {
cadena.doFilter(req, res);
} finally {
long consultas = s.getPrepareStatementCount() - antes;
if (consultas > UMBRAL) {
log.warn("Posible N+1: {} {} ejecutó {} sentencias",
req.getMethod(), req.getRequestURI(), consultas);
}
}
}
}
// ⚠ Statistics es global a la SessionFactory, así que con varias peticiones
// concurrentes el número es aproximado. Para exactitud, usa datasource-proxy
// con un contador en ThreadLocal (o ScopedValue en Java 21+).
14.3 pg_stat_activity: las cinco consultas que salvan el día
-- 1) ¿Qué está pasando AHORA MISMO? La primera consulta de todo incidente.
select pid,
now() - query_start as duracion,
now() - xact_start as duracion_transaccion,
state,
wait_event_type, wait_event,
application_name,
left(query, 120) as consulta
from pg_stat_activity
where datname = current_database()
and pid <> pg_backend_pid()
order by xact_start nulls last;
-- 2) TRANSACCIONES ABIERTAS SIN HACER NADA. El síntoma de una llamada remota
-- dentro de @Transactional o de un @Transactional mal colocado.
select pid, now() - xact_start as abierta_desde, state, left(query,150)
from pg_stat_activity
where state = 'idle in transaction'
and now() - xact_start > interval '10 seconds'
order by xact_start;
-- Cada una de estas retiene una conexión, mantiene bloqueos y BLOQUEA EL
-- VACUUM de toda la base de datos. Una sola, olvidada, puede degradar el
-- rendimiento global durante horas.
-- 3) ¿QUIÉN BLOQUEA A QUIÉN? (PostgreSQL 9.6+)
select bloqueada.pid as pid_bloqueado,
bloqueada.usename as usuario_bloqueado,
left(bloqueada.query,80) as consulta_bloqueada,
bloqueante.pid as pid_bloqueante,
left(bloqueante.query,80) as consulta_bloqueante,
bloqueante.state as estado_bloqueante,
now() - bloqueada.query_start as esperando_desde
from pg_stat_activity bloqueada
join pg_stat_activity bloqueante
on bloqueante.pid = any(pg_blocking_pids(bloqueada.pid))
where cardinality(pg_blocking_pids(bloqueada.pid)) > 0;
-- 4) Conexiones por aplicación y estado: ¿quién se come el límite?
select application_name, state, count(*)
from pg_stat_activity
group by 1, 2
order by 3 desc;
-- 5) Las consultas más costosas ACUMULADAS (requiere pg_stat_statements)
select calls,
round(total_exec_time) as ms_total,
round(mean_exec_time, 2) as ms_media,
rows / greatest(calls,1) as filas_por_llamada,
left(query, 100) as consulta
from pg_stat_statements
where dbid = (select oid from pg_database where datname = current_database())
order by total_exec_time desc
limit 20;
-- LA CLAVE PARA DETECTAR N+1 DESDE LA BASE DE DATOS: busca una consulta
-- trivial (select … from pedido where id=$1) con MILLONES de "calls" y
-- mean_exec_time de 0,2 ms. Individualmente es perfecta; el problema es
-- que se ejecuta un millón de veces. total_exec_time la delata.
-- Cómo cortar una consulta o transacción problemática (en este orden)
select pg_cancel_backend(12345); -- cancela la consulta, la sesión sigue
select pg_terminate_backend(12345); -- mata la sesión: solo si lo anterior falla
-- ⚠ pg_terminate_backend provoca un error en la aplicación y, si había una
-- transacción, un rollback. Es correcto, pero asegúrate de que el código lo
-- maneja: es exactamente el escenario que un reintento debe cubrir.
-- Y la prevención, mucho mejor que la cura: límites por rol
alter role app_tienda set statement_timeout = '10s';
alter role app_tienda set idle_in_transaction_session_timeout = '30s';
alter role app_tienda set lock_timeout = '3s';
-- Con esto, una transacción olvidada se cierra sola en 30 segundos y no puede
-- degradar la base de datos durante horas. Es la red de seguridad que hace que
-- un bug de la aplicación no se convierta en un incidente de plataforma.
14.4 Por qué un pool pequeño suele ir más rápido
Resultado contraintuitivo pero medido una y otra vez (el clásico experimento
de Oracle con 9.500 usuarios simultáneos):
Pool de 2048 conexiones → latencia media 100 ms
Pool de 96 conexiones → latencia media 2 ms
MISMA carga, MISMO hardware, 50 veces más rápido con 20 veces menos conexiones.
¿POR QUÉ?
· Una base de datos ejecuta trabajo real en paralelo limitado por el número
de núcleos y de husos del disco. Con 8 núcleos, más de ~16 consultas
verdaderamente concurrentes no van más rápido: se turnan.
· Cada conexión extra añade cambio de contexto, presión de caché de CPU,
memoria (work_mem POR OPERACIÓN, no por conexión) y contención de latches.
· Con 2048 conexiones compitiendo, la base de datos pasa más tiempo
coordinando que trabajando.
· Además, un pool grande OCULTA los problemas: absorbe las fugas de conexión
y las transacciones largas hasta que un día ya no puede, y entonces el
fallo es súbito y total.
LA FÓRMULA (Hikari):
conexiones = ((núcleos_bd × 2) + husos_efectivos_de_disco)
Con 8 núcleos y SSD (husos ≈ 1): (8 × 2) + 1 = 17
→ 16-20 conexiones para TODA la aplicación, repartidas entre instancias.
Con 6 réplicas de la aplicación y max_connections=100 en PostgreSQL:
6 × 16 = 96 conexiones. Justo al límite: reduce a 10 por instancia (60)
y deja margen para migraciones, monitorización y psql de emergencia.
SI NECESITAS MÁS CONEXIONES DE LAS QUE CABEN:
· PgBouncer en modo transaction: multiplexa miles de clientes sobre decenas
de conexiones reales. Ojo: en modo transaction NO funcionan las sentencias
preparadas con nombre (usa prepareThreshold=0 o PgBouncer 1.21+ con
max_prepared_statements) ni las consultas con estado de sesión.
· Reducir el TIEMPO que retienes cada conexión: es casi siempre la solución
correcta. Una conexión retenida 20 ms sirve 50 peticiones por segundo.
· Separar las lecturas a una réplica (sección 2.5).
14.5 Timeouts en cadena
La regla: cada capa debe rendirse ANTES que la de abajo. Si no, se acumulan
esperas y el sistema se degrada en cascada en lugar de fallar rápido.
Cliente / navegador 30 s
└─ Ingress / balanceador 25 s
└─ Timeout de la petición HTTP 20 s (spring.mvc.async.request-timeout)
└─ @Transactional(timeout) 10 s
└─ statement_timeout (PG) 8 s
└─ lock_timeout 3 s
└─ connection-timeout (Hikari) 2 s ← fallar rápido si no hay pool
└─ RestClient a otro servicio 3 s ← ¡FUERA de la transacción!
Y el orden importa: si connection-timeout (2 s) fuera mayor que el timeout de
la petición (20 s), las peticiones se acumularían esperando conexiones que
nunca llegan, y el pool se convertiría en una cola infinita.
spring:
datasource:
hikari:
connection-timeout: 2000 # fallar rápido, no encolar
validation-timeout: 1000
max-lifetime: 1200000 # 20 min, menor que el de la BD/balanceador
idle-timeout: 300000
leak-detection-threshold: 20000 # avisa de conexiones retenidas > 20 s
transaction:
default-timeout: 10 # segundos, para TODA transacción
jpa:
properties:
hibernate:
jdbc:
time_zone: UTC
"[jakarta.persistence.query.timeout]": 8000 # ms, por consulta
mvc:
async:
request-timeout: 20000
# Y en la propia base de datos, como red de seguridad final (14.3)
14.6 Plan de acción: HikariPool-1 - Connection is not available
HikariPool-1 - Connection is not available, request timed out after 2000ms
(total=20, active=20, idle=0, waiting=17)
TRADUCCIÓN: las 20 conexiones están ocupadas, 17 hilos esperan y llevamos 2 s.
El mensaje ya te dice mucho: si active=20 e idle=0, alguien NO devuelve.
PASO 1 · ¿Es carga o es una fuga? (30 segundos)
Mira hikaricp.connections.usage p99:
· < 100 ms → es CARGA legítima: hay más tráfico del que el pool soporta.
· > 1 s → alguien RETIENE conexiones: fuga o transacción larga.
· Creciendo sin parar → FUGA: conexiones que nunca se devuelven.
PASO 2 · Si son transacciones largas: encuéntralas
a) En la base de datos:
select pid, now()-xact_start, state, left(query,100)
from pg_stat_activity where state='idle in transaction';
'idle in transaction' = transacción abierta sin trabajo en la BD.
→ Casi siempre una llamada HTTP o de mensajería dentro de @Transactional.
b) En la aplicación: activa leak-detection-threshold: 20000 y busca en el log
"Connection leak detection triggered for … on thread http-nio-8080-exec-3"
Hikari imprime la TRAZA DE PILA de quien pidió la conexión: ahí está el
método culpable, con nombre y línea.
PASO 3 · Mitigación inmediata (en este orden de preferencia)
1. Revertir el último despliegue si el problema empezó con él. Casi siempre
es un @Transactional nuevo mal puesto o un N+1 recién introducido.
2. Matar las transacciones colgadas: pg_terminate_backend(pid).
3. Reiniciar las instancias afectadas: vacía el pool. Vuelve en unos minutos
si la causa sigue ahí, pero te da aire para diagnosticar.
⚠ NO subir el tamaño del pool como primera reacción: si hay una fuga, con 60
conexiones tardará el triple en agotarse y habrás empeorado el rendimiento
de la base de datos entretanto (14.4). Solo súbelo si mediste que es carga.
PASO 4 · Corrección
· Llamadas remotas fuera de la transacción (8.9).
· @Transactional solo en el servicio y solo alrededor del acceso a datos.
· open-in-view: false (8.10), para que la conexión no viva toda la petición.
· statement_timeout e idle_in_transaction_session_timeout en el rol de la
aplicación: la red de seguridad que evita la repetición (14.3).
· Reducir consultas por petición: menos N+1, menos tiempo de conexión.
PASO 5 · Prevención
· Alerta en hikaricp.connections.pending > 0 durante 30 s.
· Alerta en hikaricp.connections.usage p99 > 1 s.
· leak-detection-threshold activo también en producción: el coste es nulo.
· Un test de carga que agote el pool a propósito y verifique que la
aplicación devuelve 503 con degradación elegante, en lugar de colgarse.
| Síntoma en las métricas | Diagnóstico | Acción |
active alto, usage p99 bajo, muchas consultas/petición | N+1 o demasiadas consultas | Sección 7: @EntityGraph, proyecciones |
active alto, usage p99 muy alto, BD ociosa | Transacción abierta esperando algo externo | Sección 8.9: llamadas fuera de la transacción |
active alto y BD al 100 % de CPU | Consultas lentas de verdad | pg_stat_statements, EXPLAIN, índices (módulo 06) |
active crece y no baja nunca | Fuga de conexiones | leak-detection-threshold y revisar el código señalado |
Muchos rollback y OptimisticLock | Contención en las mismas filas | Sección 9: bloqueo pesimista, SKIP LOCKED, sentencias atómicas |
wait_event_type = 'Lock' frecuente | Contención de bloqueos o un DDL esperando | pg_blocking_pids, lock_timeout, orden canónico |
| Latencia que crece con el tiempo de uptime | Bloat por VACUUM bloqueado | Buscar transacciones largas; revisar pg_stat_user_tables.n_dead_tup |
Lo que hay que dejar preparado hoy para el incidente de dentro de tres meses: métricas de Hikari e
Hibernate en el dashboard, la métrica de consultas por petición, pg_stat_statements
activado, leak-detection-threshold puesto, los timeouts en cadena configurados y los
límites por rol en la base de datos. Todo esto se configura en una tarde y es la diferencia entre resolver un
incidente en veinte minutos o pasar la noche adivinando.
15 · Preguntas frecuentes
Batería rápida para comprobar que el módulo quedó asimilado. Si no puedes responder en voz alta, vuelve a la sección citada.
¿Qué idea de este módulo explicaría primero en una entrevista?
La que conecta el problema de negocio con la solución técnica y sus contrapartidas. No recites APIs: cuenta un caso, una decisión y qué descartaste.
¿Cómo sé si lo he entendido de verdad?
Si puedes escribir un ejemplo mínimo de memoria, explicar el fallo típico y decir cuándo no usar la técnica. La checklist del final de cada sección es el listón.
¿Qué debo practicar con teclado y no solo leer?
Todo lo que tenga bloque de código en el módulo: cópialo, rómpelo, mídelo. La lectura sin ejecución no fija el contrato de equals, un plan de ejecución o un probe de Kubernetes.
¿Cómo relaciono este módulo con el proyecto final?
Cada concepto debe aparecer en el repositorio del módulo 13 (Cafetería Tech / MiniShop): un commit, un test o una decisión documentada. Si no aparece, no cuenta como aprendido.
¿Qué preguntas trampa debo anticipar?
Las que piden el por qué y el cuándo no. Prepárate a decir “depende” seguido de dos criterios medibles, no de una preferencia estética.
¿Cuánto tiempo debo dedicarle a este módulo en el plan?
El que indica el badge de la cabecera. Si vas corto de días, prioriza las secciones marcadas como críticas en el índice y los ejercicios numerados; deja el resto para el repaso del fin de semana.
¿Qué hago si un ejemplo no compila con mi versión?
Comprueba Java 21+ y Spring Boot 3.x. Las APIs nuevas (virtual threads, RestClient, ProblemDetail) no están en Java 8 ni en Spring Boot 2. Ajusta o sube versión; no “arregles” degradando el ejemplo.
¿Debo memorizar flags, anotaciones y comandos?
Memoriza el mapa mental y tres ejemplos. Los flags exactos se consultan; lo que se evalúa es saber cuál buscar y por qué lo necesitas.
¿Cómo evito estudiar en modo pasivo?
Cierra el HTML y escribe de memoria: un test, una entidad, un Dockerfile o una respuesta de entrevista de 90 segundos. Luego contrasta. Ese ciclo es el 70 % teclado del plan.
¿Qué enlazo con otros módulos?
Usa el aside y los enlaces internos. Persistencia remite a SQL (06) y a Spring Boot (04); despliegue a microservicios (08) y seguridad (10). No dupliques: profundiza donde el plan te manda.
¿Cómo demuestro esto en el CV o en GitHub?
Con un commit claro, un test que falle sin el arreglo, y una línea en el README del proyecto (“detectamos N+1 / OOMKilled / … y lo medimos”). Evidencia > adjetivos.
Si solo me queda una hora, ¿qué hago?
Lee la sección de errores comunes, responde tres FAQ en voz alta y marca dos ejercicios como hechos solo si los has ejecutado. Mejor poco sólido que mucho subrayado.
16 · Ejercicios y retos
17 · Resumen y recursos
Qué debes recordar
- El por qué manda sobre la lista de APIs.
- Mide antes de optimizar; los síntomas engañan.
- Documenta decisiones y contrapartidas en el proyecto.
- Los tests y la observabilidad cierran el aprendizaje.
Siguiente paso
- Completa las checklists marcadas arriba.
- Pasa al módulo siguiente solo con los ejercicios 1–3 hechos.
- Anota dudas para el simulacro del módulo 12.
Cierre: no intentes dominar todo el módulo de una sentada. Domina el núcleo, demuéstralo con código y vuelve a las secciones avanzadas cuando el proyecto te las exija.