@Transactional ha aparecido en todos los ejemplos de las tres lecciones anteriores sin que la hayamos explicado. La hemos puesto en los servicios, la hemos necesitado en @Modifying, hemos dicho que el contexto de persistencia vive lo que dura la transacción, que el dirty checking guarda sin llamar a save() y que readOnly = true optimiza algo. Todo eso son afirmaciones pendientes de justificación. Esta lección las justifica.
Y va más allá, porque las transacciones son donde la corrección de CicloUrbana se juega de verdad. Iniciar un alquiler exige marcar la bicicleta como alquilada y crear el registro de alquiler: si ocurre lo primero sin lo segundo, una bicicleta queda bloqueada para siempre sin que nadie la tenga; si ocurre lo segundo sin lo primero, dos ciudadanos de Ribalta pueden alquilar la misma bicicleta. La transacción es lo que impide que exista un estado intermedio. Veremos también las dos trampas que hacen que @Transactional a veces no haga nada en absoluto, sin ningún aviso: son responsables de una cantidad desproporcionada de errores en producción.
Contenido
- Qué es una transacción: ACID en CicloUrbana
- Gestión declarativa con
@Transactional - Dónde se pone y por qué
- Cómo funciona por dentro: el proxy AOP
- Trampa 1: la autoinvocación
- Trampa 2: los métodos no públicos
propagation: los siete valoresisolation: los cuatro nivelesreadOnly,timeouty los atributos restantes- La regla del rollback
- El contexto de persistencia y el flush
- Bloqueo pesimista frente a optimista
TransactionTemplate: gestión programática- Eventos transaccionales
- Transacciones y
LazyInitializationException - Errores Comunes y Consejos
- Ejercicios
- Qué es una transacción: ACID en CicloUrbana
Una transacción es una unidad de trabajo que se ejecuta entera o nada. Su definición clásica son las cuatro propiedades ACID, y conviene verlas sobre el caso real: iniciar un alquiler en la estación «Plaza Mayor».
@Transactional
public AlquilerResponse iniciar(IniciarAlquilerRequest peticion) {
Bicicleta bicicleta = bicicletaRepositorio.findById(peticion.bicicletaId())
.orElseThrow(() -> new RecursoNoEncontradoException("Bicicleta", peticion.bicicletaId()));
if (!bicicleta.puedeAlquilarse(redProperties.umbralBateria())) {
throw new BicicletaNoDisponibleException(bicicleta.getMatricula());
}
bicicleta.setEstado(EstadoBicicleta.EN_USO); // (1)
bicicleta.setEstacion(null); // (2) sale de la estación
Alquiler alquiler = new Alquiler();
alquiler.setUsuario(usuarioRepositorio.getReferenceById(peticion.usuarioId()));
alquiler.setBicicleta(bicicleta);
alquiler.setEstacionOrigen(
estacionRepositorio.getReferenceById(peticion.estacionOrigenId()));
alquiler.setInicio(Instant.now());
alquilerRepositorio.save(alquiler); // (3)
eventos.publishEvent(new AlquilerIniciado(alquiler.getId(), Instant.now()));
return mapper.aRespuesta(alquiler);
}Las tres operaciones marcadas deben ocurrir juntas.
| Propiedad | Qué garantiza | En CicloUrbana |
|---|---|---|
| Atomicidad | Todo o nada | Si el INSERT del alquiler falla, la bicicleta vuelve a DISPONIBLE |
| Consistencia | Se respetan las restricciones | No se puede crear un alquiler con una bicicleta_id inexistente |
| Aislamiento | Las transacciones concurrentes no se pisan | Dos usuarios no alquilan la misma bicicleta |
| Durabilidad | Confirmado es permanente | Tras el 200 OK, un corte de luz no pierde el alquiler |
La atomicidad se ve mejor con el contraejemplo. Sin transacción, cada operación de repositorio se confirma por separado: el UPDATE bicicletas SET estado='EN_USO' se confirma, y si el INSERT INTO alquileres falla después, la bicicleta RB-0142 queda EN_USO sin ningún alquiler que la respalde.
Ese estado inconsistente es especialmente dañino porque no produce ningún error visible: la bicicleta simplemente desaparece del inventario disponible y nadie sabe por qué. Con @Transactional, la excepción provoca un rollback y ambos cambios se deshacen.
- Gestión declarativa con
@Transactional
@TransactionalSpring ofrece dos formas de gestionar transacciones: declarativa (una anotación) y programática (código explícito). La declarativa es la habitual porque separa la gestión de la lógica de negocio.
Es todo. Detrás de esa anotación ocurre lo siguiente: se obtiene una conexión del pool y se pone en autoCommit = false; se abre un contexto de persistencia asociado a la transacción; se ejecuta el método; si termina bien se hace flush y commit, y si lanza una excepción no comprobada, rollback; y finalmente se cierra el contexto y la conexión vuelve al pool.
Importa el import. Hay dos anotaciones con el mismo nombre:
| Anotación | Origen | Recomendación |
|---|---|---|
org.springframework.transaction.annotation.Transactional |
Spring | Úsala. Tiene todos los atributos |
jakarta.transaction.Transactional |
Jakarta EE | Funciona, pero sin readOnly, isolation ni timeout |
En CicloUrbana usamos siempre la de Spring.
- Dónde se pone y por qué
La respuesta corta: en la capa de servicio. La larga explica por qué no en las otras dos.
| Capa | ¿@Transactional? |
Por qué |
|---|---|---|
| Controlador | No | La transacción viviría durante la serialización JSON, reteniendo una conexión del pool; además mezcla responsabilidades |
| Servicio | Sí | Es donde vive el caso de uso, la unidad de trabajo con sentido de negocio |
| Repositorio | No (ya la tiene) | SimpleJpaRepository está anotado; cada método es su propia transacción si no hay una en curso |
Por qué el servicio es la frontera correcta. Un caso de uso —«iniciar un alquiler»— es exactamente lo que debe ser atómico. Un método de repositorio es demasiado pequeño: si iniciar fuera la suma de tres transacciones independientes, no habría atomicidad. Un controlador es demasiado grande: incluye validación, mapeo y serialización, trabajo que no necesita una conexión abierta. El patrón recomendado, que ya usamos en 04-05:
@Service
@Transactional(readOnly = true) // por defecto: solo lectura
public class AlquilerService {
@Transactional // los que escriben lo declaran explícitamente
public AlquilerResponse iniciar(IniciarAlquilerRequest peticion) { /* ... */ }
@Transactional
public AlquilerResponse finalizar(Long id, FinalizarAlquilerRequest peticion) { /* ... */ }
public PaginaResponse<AlquilerResponse> listar(Pageable pageable) { /* solo lee */ }
}Que lo por defecto sea readOnly = true tiene dos ventajas: optimiza todas las lecturas y convierte escribir en una decisión consciente, de modo que un método que se olvide de anotarse no escribirá por accidente.
- Cómo funciona por dentro: el proxy AOP
Aquí está la clave para entender las dos trampas del apartado siguiente.
@Transactional no modifica tu código. Spring crea un proxy que envuelve tu bean —el mismo mecanismo de BeanPostProcessor de 02-03 y de los repositorios de 04-05—. Lo que se inyecta en el controlador no es tu AlquilerService: es un proxy que lo contiene.
sequenceDiagram
participant C as AlquilerController
participant P as Proxy de AlquilerService
participant TM as JpaTransactionManager
participant S as AlquilerService (real)
C->>P: iniciar(peticion)
P->>TM: ¿hay transacción? No -> abrir
TM->>TM: conexión del pool, autoCommit=false
P->>S: iniciar(peticion)
S-->>P: AlquilerResponse
P->>TM: commit (flush + COMMIT)
P-->>C: AlquilerResponse
Si el método lanza una excepción no comprobada, el proxy pide rollback en lugar de commit y la relanza.
Spring crea el proxy con JDK dinámico si el bean implementa interfaces, o con CGLIB generando una subclase si no; Spring Boot usa CGLIB por defecto (spring.aop.proxy-target-class=true), lo que explica el requisito de que los métodos transaccionales no sean final ni private.
La consecuencia fundamental: solo las llamadas que atraviesan el proxy activan la transacción. Una llamada de un método de la clase a otro de la misma clase va por this y no pasa por el proxy. De ahí la trampa 1.
- Trampa 1: la autoinvocación
Es el error más frecuente y el más difícil de detectar, porque no produce ningún síntoma hasta que algo falla a medias.
@Service
public class AlquilerService {
public void procesarLoteDevoluciones(List<Long> ids) {
for (Long id : ids) {
finalizarConTransaccion(id); // llamada interna: this.finalizarConTransaccion()
}
}
@Transactional
public void finalizarConTransaccion(Long id) {
// ¡Esta anotación NO tiene ningún efecto cuando se llama desde arriba!
}
}La llamada finalizarConTransaccion(id) es en realidad this.finalizarConTransaccion(id). this es el objeto real, no el proxy, así que la anotación se ignora por completo. Cada operación se autoconfirma como si no hubiera transacción, y un fallo a mitad del lote deja las devoluciones anteriores confirmadas y las siguientes sin hacer.
Ni el compilador ni Spring avisan. El código parece correcto.
Las tres soluciones, de mejor a peor. A. Extraer a otro bean (la recomendada):
@Service
public class ProcesadorDevoluciones {
private final AlquilerService alquilerService; // inyectado: es el PROXY
public void procesarLote(List<Long> ids) {
ids.forEach(alquilerService::finalizarConTransaccion); // pasa por el proxy
}
}Además de funcionar, suele mejorar el diseño: orquestar el lote y ejecutar la operación individual son responsabilidades distintas.
B. Autoinyección. Funciona y es fea: inyectar en la propia clase un campo @Lazy private final AlquilerService self —el proxy de sí misma— y llamar a self::finalizarConTransaccion. @Lazy es necesario para romper el ciclo de dependencia (02-02).
C. TransactionTemplate (apartado 13): gestión programática dentro del propio método, sin proxy de por medio. Esta trampa aplica igual a @Cacheable (09-02), @Async (07-03) y @PreAuthorize (05-05): toda anotación basada en proxies falla en la autoinvocación.
- Trampa 2: los métodos no públicos
@Transactional
private void metodoPrivado() { } // NO funciona
@Transactional
protected void metodoProtegido() { } // NO funciona de forma fiable
@Transactional
void metodoDePaquete() { } // NO funciona de forma fiableCon proxies CGLIB, Spring genera una subclase que sobrescribe los métodos: uno private no se puede sobrescribir, y protected o de paquete no se interceptan de forma fiable. La anotación se ignora en silencio. Desde Spring Framework 6.0 el arranque registra un aviso al detectarlo, lo que ayuda, pero la regla sigue siendo simple: @Transactional solo en métodos public, y nunca en métodos ni clases final.
propagation: los siete valores
propagation: los siete valorespropagation responde a: ¿qué hacer si ya hay una transacción en curso cuando se llama a este método?
| Valor | Si hay transacción | Si no hay |
|---|---|---|
REQUIRED (por defecto) |
Se une a ella | Crea una nueva |
REQUIRES_NEW |
Suspende la actual y crea otra independiente | Crea una nueva |
SUPPORTS |
Se une a ella | Se ejecuta sin transacción |
NOT_SUPPORTED |
Suspende la actual | Se ejecuta sin transacción |
MANDATORY |
Se une a ella | Lanza excepción |
NEVER |
Lanza excepción | Se ejecuta sin transacción |
NESTED |
Crea un punto de guardado (savepoint) | Crea una nueva |
Los tres que importan de verdad, con casos de CicloUrbana:
REQUIRED: el 95 % de los casos. AlquilerService.iniciar llama a BicicletaService.marcarAlquilada; ambos son @Transactional y el segundo se une a la transacción del primero: una sola transacción y un solo commit.
REQUIRES_NEW: registrar algo que debe sobrevivir a un rollback.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registrarIntento(Long usuarioId, String operacion, boolean exitosa) {
registroAuditoriaRepositorio.save(
new RegistroAuditoria(usuarioId, operacion, exitosa, Instant.now()));
}Si el intento de alquiler falla y la transacción principal hace rollback, el registro de auditoría se conserva, porque vivió en su propia transacción. Es exactamente lo que se quiere de una auditoría: registrar también lo que salió mal.
Su coste hay que conocerlo: REQUIRES_NEW usa una segunda conexión del pool mientras la primera sigue suspendida. Con maximum-pool-size: 10, diez peticiones concurrentes con REQUIRES_NEW agotan el pool y se produce un interbloqueo: cada hilo espera una conexión que otro hilo suspendido no soltará. Es una causa real y difícil de diagnosticar de caídas en producción.
NESTED: deshacer una parte sin perder el resto. Crea un punto de guardado dentro de la transacción actual; si falla, se vuelve a él y el resto sobrevive. Requiere soporte de JDBC —PostgreSQL lo tiene— pero no funciona con JpaTransactionManager, solo con DataSourceTransactionManager, lo que en la práctica lo descarta en aplicaciones JPA.
isolation: los cuatro niveles
isolation: los cuatro nivelesisolation controla cuánto puede una transacción ver del trabajo en curso de otras. Los fenómenos que se pueden producir:
| Fenómeno | Qué es | Ejemplo en CicloUrbana |
|---|---|---|
| Lectura sucia | Leer datos no confirmados que luego se deshacen | Ver una bicicleta como EN_USO en una transacción que acaba en rollback |
| Lectura no repetible | Leer la misma fila dos veces con valores distintos | La capacidad de una estación cambia entre dos lecturas |
| Lectura fantasma | Una consulta devuelve filas nuevas al repetirla | Contar bicicletas disponibles dos veces y obtener distinto número |
Y los niveles que los previenen:
| Nivel | Lectura sucia | No repetible | Fantasma | Coste |
|---|---|---|---|---|
READ_UNCOMMITTED |
Posible | Posible | Posible | Mínimo |
READ_COMMITTED |
No | Posible | Posible | Bajo |
REPEATABLE_READ |
No | No | Posible* | Medio |
SERIALIZABLE |
No | No | No | Alto |
PostgreSQL usa READ_COMMITTED por defecto, y es el nivel correcto para prácticamente todo CicloUrbana. Dos particularidades suyas conviene saberlas: no implementa READ_UNCOMMITTED —pedirlo da READ_COMMITTED, porque nunca permite lecturas sucias— y su REPEATABLE_READ también evita los fantasmas, al estar implementado con instantáneas (MVCC), siendo más fuerte que lo que exige el estándar. Subirlo tiene sentido en un informe mensual que deba ver una foto coherente: @Transactional(isolation = Isolation.REPEATABLE_READ).
La regla práctica: no toques isolation salvo que sepas exactamente por qué. Subirlo aumenta los bloqueos y la probabilidad de errores de serialización que obligan a reintentar. Para el 99 % de los casos, READ_COMMITTED más el bloqueo optimista de @Version (04-03) es la combinación correcta.
readOnly, timeout y los atributos restantes
readOnly, timeout y los atributos restantesreadOnly = true no es una simple declaración de intenciones: Hibernate cambia el FlushMode a MANUAL y deja de hacer flush automático; se salta el dirty checking, sin guardar copias del estado original de cada entidad, lo que reduce notablemente la memoria en consultas de muchas filas; y marca la conexión JDBC como de solo lectura, lo que en algunas configuraciones permite dirigirla a una réplica. En una consulta que devuelve 10 000 entidades, no mantener 10 000 copias es un ahorro sustancial: por eso readOnly = true a nivel de clase es una buena práctica y no un adorno.
Ojo con una expectativa equivocada: readOnly no impide escribir. Con Hibernate, un INSERT explícito puede llegar a ejecutarse. Es una optimización, no una barrera de seguridad.
timeout limita la duración en segundos (@Transactional(timeout = 10)). Al superarse se lanza TransactionTimedOutException y se hace rollback. Es una red de seguridad valiosa, porque una transacción que se eternice retiene una conexión del pool y bloquea filas; en CicloUrbana tiene sentido en operaciones que puedan degenerar, como informes con filtros abiertos.
| Atributo | Para qué | Valor por defecto |
|---|---|---|
propagation |
Comportamiento ante una transacción existente | REQUIRED |
isolation |
Nivel de aislamiento | El del motor (DEFAULT) |
readOnly |
Optimización de lectura | false |
timeout |
Duración máxima en segundos | Sin límite |
rollbackFor |
Excepciones comprobadas que provocan rollback | Ninguna |
noRollbackFor |
Excepciones que no deben provocarlo | Ninguna |
- La regla del rollback
Por defecto, Spring hace rollback solo ante RuntimeException y Error. Las excepciones comprobadas confirman la transacción.
Es una herencia de EJB que sorprende a todo el mundo:
@Transactional
public void operacion() throws IOException {
repositorio.save(entidad);
throw new IOException("fallo de red"); // ¡se hace COMMIT! El save se confirma
}
// Para cambiarlo: @Transactional(rollbackFor = Exception.class)En CicloUrbana el problema no se plantea porque CicloUrbanaException, raíz de la jerarquía de 03-06, extiende RuntimeException. RecursoNoEncontradoException, EstacionLlenaException, BicicletaNoDisponibleException y las demás provocan rollback automáticamente. Fue una buena decisión de diseño y esta es una de las razones.
El error clásico: capturar la excepción y perder el rollback.
@Transactional
public void iniciarConCobroMal(IniciarAlquilerRequest peticion) {
Alquiler alquiler = crearAlquiler(peticion);
try {
pasarelaPago.cobrar(alquiler.getImporte());
} catch (PagoRechazadoException e) {
log.error("Pago rechazado", e); // capturada y "gestionada"
}
// La transacción hace COMMIT: el alquiler queda creado SIN haberse cobrado
}Capturar la excepción impide que llegue al proxy, y sin excepción el proxy hace commit. El alquiler se registra aunque el pago fallara.
Y una variante aún más desconcertante: si la excepción se lanza en un método interno también @Transactional (propagación REQUIRED) y se captura en el externo, la transacción ya quedó marcada para rollback por el interno, y al confirmar salta UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only. Las dos soluciones correctas:
// A. Relanzar como excepción del dominio (lo habitual)
catch (PagoRechazadoException e) {
log.error("Pago rechazado para el alquiler {}", alquiler.getId(), e);
throw new ReglaNegocioException("PAGO_RECHAZADO", "El pago ha sido rechazado");
}
// B. Marcar explícitamente la transacción para rollback
catch (PagoRechazadoException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}La A es preferible: el @RestControllerAdvice de 03-06 la convierte en un ProblemDetail con su código, y el cliente sabe qué pasó.
- El contexto de persistencia y el flush
El contexto de persistencia vive exactamente lo que dura la transacción. Ese es el vínculo que hemos ido anunciando desde 04-01.
Cuándo hace Hibernate el flush, es decir, cuándo envía a la base de datos el SQL acumulado:
| Momento | ¿Hay flush? |
|---|---|
| Antes del commit | Siempre |
| Antes de una consulta JPQL que pueda verse afectada | Sí (FlushMode.AUTO) |
Al llamar a entityManager.flush() o saveAndFlush() |
Sí, explícito |
Con readOnly = true |
No automáticamente |
Al llamar a save() sobre una entidad nueva |
No necesariamente: solo asigna el id |
El flush automático antes de una consulta es más importante de lo que parece: guardar una estación nueva y consultar acto seguido existsByNombre devuelve true porque Hibernate vuelca el INSERT pendiente antes de ejecutar la consulta, aunque en el momento del save solo hubiera asignado el id.
El dirty checking, en detalle. Al cargar una entidad, Hibernate guarda una copia de su estado (la snapshot); en el flush compara campo a campo y genera un UPDATE solo si algo cambió, con solo las columnas modificadas si está activado el SQL dinámico.
@Transactional
public void ajustarCapacidad(Long id, int nuevaCapacidad) {
Estacion estacion = estacionRepositorio.findById(id)
.orElseThrow(() -> new RecursoNoEncontradoException("Estación", id));
estacion.setCapacidad(nuevaCapacidad);
// Sin save(). Al hacer commit:
// UPDATE estaciones SET capacidad=?, version=? WHERE id=? AND version=?
}Es correcto y es lo idiomático. Llamar a save() aquí es redundante.
El coste oculto del dirty checking: mantener la copia de cada entidad cargada consume memoria, y comparar todas en cada flush consume CPU. Por eso readOnly = true en las consultas es una optimización real.
- Bloqueo pesimista frente a optimista
En 04-03 añadimos @Version y vimos el bloqueo optimista. Aquí llega su complemento.
Optimista (@Version) |
Pesimista (@Lock) |
|
|---|---|---|
| Estrategia | Detecta el conflicto al escribir | Previene el conflicto bloqueando la fila |
| Bloquea filas | No | Sí, hasta el fin de la transacción |
| Coste | Una columna | Contención; riesgo de interbloqueo |
| Falla | Al hacer commit (409) |
Espera, o da timeout |
| Adecuado para | Conflictos raros | Conflictos frecuentes en un recurso escaso |
El caso donde el optimista no basta en CicloUrbana es la carrera por la última bicicleta de una estación: dos ciudadanos pulsan «alquilar» a la vez en «Plaza Mayor», ambas transacciones leen la misma bicicleta como DISPONIBLE y ambas la marcan como EN_USO. Con @Version, el segundo commit falla con un 409, lo cual es correcto pero mala experiencia: el usuario recibe un error cuando podríamos haberle asignado otra bicicleta.
El bloqueo pesimista lo evita serializando el acceso:
public interface BicicletaRepositorio extends JpaRepository<Bicicleta, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("""
select b from Bicicleta b
where b.estacion.id = :estacionId
and b.estado = com.ciclourbana.bicicletas.EstadoBicicleta.DISPONIBLE
and b.nivelBateria >= :umbral
order by b.nivelBateria desc
limit 1
""")
Optional<Bicicleta> bloquearMejorDisponible(@Param("estacionId") Long estacionId,
@Param("umbral") int umbral);
}Genera un SELECT ... FOR UPDATE: la fila queda bloqueada hasta el fin de la transacción y la segunda espera. Cuando el primero confirma, el segundo relee, ve que ya no está disponible y busca otra. Nadie recibe un 409.
Los modos disponibles: PESSIMISTIC_READ (bloqueo compartido: otros leen, no escriben), PESSIMISTIC_WRITE (exclusivo, SELECT ... FOR UPDATE), PESSIMISTIC_FORCE_INCREMENT (exclusivo y además incrementa @Version), OPTIMISTIC (comprueba la versión al final) y OPTIMISTIC_FORCE_INCREMENT (la incrementa aunque nada cambie).
Tres reglas para el bloqueo pesimista: fija siempre un timeout, porque sin él una transacción larga puede bloquear a las demás indefinidamente; mantén la transacción lo más corta posible, ya que el bloqueo dura hasta el commit; y bloquea las filas siempre en el mismo orden en todos los métodos, o dos transacciones que bloqueen A→B y B→A producirán un interbloqueo que PostgreSQL resolverá matando una de ellas.
El criterio de elección: optimista por defecto —es lo que llevan Estacion, Bicicleta y Alquiler—, y pesimista solo en los puntos concretos de alta contención sobre un recurso escaso.
TransactionTemplate: gestión programática
TransactionTemplate: gestión programáticaCuando la anotación no encaja —control fino del alcance, transacciones dentro de un bucle, o para esquivar la autoinvocación—, TransactionTemplate da control explícito:
@Service
public class ImportadorEstaciones {
private final TransactionTemplate plantilla; // new TransactionTemplate(gestor)
private final EstacionRepositorio estacionRepositorio;
public ResultadoImportacion importar(List<CrearEstacionRequest> lote) {
int correctas = 0, fallidas = 0;
for (CrearEstacionRequest fila : lote) {
try {
// Una transacción POR FILA: un fallo no arrastra al resto
plantilla.executeWithoutResult(estado -> {
estacionRepositorio.save(mapper.aEntidad(fila));
});
correctas++;
} catch (DataAccessException e) {
log.warn("Fila descartada: {}", fila.nombre(), e);
fallidas++;
}
}
return new ResultadoImportacion(correctas, fallidas);
}
}Este caso —importar el catálogo de estaciones de Ribalta desde un CSV con filas potencialmente erróneas— es el ejemplo canónico: con @Transactional en el método, un solo error anularía la importación entera; con una transacción por fila se importa lo válido y se registra lo descartado.
@Transactional |
TransactionTemplate |
|
|---|---|---|
| Legibilidad | Mejor | Peor (código adicional) |
| Control del alcance | Todo el método | Exacto |
| Afectado por la autoinvocación | Sí | No |
| Transacciones en bucle | No | Sí |
La regla: @Transactional por defecto; TransactionTemplate cuando la anotación no llega.
- Eventos transaccionales
En 02-02 publicamos el evento AlquilerIniciado. Un @EventListener normal se ejecuta de forma síncrona y dentro de la transacción, lo que produce dos problemas graves.
Problema 1: se notifica algo que puede no ocurrir. Si el escuchador envía un correo y después la transacción hace rollback, el correo anuncia un alquiler que no existe. Problema 2: se retiene una conexión durante la llamada externa. Cobrar en la pasarela puede tardar dos segundos, y durante todo ese tiempo la conexión sigue prestada: la causa de agotamiento del pool que analizamos en 04-02.
@TransactionalEventListener resuelve ambos:
@Component
public class NotificadorAlquiler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void alConfirmarse(AlquilerIniciado evento) {
// Solo se ejecuta si la transacción se confirmó de verdad
notificacionService.enviarConfirmacion(evento.alquilerId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void alDeshacerse(AlquilerIniciado evento) {
log.warn("Alquiler {} no llegó a confirmarse", evento.alquilerId());
}
}| Fase | Cuándo se ejecuta | Uso típico |
|---|---|---|
BEFORE_COMMIT |
Antes del commit, aún dentro | Validaciones finales |
AFTER_COMMIT (por defecto) |
Tras confirmar | Notificaciones, integraciones |
AFTER_ROLLBACK |
Tras deshacer | Registrar el fallo |
AFTER_COMPLETION |
Tras cualquiera de los dos | Limpieza |
Dos advertencias importantes. En AFTER_COMMIT la transacción ya terminó: si el escuchador intenta escribir en la base de datos necesita @Transactional(propagation = REQUIRES_NEW), y sin eso los cambios se pierden en silencio. Y sigue siendo síncrono por defecto, ejecutándose en el mismo hilo después del commit pero antes de responder al cliente; para no retrasar la respuesta, combínalo con @Async (07-03).
Aplicado a CicloUrbana, el cobro del alquiler pasa a ocurrir después del commit, con la conexión ya devuelta al pool: ese cambio por sí solo puede multiplicar la capacidad de la aplicación.
- Transacciones y
LazyInitializationException
LazyInitializationExceptionCerramos el círculo abierto en 04-04. Con open-in-view: false (04-02), el contexto de persistencia muere con la transacción del servicio. Todo lo que salga de ahí está separado.
// MAL: la entidad sale del servicio con relaciones perezosas sin cargar
@Transactional(readOnly = true)
public Estacion obtener(Long id) {
return estacionRepositorio.findById(id).orElseThrow();
}
// El controlador serializa y accede a getBicicletas() -> LazyInitializationException// BIEN: el mapeo a DTO ocurre DENTRO de la transacción
@Transactional(readOnly = true)
public EstacionDetalleResponse obtenerDetalle(Long id) {
Estacion estacion = estacionRepositorio.buscarConBicicletas(id)
.orElseThrow(() -> new RecursoNoEncontradoException("Estación", id));
return mapper.aDetalle(estacion); // aquí la transacción sigue abierta
}Esto convierte la regla de 03-05 —«el servicio devuelve DTOs, no entidades»— de buena práctica en requisito técnico. Las tres decisiones del módulo encajan aquí: LAZY en todas las asociaciones (04-04), open-in-view: false (04-02) y mapeo dentro de la transacción. Juntas garantizan que ninguna consulta se dispare por accidente durante la serialización.
Errores Comunes y Consejos
Llamar a un método @Transactional desde la misma clase. La anotación se ignora sin ningún aviso. Extrae a otro bean.
Poner @Transactional en métodos privados o final. No se interceptan. Solo métodos public en clases no final.
Capturar una excepción y no relanzarla. El commit se produce igualmente, o aparece UnexpectedRollbackException. Relanza como excepción del dominio.
Esperar rollback con excepciones comprobadas. Por defecto no lo hay. En CicloUrbana no es problema porque todo hereda de RuntimeException.
Hacer llamadas externas dentro de la transacción. Retiene una conexión durante toda la llamada de red. Usa @TransactionalEventListener(AFTER_COMMIT).
Abusar de REQUIRES_NEW. Consume una segunda conexión mientras la primera está suspendida; con concurrencia agota el pool y produce interbloqueos.
Subir el isolation «por seguridad». Aumenta bloqueos y errores de serialización. READ_COMMITTED más @Version cubre casi todo.
Consejo: @Transactional(readOnly = true) en la clase de servicio. Optimiza todas las lecturas y hace que escribir sea una decisión consciente.
Consejo: transacciones cortas. Cada milisegundo de transacción es un milisegundo de conexión prestada y de filas bloqueadas. Valida antes de abrirla y notifica después de cerrarla.
Consejo: comprueba que la transacción está activa. Con logging.level.org.springframework.transaction: DEBUG verás los mensajes Creating new transaction e Initiating transaction commit; si no aparecen donde esperabas, casi seguro es la trampa 1.
Ejercicios
Ejercicio 1: finalizar un alquiler
Implementa AlquilerService.finalizar(Long alquilerId, FinalizarAlquilerRequest peticion). Debe: verificar que el alquiler existe y está en curso; comprobar que la estación de destino tiene hueco (si no, EstacionLlenaException); calcular el importe con CalculadoraTarifa; marcar la bicicleta como DISPONIBLE en la estación de destino; cerrar el alquiler; y notificar al usuario solo si todo se confirmó. Justifica cada decisión transaccional.
Ejercicio 2: encontrar cuatro fallos transaccionales
@Service
public class MantenimientoService {
@Transactional
public void revisionNocturna() {
List<Bicicleta> bicicletas = bicicletaRepositorio.findByNivelBateriaLessThan(20);
for (Bicicleta bici : bicicletas) {
procesarBicicleta(bici);
}
servicioExterno.notificarTaller(bicicletas.size());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
private void procesarBicicleta(Bicicleta bici) {
try {
bici.setEstado(EstadoBicicleta.MANTENIMIENTO);
bicicletaRepositorio.save(bici);
} catch (Exception e) {
log.error("Error", e);
}
}
}Ejercicio 3: elegir el tipo de bloqueo
Para cada operación de CicloUrbana, decide entre bloqueo optimista, pesimista o ninguno, y justifícalo.
- Editar el nombre de una estación desde el panel del ayuntamiento.
- Alquilar la última bicicleta disponible de una estación en hora punta.
- Consultar el listado público de estaciones.
- Descontar saldo del monedero de un usuario al finalizar un alquiler.
Soluciones
Solución 1.
@Service
@Transactional(readOnly = true)
public class AlquilerService {
@Transactional // sobrescribe readOnly: este método escribe
public AlquilerResponse finalizar(Long alquilerId, FinalizarAlquilerRequest peticion) {
Alquiler alquiler = alquilerRepositorio.findById(alquilerId)
.orElseThrow(() -> new RecursoNoEncontradoException("Alquiler", alquilerId));
if (alquiler.getFin() != null) {
throw new ConflictoRecursoException("El alquiler " + alquilerId + " ya finalizó");
}
Estacion destino = estacionRepositorio.findById(peticion.estacionDestinoId())
.orElseThrow(() -> new RecursoNoEncontradoException(
"Estación", peticion.estacionDestinoId()));
if (bicicletaRepositorio.countByEstacionId(destino.getId()) >= destino.getCapacidad()) {
throw new EstacionLlenaException(destino.getId());
}
Instant fin = Instant.now();
BigDecimal importe = selectorTarifa.paraUsuario(alquiler.getUsuario())
.calcular(Duration.between(alquiler.getInicio(), fin));
Bicicleta bicicleta = alquiler.getBicicleta();
bicicleta.setEstado(EstadoBicicleta.DISPONIBLE);
destino.anadirBicicleta(bicicleta); // sincroniza ambos lados (04-04)
alquiler.setFin(fin);
alquiler.setEstacionDestino(destino);
alquiler.setImporte(importe);
alquiler.setEstado(EstadoAlquiler.FINALIZADO);
// sin save(): las tres entidades están gestionadas (dirty checking)
eventos.publishEvent(new AlquilerFinalizado(alquiler.getId(), importe, fin));
return mapper.aRespuesta(alquiler);
}
}@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notificarFinalizacion(AlquilerFinalizado evento) {
notificacionService.enviarResumen(evento.alquilerId(), evento.importe());
}Las decisiones transaccionales, una a una:
@TransactionalsinreadOnly, porque escribe en tres entidades: la clase declarareadOnly = truey este método lo sobrescribe, de modo que escribir es una decisión explícita.- Todo en una sola transacción. Cerrar el alquiler, liberar la bicicleta y asignarla a la estación deben ocurrir juntos; si el
UPDATEdel alquiler fallara tras cambiar la bicicleta, esta quedaríaDISPONIBLEsin estar aparcada en ningún sitio. - Sin
save(): las tres entidades están gestionadas y el dirty checking genera losUPDATEen el commit. - Las excepciones son de
CicloUrbanaException, no comprobadas, así que provocan rollback y el@RestControllerAdvicede 03-06 las convierte en409o404conProblemDetail. - La notificación va en
AFTER_COMMIT: enviarla dentro retendría la conexión durante la llamada al servicio de correo y podría anunciar un alquiler que después se deshace. - El mapeo a DTO ocurre dentro de la transacción, requisito con
open-in-view: false. Y sin bloqueo pesimista, porque@VersionenAlquilerbasta: dos usuarios no finalizan el mismo alquiler a la vez.
Solución 2. Los cuatro fallos:
1. Autoinvocación (el más grave). procesarBicicleta(bici) se llama con this, sin pasar por el proxy, así que REQUIRES_NEW se ignora por completo y todo se ejecuta en la transacción externa.
2. @Transactional en un método private. No es interceptable: aunque se corrigiera la autoinvocación, seguiría sin funcionar. Debe ser public y vivir en otro bean.
3. Excepción capturada y no relanzada. Si el save falla, se registra en el log y el bucle continúa: la transacción externa confirma y se dan por procesadas bicicletas que no lo están. Y si el fallo hubiera marcado la transacción para rollback, el commit final daría UnexpectedRollbackException.
4. Llamada externa dentro de la transacción. servicioExterno.notificarTaller(...) retiene la conexión durante toda la llamada de red; en una revisión nocturna sobre cientos de bicicletas, la transacción puede durar minutos con una conexión bloqueada.
Un quinto problema de diseño: cargar todas las bicicletas para modificarlas una a una es innecesario, ya que una consulta @Modifying (04-06) lo haría en una sola sentencia. Versión corregida:
@Service
public class MantenimientoService {
private final ProcesadorBicicletas procesador; // otro bean: se pasa por el proxy
private final ApplicationEventPublisher eventos;
public void revisionNocturna() { // SIN @Transactional: solo orquesta
List<Long> ids = bicicletaRepositorio.buscarIdsConBateriaBajaDisponibles(20);
int procesadas = 0;
for (Long id : ids) {
try {
procesador.marcarEnMantenimiento(id); // transacción independiente
procesadas++;
} catch (DataAccessException e) {
log.error("No se pudo procesar la bicicleta {}", id, e);
}
}
eventos.publishEvent(new RevisionNocturnaCompletada(procesadas));
}
}
@Service
public class ProcesadorBicicletas {
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 5)
public void marcarEnMantenimiento(Long bicicletaId) {
Bicicleta bici = bicicletaRepositorio.findById(bicicletaId)
.orElseThrow(() -> new RecursoNoEncontradoException("Bicicleta", bicicletaId));
bici.setEstado(EstadoBicicleta.MANTENIMIENTO);
// sin save(): dirty checking
}
}revisionNocturna ya no es transaccional: solo orquesta. Cada bicicleta se procesa en su propia transacción, un fallo individual no arrastra al resto y la notificación al taller viaja en un evento que se atiende fuera de cualquier transacción.
Solución 3.
- Optimista (
@Version). Los conflictos son raros —dos administrativos editando la misma estación a la vez— y el409con «recarga y vuelve a intentarlo» es una respuesta perfectamente aceptable. Bloquear la fila penalizaría a todas las lecturas concurrentes sin ganancia real. - Pesimista (
PESSIMISTIC_WRITEcontimeout). Es el caso de contención real: en hora punta, varios ciudadanos compiten por el mismo recurso escaso. Con optimista, todos menos uno reciben un409innecesario cuando el sistema podría asignarles otra bicicleta. ElSELECT ... FOR UPDATEserializa el acceso y cada usuario obtiene una bicicleta o un mensaje honesto de que no quedan. - Ninguno. Es una lectura pura:
@Transactional(readOnly = true)y ningún bloqueo. Cualquier bloqueo aquí sería puro coste. - Pesimista. El saldo de un monedero es el ejemplo canónico de actualización perdida: leer 12,50 €, restar 1,75 € y escribir 10,75 € desde dos transacciones concurrentes hace desaparecer uno de los descuentos. El optimista lo detectaría, pero fallar un cobro ya realizado es peor que esperar unos milisegundos. Mejor aún es evitar la lectura previa con un
UPDATEatómico —update Monedero m set m.saldo = m.saldo - :importe where m.id = :id and m.saldo >= :importe—, que resuelve la carrera sin bloquear nada.
Conclusión
Ya sabes qué hace realmente @Transactional, y era mucho más de lo que la anotación aparenta. Entiendes las cuatro propiedades ACID sobre el caso concreto de iniciar un alquiler en Ribalta, donde marcar la bicicleta y crear el registro deben ocurrir juntos o ninguno, y has visto que el estado inconsistente más peligroso es el que no produce ningún error. Sabes que la anotación va en la capa de servicio —no en el controlador, donde retendría la conexión durante la serialización, ni en el repositorio, cuyos métodos son demasiado pequeños para ser un caso de uso— y usas el patrón de readOnly = true en la clase con @Transactional explícito en los métodos que escriben, para que escribir sea siempre una decisión consciente.
Conoces el mecanismo del proxy AOP y, con él, las dos trampas que hacen que la anotación no haga absolutamente nada: la autoinvocación, porque una llamada por this no atraviesa el proxy, y los métodos no públicos, que CGLIB no puede interceptar. Ambas fallan en silencio, y su solución —extraer a otro bean— casi siempre mejora también el diseño. Manejas los siete valores de propagation sabiendo que REQUIRED cubre el 95 % de los casos, que REQUIRES_NEW es la respuesta correcta para una auditoría que debe sobrevivir al rollback pero consume una segunda conexión que puede agotar el pool, y que NESTED no es viable con JpaTransactionManager. Sitúas los cuatro niveles de isolation frente a los tres fenómenos de concurrencia, sabes que PostgreSQL usa READ_COMMITTED por defecto y no implementa lecturas sucias, y tienes claro que subir el nivel «por seguridad» compra bloqueos, no corrección.
Dominas la regla del rollback: por defecto solo con excepciones no comprobadas, algo que en CicloUrbana no da problemas porque toda la jerarquía de 03-06 hereda de RuntimeException; y reconoces el error clásico de capturar la excepción y quedarse sin rollback, junto con su variante desconcertante, la UnexpectedRollbackException. Sabes cuándo hace flush Hibernate y por qué el dirty checking hace innecesario llamar a save() sobre una entidad gestionada, así como el coste en memoria que readOnly = true evita. Distingues el bloqueo optimista del pesimista y sabes elegir: @Version por defecto, SELECT ... FOR UPDATE con timeout en la carrera por la última bicicleta de «Plaza Mayor». Usas TransactionTemplate cuando la anotación no llega —una transacción por fila al importar el catálogo de estaciones— y has sacado el cobro y las notificaciones fuera de la transacción con @TransactionalEventListener(AFTER_COMMIT), devolviendo la conexión al pool antes de hacer nada lento. En sistemas distribuidos esta atomicidad deja de estar disponible y hay que recurrir a patrones como saga, que veremos en 07-05.
Queda un cabo suelto que arrastramos desde 04-02: el esquema. ddl-auto: update sigue creando tablas por su cuenta, nadie sabe exactamente qué SQL se ha ejecutado sobre la base de datos y eso no puede llegar a producción. La lección 04-08, Migraciones de Esquema con Flyway, cierra el módulo: veremos por qué el esquema debe versionarse como código, la convención de nombres de los scripts y la tabla flyway_schema_history con sus checksums, escribiremos el esquema inicial completo de CicloUrbana en SQL de PostgreSQL —coherente con las entidades y relaciones de 04-03 y 04-04— y la migración de datos que jubila al CargadorEstacionesDemo del módulo 1, y aprenderemos el patrón expand/contract para renombrar una columna sin parar el servicio.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
