
Recap rápido de @Transactional
@Transactional es la anotación que delimita una unidad atómica de trabajo. Spring abre la transacción al entrar al método, hace commit al salir con éxito y rollback ante una RuntimeException. La implementación se apoya en un proxy AOP que envuelve el bean y delega en el PlatformTransactionManager configurado (típicamente JpaTransactionManager cuando hay JPA o DataSourceTransactionManager para JDBC plano).
Lo que no es tan conocido es que @Transactional tiene siete atributos relevantes más allá del binario "transacción sí o no": propagation, isolation, readOnly, timeout, rollbackFor, noRollbackFor y transactionManager. Dominarlos es lo que separa una capa de servicio correcta de una con corrupciones sutiles bajo carga.
graph LR
A[Cliente HTTP] --> B[Controller]
B --> C[Service @Transactional REQUIRED]
C --> D[Repository save]
C --> E[AuditService @Transactional REQUIRES_NEW]
E --> F[Audit Repository save]
D --> G[(BD: tx principal)]
F --> H[(BD: tx independiente)]
Propagation: cómo se compone con la transacción existente
Spring define siete niveles. Cada uno responde a la pregunta "qué hago si ya hay una transacción activa cuando se invoca este método".
REQUIRED (default)
Si hay transacción activa, se une. Si no, abre una nueva. Es el comportamiento esperado el 90% del tiempo:
@Service
@RequiredArgsConstructor
public class PedidoService {
private final PedidoRepository pedidoRepository;
private final LineaPedidoRepository lineaRepository;
@Transactional
public Pedido crear(PedidoCrearDto dto) {
var pedido = pedidoRepository.save(new Pedido(dto.cliente()));
dto.lineas().forEach(l -> lineaRepository.save(new LineaPedido(pedido, l)));
return pedido;
}
}
Si crear se invoca desde otro método ya transaccional, se ejecuta en la misma transacción. Si falla la inserción de cualquier línea, hace rollback de todo.
REQUIRES_NEW: suspender y abrir tx nueva
Suspende la transacción activa, abre una nueva, la commitea (o rollbackea) por separado y reanuda la suspendida. Caso canónico: auditoría que debe persistir aunque el método principal falle.
@Service
@RequiredArgsConstructor
public class AuditoriaService {
private final EventoAuditoriaRepository repository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registrar(String accion, Long usuarioId, String detalles) {
repository.save(new EventoAuditoria(accion, usuarioId, detalles, Instant.now()));
}
}
@Service
@RequiredArgsConstructor
public class TransferenciaService {
private final CuentaRepository cuentaRepository;
private final AuditoriaService auditoriaService;
@Transactional
public void transferir(Long origenId, Long destinoId, BigDecimal cantidad) {
auditoriaService.registrar("TRANSFERENCIA_INICIADA", origenId, cantidad.toString());
var origen = cuentaRepository.findById(origenId).orElseThrow();
if (origen.getSaldo().compareTo(cantidad) < 0) {
throw new SaldoInsuficienteException(origenId);
}
// ... resto de la lógica
}
}
Si la transferencia falla con SaldoInsuficienteException, la transacción principal hace rollback. Pero la fila de auditoría persiste porque vivió en una transacción independiente que ya hizo commit. Esto deja trazabilidad incluso de los intentos fallidos, fundamental para regulaciones bancarias y forenses.
El método
registrardebe estar en un bean distinto del que llama. Si llamaras a un método REQUIRES_NEW desde otro método del mismo servicio víathis.registrar(...), el proxy de Spring no se aplica y la propagation se ignora silenciosamente. Esto es la causa #1 de auditorías que desaparecen en producción.
NESTED: savepoints dentro de la misma tx
Crea un savepoint dentro de la transacción actual. Si el método anidado falla, hace rollback solo hasta ese savepoint sin abortar la transacción exterior:
@Transactional
public void importarLote(List<ProductoDto> productos) {
productos.forEach(p -> {
try {
importarUno(p);
} catch (DataIntegrityViolationException ex) {
log.warn("Producto {} duplicado, se omite", p.sku());
}
});
}
@Transactional(propagation = Propagation.NESTED)
public void importarUno(ProductoDto dto) {
productoRepository.save(toEntity(dto));
}
Si un producto del lote viola una constraint, su savepoint se revierte y la transacción exterior continúa con los siguientes. Sin NESTED, el primer fallo abortaría toda la importación.
NESTED requiere que el JpaTransactionManager y el driver JDBC soporten savepoints. PostgreSQL y MySQL los soportan; Oracle también. SQL Server tiene limitaciones con XA.
MANDATORY, SUPPORTS, NEVER, NOT_SUPPORTED
Niveles menos frecuentes pero útiles en casos específicos:
- MANDATORY: exige que ya exista una transacción. Si no hay, lanza
IllegalTransactionStateException. Útil para métodos que solo deben llamarse desde un contexto transaccional. - SUPPORTS: si hay tx, se une; si no, ejecuta sin transacción. Para operaciones puramente de lectura que pueden ser parte de un flujo transaccional o no.
- NEVER: lanza excepción si hay tx activa. Para forzar que ciertas operaciones (por ejemplo, llamadas a APIs externas largas) no estén dentro de una transacción que mantiene locks.
- NOT_SUPPORTED: suspende la tx activa si la hay, ejecuta sin transacción y la reanuda. Útil cuando llamas a un sistema externo lento desde un servicio transaccional.
Isolation: qué fenómenos tolera la base de datos
El estándar SQL define cuatro fenómenos de concurrencia y cuatro niveles que los previenen progresivamente.
| Fenómeno | Descripción | |----------|-------------| | Dirty read | Una tx lee cambios no commiteados de otra | | Non-repeatable read | Misma SELECT devuelve valores distintos en la misma tx | | Phantom read | Misma SELECT con WHERE devuelve filas nuevas insertadas por otra tx | | Lost update | Dos tx leen y escriben el mismo registro, una pisa la otra |
| Isolation level | Dirty | Non-repeatable | Phantom | |-----------------|-------|----------------|---------| | READ_UNCOMMITTED | Sí | Sí | Sí | | READ_COMMITTED | No | Sí | Sí | | REPEATABLE_READ | No | No | Sí (SQL standard) | | SERIALIZABLE | No | No | No |
En PostgreSQL
PostgreSQL implementa isolation con MVCC (multi-version concurrency control). Sus niveles reales son:
- READ_COMMITTED (default): cada SELECT ve el snapshot del momento de la SELECT. Permite non-repeatable y phantom.
- REPEATABLE_READ: cada tx ve el snapshot del momento del primer SELECT. Previene también phantom (PostgreSQL es más estricto que el estándar). Lanza
serialization_failureen escrituras concurrentes. - SERIALIZABLE: añade detección de anomalías de serialización mediante predicate locking. Garantiza que el resultado equivale a una ejecución serie.
PostgreSQL ignora READ_UNCOMMITTED y lo trata como READ_COMMITTED.
En MySQL InnoDB
- READ_COMMITTED: similar a PostgreSQL.
- REPEATABLE_READ (default en MySQL): usa next-key locks que previenen phantom dentro de la transacción. Distinto del estándar SQL.
- SERIALIZABLE: convierte cada SELECT en
SELECT ... FOR SHARE, lo que serializa de facto.
Configuración en Spring
@Transactional(isolation = Isolation.REPEATABLE_READ)
public Reporte calcularReporteDiario(LocalDate fecha) {
var ventas = ventaRepository.findByFecha(fecha);
var devoluciones = devolucionRepository.findByFecha(fecha);
return new Reporte(ventas, devoluciones);
}
Aquí el reporte mezcla dos consultas. Con READ_COMMITTED, una venta nueva durante la ejecución podría aparecer en devoluciones (si fue devuelta a su vez) pero no en ventas, dando totales incoherentes. REPEATABLE_READ congela el snapshot y garantiza coherencia.
Subir el isolation tiene coste: más locks en MySQL, más serialización fallida en PostgreSQL. La regla es partir de READ_COMMITTED y solo subir cuando una incoherencia concreta lo justifique.
readOnly = true
readOnly = true señala que la transacción no escribirá. El efecto se reparte en varias capas:
- Hibernate: desactiva el dirty checking. No compara entidades cargadas contra el estado original al final de la tx, lo que ahorra CPU en transacciones que cargan muchos objetos.
- JDBC: marca la conexión
Connection.setReadOnly(true). Algunos drivers usan esto para enrutar a réplicas de lectura o aplicar optimizaciones (PostgreSQL JDBC respeta hot standby read-only). - Spring Data: si usas Spring Data JPA con multi-tenancy o routing dinámico, el flag puede dirigir la conexión a un read replica.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CatalogoConsultaService {
private final ProductoRepository productoRepository;
public ProductoDto buscarPorId(Long id) {
return productoRepository.findById(id).map(this::toDto).orElseThrow();
}
public Page<ProductoDto> listar(Pageable pageable) {
return productoRepository.findAll(pageable).map(this::toDto);
}
}
Anotar la clase con @Transactional(readOnly = true) aplica el flag a todos los métodos. Si un método debe escribir, se sobrescribe con @Transactional en ese método concreto.
Si dentro de un método readOnly modificas una entidad cargada, Hibernate no sincroniza el cambio. Algunas versiones lo silencian; otras lanzan
TransactionSystemExceptional intentar el commit. La intención es ser estricto: si declaras readOnly, no escribas.
Reglas de rollback
Por defecto, Spring hace rollback ante RuntimeException y Error, pero no ante checked exceptions (Exception y subclases que no sean RuntimeException). Este comportamiento heredado de EJB sorprende a desarrolladores nuevos.
@Transactional(rollbackFor = Exception.class)
public void procesarFichero(Path ruta) throws IOException {
var contenido = Files.readString(ruta);
repository.save(new Procesado(contenido));
}
rollbackFor = Exception.class extiende el rollback a cualquier excepción, incluidas las checked. Es la configuración recomendada cuando lanzas IOException o similar y no quieres que la BD quede a medio escribir.
noRollbackFor es el inverso: indica excepciones que no deben provocar rollback aunque sean RuntimeException. Caso típico: una excepción de negocio que sí debe persistir un evento de auditoría:
@Transactional(noRollbackFor = ValidacionNegocioException.class)
public void registrarIntento(SolicitudDto dto) {
eventoRepository.save(new Evento("INTENTO", dto));
if (!validar(dto)) {
throw new ValidacionNegocioException("datos inválidos");
}
procesarRepository.save(new Procesado(dto));
}
En la práctica suele ser más limpio resolver este caso con
REQUIRES_NEWpara el evento (siempre persiste) que connoRollbackFor(la transacción exterior queda en estado raro). UsanoRollbackForsolo cuando entiendas exactamente la implicación.
Suspensión de transacciones
Cuando entra un método con REQUIRES_NEW o NOT_SUPPORTED y hay tx activa, Spring suspende la tx exterior. Internamente:
- Guarda el
TransactionStatusactual y la conexión JDBC asociada. - Solicita una conexión nueva del pool.
- Abre la nueva tx sobre esa conexión.
- Al terminar, libera la conexión nueva y reanuda la conexión anterior.
Implicaciones operativas:
- La tx anidada no comparte la sesión Hibernate con la exterior. Las entidades cargadas en la tx exterior están detached durante la ejecución de la nueva.
- Se consume una conexión adicional del pool mientras dura la suspensión. Si tu pool es de 10 y abres tx anidadas en 10 hilos a la vez, te quedas sin conexiones y el undécimo se bloquea.
- El cálculo de tamaño del pool debe contemplar la profundidad máxima de anidamiento.
Casos prácticos
Auditoría que debe persistir aun si el método falla
REQUIRES_NEW en el servicio de auditoría, llamado antes de la lógica que puede fallar y después de cualquier operación cuyo resultado quieras dejar trazado.
Idempotencia de webhooks
Un endpoint /webhook recibe un evento con request_id único. La idea es: si ya procesamos este id, devolver 200 sin reprocesar. La implementación:
@Transactional
public ResponseEntity<Void> recibir(WebhookEvent event) {
if (procesadosRepository.existsByRequestId(event.requestId())) {
return ResponseEntity.ok().build();
}
procesadosRepository.save(new Procesado(event.requestId()));
procesarLogicaNegocio(event);
return ResponseEntity.ok().build();
}
Con isolation default (READ_COMMITTED), dos webhooks simultáneos con el mismo id pueden hacer ambos existsBy = false y ambos guardar. La solución es constraint UNIQUE en request_id y captura de DataIntegrityViolationException para responder 200 de igual forma. La transacción se rebobina sola, sin hacer falta SERIALIZABLE.
Lectura masiva para reporting
Anotar el servicio de reporting con @Transactional(readOnly = true). La sesión Hibernate carga miles de objetos sin tracking, multiplicando el throughput por 3 o 4 en consultas pesadas. Si tu infraestructura tiene réplica de lectura, esto la dirige automáticamente al replica.
Buenas prácticas
@Transactionalen la capa de servicio, no en repositorios ni en controllers. Los repositorios ya están protegidos por defecto vía Spring Data; los controllers son demasiado de alto nivel.- Clase anotada
readOnly = truey métodos de escritura sobrescriben con@Transactional. Documenta la intención y reduce errores. rollbackFor = Exception.classpara servicios que lanzan checked. La regla por defecto de Spring es trampa heredada de EJB.- REQUIRES_NEW en otro bean siempre. Llamar a un método REQUIRES_NEW vía
this.lo deja en la misma transacción y nadie te avisa. - Isolation por defecto READ_COMMITTED y subir solo cuando una incoherencia concreta lo justifique. REPEATABLE_READ y SERIALIZABLE son caros bajo carga.
- Pool de conexiones dimensionado para la profundidad máxima de anidamiento. Cada REQUIRES_NEW consume una conexión extra mientras dura.
- Métricas de transacción: contar commits, rollbacks y duraciones por servicio con Micrometer. Una subida en rollbacks correlaciona con bugs nuevos antes de que el cliente los reporte.
@Transactionales la anotación más usada y la peor entendida de Spring. Conocer sus siete atributos a fondo es el filtro entre un desarrollador junior y uno senior. La diferencia se nota cuando el sistema está bajo carga y empiezan a aparecer corrupciones que en un entorno de pruebas con un solo hilo nunca se manifestaron.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Diferenciar los siete niveles de Propagation y su efecto sobre la transacción actual. Elegir el isolation adecuado para evitar dirty read, non-repeatable read y phantom read en PostgreSQL y MySQL. Activar readOnly = true para reducir presión sobre el persistence context. Configurar rollbackFor y noRollbackFor para excepciones checked. Diseñar auditoría inmune a rollback con REQUIRES_NEW.